/**
* Frutiger Aero's soundtrack player: only in Aero, only while enabled; a
* random track starts, the credit bar expands from the circle, pause/close
* reach the widget, and the geometry clears the phone tab bar and the chat
* composer. The SoundCloud widget API is stubbed — the real one needs the
* network and the live playlist.
*/
import { BASE, DECK, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
const { check, finish } = makeChecker();
const browser = await launchBrowser();
/** A tiny stand-in for https://w.soundcloud.com/player/api.js. */
const STUB = `(function(){
var sounds = [
{ title: "Track One", user: { username: "Bigblamboii" } },
{ title: "Track Two", user: { username: "Bigblamboii" } },
{ title: "Track Three", user: { username: "Bigblamboii" } }
];
window.__scStub = { skipped: [], plays: 0, pauses: 0, volume: null, current: 0, sounds: sounds, iframeSrc: null };
function Widget(el) {
window.__scStub.iframeSrc = el && el.getAttribute ? el.getAttribute("src") : null;
var handlers = {};
function emit(event) { (handlers[event] || []).forEach(function (h) { h(); }); }
var api = {
bind: function (event, handler) { (handlers[event] = handlers[event] || []).push(handler); },
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;
/* 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; }
};
setTimeout(function () { emit("ready"); }, 20);
return api;
}
Widget.Events = { READY: "ready", PLAY: "play", PAUSE: "pause", FINISH: "finish" };
window.SC = { Widget: Widget };
})();`;
async function stubSoundCloud(page) {
await page.route("https://w.soundcloud.com/player/api.js*", (route) =>
route.fulfill({ contentType: "application/javascript", body: STUB }),
);
await page.route("https://w.soundcloud.com/player/?*", (route) =>
route.fulfill({ contentType: "text/html", body: "
player" }),
);
}
/* 1. the embed belongs to Aero alone ---------------------------------- */
{
const { context, page } = await makePage(browser, { width: 1280, height: 731 });
await stubSoundCloud(page);
const requests = [];
page.on("request", (request) => {
if (request.url().includes("soundcloud.com")) requests.push(request.url());
});
await seed(page, { theme: "light", decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1200);
check("light theme: no player", (await page.locator("[data-testid=aero-music]").count()) === 0);
check("light theme: nothing is requested from SoundCloud", requests.length === 0, JSON.stringify(requests.slice(0, 2)));
await context.close();
}
/* 2. Aero: a random track starts, credits expand ---------------------- */
{
const { context, page } = await makePage(browser, { width: 1280, height: 731 });
await stubSoundCloud(page);
await seed(page, { theme: "aero", decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
const circle = page.getByRole("button", { name: "Frutiger Aero music" });
check("aero: the circle is the default state", await circle.isVisible());
check("aero: no credits while collapsed", (await page.getByText("SoundCloud ·", { exact: false }).count()) === 0);
const stub = await page.evaluate(() => window.__scStub);
const index = stub.skipped[0];
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();
await page.waitForTimeout(500);
const bar = await page.evaluate(() => document.querySelector("[data-testid=aero-music]")?.innerText ?? "");
check("aero: expanding credits the track and the author", bar.includes("Track") && bar.includes("SoundCloud · Bigblamboii"), JSON.stringify(bar));
await page.getByRole("button", { name: "Pause the music" }).click();
await page.waitForTimeout(300);
check("aero: pause reaches the widget", (await page.evaluate(() => window.__scStub.pauses)) >= 1);
/* The credit area collapses back to the circle without stopping. */
await page.getByRole("button", { name: "Back to the music button" }).click();
await page.waitForTimeout(400);
check("aero: the bar collapses back to the circle", await circle.isVisible());
await circle.click();
await page.waitForTimeout(300);
await page.getByRole("button", { name: "Turn the music off" }).click();
await page.waitForTimeout(400);
check("aero: turning it off hides the player", (await page.locator("[data-testid=aero-music]").count()) === 0);
check("aero: the choice is remembered", (await page.evaluate(() => localStorage.getItem("freezmo.aero-music.v1"))) === "off");
await page.reload({ waitUntil: "networkidle" });
await page.waitForTimeout(1200);
check("aero: it stays off after a reload", (await page.locator("[data-testid=aero-music]").count()) === 0);
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 });
await stubSoundCloud(page);
await seed(page, { theme: "dark", decks: [], extra: { "freezmo.aero-music.v1": "off" } });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1200);
check("dark theme: no player", (await page.locator("[data-testid=aero-music]").count()) === 0);
await page.locator("text=Settings").first().click({ force: true });
await page.waitForTimeout(1200);
await page.getByRole("button", { name: "Frutiger Aero" }).first().click();
await page.waitForTimeout(1600);
check("selecting Aero again brings the music back", (await page.locator("[data-testid=aero-music]").count()) === 1);
check("selecting Aero again starts playback", ((await page.evaluate(() => window.__scStub?.plays)) ?? 0) >= 1);
await context.close();
}
/* 4. geometry: phones clear the tab bar and the chat composer --------- */
{
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
await stubSoundCloud(page);
await seed(page, { theme: "aero", decks: [] });
await page.goto(BASE, { waitUntil: "networkidle" });
await page.waitForTimeout(1400);
const circle = await page.locator("[data-testid=aero-music]").boundingBox();
const tabbar = await page.locator("nav.tabbar").boundingBox();
check("phone: the circle sits above the tab bar", Boolean(circle && tabbar) && circle.y + circle.height <= tabbar.y + 1, JSON.stringify({ circle, tabbar }));
check("phone: the circle is a 44px touch target", Boolean(circle) && circle.width >= 43.5 && circle.height >= 43.5, JSON.stringify(circle && { w: Math.round(circle.width), h: Math.round(circle.height) }));
await page.goto(`${BASE}/chat`, { waitUntil: "networkidle" });
await page.waitForTimeout(1600);
const chatCircle = await page.locator("[data-testid=aero-music]").boundingBox();
const composer = await page.locator(".chat-composer-band").boundingBox();
check("phone chat: the circle sits above the composer", Boolean(chatCircle && composer) && chatCircle.y + chatCircle.height <= composer.y + 1, JSON.stringify({ chatCircle, composer }));
await context.close();
}
/* 4b. the podcast player: the control rides above that overlay -------- */
{
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 1, isMobile: true, hasTouch: true });
await stubSoundCloud(page);
await seed(page, {
theme: "aero",
decks: [],
extra: {
"freezmo.podcasts.v1": JSON.stringify([
{
id: "pod_music",
title: "While you listen",
summary: "",
language: "English",
sourceText: "x",
createdAt: 1,
updatedAt: 1,
sections: [{ heading: "One", text: "Look up on a clear day." }],
},
]),
},
});
await page.goto(`${BASE}/podcast/pod_music`, { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
const circle = page.getByRole("button", { name: "Frutiger Aero music" });
check("podcast: the music control is there while listening", (await circle.count()) === 1);
const reachable = await page.evaluate(() => {
const button = document.querySelector("[data-testid=aero-music] button");
if (!button) return "missing";
const box = button.getBoundingClientRect();
const top = document.elementFromPoint(box.x + box.width / 2, box.y + box.height / 2);
return button.contains(top) || top === button ? "on-top" : `covered by ${top?.className?.toString().slice(0, 40)}`;
});
check("podcast: nothing covers it (it rides above the overlay)", reachable === "on-top", reachable);
await circle.click();
await page.waitForTimeout(400);
check("podcast: tapping it expands the credit bar", (await page.getByText("SoundCloud ·", { exact: false }).count()) >= 1);
await context.close();
}
/* 5. immersive overlays step aside, the music keeps playing ----------- */
{
const { context, page } = await makePage(browser, { width: 1280, height: 731 });
await stubSoundCloud(page);
await seed(page, { theme: "aero", decks: [DECK] });
await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
await page.waitForTimeout(1500);
const hidden = await page.evaluate(() => {
const player = document.querySelector("[data-testid=aero-music]");
return player ? getComputedStyle(player).visibility : "missing";
});
check("study feed: the control steps aside (music keeps playing)", hidden === "hidden", hidden);
await context.close();
}
await browser.close();
finish();