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:
parent
a4c85ed77a
commit
1ad199fb56
5 changed files with 118 additions and 12 deletions
11
README.md
11
README.md
|
|
@ -1501,14 +1501,17 @@ Things I chose while building, with the reasoning:
|
|||
while Aero is on and the listener has not turned it off — light/dark request nothing from
|
||||
soundcloud.com, and a suite check pins that; the off choice persists (`freezmo.aero-music.v1`)
|
||||
while re-selecting Aero in Settings is fresh consent (`applyTheme` clears the flag); volume
|
||||
starts at 45; playback pauses rather than resumes after a reload (browsers require a gesture,
|
||||
so the circle comes up paused and one tap starts it); the iframe is 1px and inert, only the
|
||||
audio engine; the control clears the phone tab bar, lifts above the chat composer and the
|
||||
starts at 45; the iframe is 1px and inert, only the audio engine; the control clears the phone tab bar, lifts above the chat composer and the
|
||||
wizard bar, steps aside under immersive overlays and while the cookie notice shares the
|
||||
corner, and the credit area collapses back to the circle without stopping the music. The
|
||||
cookie policy gained the one third-party-embed paragraph it was owed (soundcloud.com may set
|
||||
its own cookies; Freezmo still sets none). `tests/aero-music-test.mjs` stubs the widget API
|
||||
and pins all of it (21 checks).
|
||||
and pins all of it (28 checks). **Follow-up:** it must not need a tap on the player at all. The
|
||||
embed URL now carries `auto_play=true` as a second try, and when the browser still refuses
|
||||
(a reload has no gesture) the retry rides the next `pointerdown`/`keydown` anywhere *outside*
|
||||
the player — so the first tap on anything starts the music, with a 700ms throttle so a flurry
|
||||
of taps cannot skip through the set. An explicit pause or the × is respected: no later gesture
|
||||
undoes it. The suite simulates the refusal (`__scAllowPlay`) and pins all three paths.
|
||||
|
||||
100. **The review breakdown carries each deck's own total.** The follow-up to 99: "bro its not
|
||||
possible, I only have 2 decks" — the screenshot showed three deck cards (one clipped at the
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "freezmo",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"private": true,
|
||||
"license": "GPL-3.0-only",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
*
|
||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||
*/
|
||||
const CACHE_VERSION = "freezmo-v92";
|
||||
const CACHE_VERSION = "freezmo-v93";
|
||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,13 @@ const STUB = `(function(){
|
|||
getSounds: function (cb) { setTimeout(function () { cb(sounds); }, 10); },
|
||||
getCurrentSound: function (cb) { setTimeout(function () { cb(sounds[window.__scStub.current]); }, 0); },
|
||||
skip: function (index) { window.__scStub.skipped.push(index); window.__scStub.current = index; },
|
||||
play: function () { window.__scStub.plays += 1; emit("play"); },
|
||||
play: function () {
|
||||
window.__scStub.plays += 1;
|
||||
/* A browser that has not seen a gesture yet refuses the start: the
|
||||
app must retry from the next real one. */
|
||||
if (window.__scAllowPlay === false) return;
|
||||
emit("play");
|
||||
},
|
||||
pause: function () { window.__scStub.pauses += 1; emit("pause"); },
|
||||
setVolume: function (value) { window.__scStub.volume = value; }
|
||||
};
|
||||
|
|
@ -80,6 +86,7 @@ async function stubSoundCloud(page) {
|
|||
check("aero: a random track from the playlist is chosen", Number.isInteger(index) && index >= 0 && index < stub.sounds.length, String(index));
|
||||
check("aero: playback starts", stub.plays >= 1, String(stub.plays));
|
||||
check("aero: the embed points at the playlist", decodeURIComponent(stub.iframeSrc ?? "").includes("soundcloud.com/rustie-gamer/sets/frutiger-aero"));
|
||||
check("aero: the embed itself asks to autoplay", (stub.iframeSrc ?? "").includes("auto_play=true"));
|
||||
check("aero: background volume", stub.volume === 45, String(stub.volume));
|
||||
|
||||
await circle.click();
|
||||
|
|
@ -108,6 +115,62 @@ async function stubSoundCloud(page) {
|
|||
await context.close();
|
||||
}
|
||||
|
||||
/* 2b. a refused autoplay starts from the next gesture ----------------- */
|
||||
{
|
||||
const { context, page } = await makePage(browser, { width: 1280, height: 731 });
|
||||
await stubSoundCloud(page);
|
||||
await seed(page, { theme: "aero", decks: [] });
|
||||
/* Load with autoplay refused, the way a browser without activation does. */
|
||||
await page.addInitScript(() => {
|
||||
window.__scAllowPlay = false;
|
||||
});
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(1500);
|
||||
const first = await page.evaluate(() => window.__scStub.plays);
|
||||
check("blocked autoplay: one attempt is made anyway", first >= 1, String(first));
|
||||
check(
|
||||
"blocked autoplay: the circle shows nothing playing yet",
|
||||
(await page.getByRole("button", { name: "Frutiger Aero music" }).count()) === 1,
|
||||
);
|
||||
await page.getByRole("button", { name: "Frutiger Aero music" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
check("blocked autoplay: play is offered while it waits", (await page.getByRole("button", { name: "Play the music" }).count()) === 1);
|
||||
await page.getByRole("button", { name: "Back to the music button" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
/* The browser would now allow it: the next tap anywhere starts the music.
|
||||
(The retry throttle is 700ms, so give the previous attempt room.) */
|
||||
await page.waitForTimeout(900);
|
||||
await page.evaluate(() => {
|
||||
window.__scAllowPlay = true;
|
||||
});
|
||||
const before = await page.evaluate(() => window.__scStub.plays);
|
||||
await page.evaluate(() => {
|
||||
document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
const after = await page.evaluate(() => window.__scStub.plays);
|
||||
check("blocked autoplay: the next tap starts the music", after > before, `${before} -> ${after}`);
|
||||
await page.getByRole("button", { name: "Frutiger Aero music" }).click();
|
||||
await page.waitForTimeout(400);
|
||||
check("after the gesture start the bar offers pause", (await page.getByRole("button", { name: "Pause the music" }).count()) === 1);
|
||||
|
||||
/* An explicit pause is respected: no gesture restarts it. */
|
||||
await page.getByRole("button", { name: "Pause the music" }).click();
|
||||
await page.waitForTimeout(300);
|
||||
const paused = await page.evaluate(() => window.__scStub.plays);
|
||||
await page.evaluate(() => {
|
||||
document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(600);
|
||||
check(
|
||||
"an explicit pause is not undone by later taps",
|
||||
(await page.evaluate(() => window.__scStub.plays)) === paused,
|
||||
`${paused} -> ${await page.evaluate(() => window.__scStub.plays)}`,
|
||||
);
|
||||
await context.close();
|
||||
}
|
||||
|
||||
/* 3. picking Aero again is fresh consent ------------------------------ */
|
||||
{
|
||||
const { context, page } = await makePage(browser, { width: 1280, height: 731 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue