freezmo/components/AeroMusic.tsx
Martin Dvorak a4c85ed77a Frutiger Aero: the theme now plays its soundtrack
Selecting Aero starts a random track from the Frutiger Aero playlist
(Bigblamboii's set on SoundCloud); every following track is drawn at random
too, so the set shuffles itself. The player is the credit bar from the mock:
a 44px glass circle with a note by default, expanding into pause + the
track's title + "SoundCloud - artist" (both reported by the widget).

- Only Aero ever touches SoundCloud: the embed and the widget API are created
  while the theme is on and the listener has not turned it off; light/dark
  request nothing (pinned by a suite check).
- The off choice persists (freezmo.aero-music.v1); re-selecting Aero in
  Settings is fresh consent (applyTheme clears it).
- Autoplay rides the theme-selection gesture; after a reload the circle comes
  up paused (browsers require a gesture) and one tap starts it.
- Volume 45; the iframe is 1px and inert, only the audio engine; the credit
  area collapses back to the circle without stopping the music.
- Geometry: 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. 44px targets on phones.
- Cookie policy gained the third-party-embed paragraph it was owed.

tests/aero-music-test.mjs (16th suite, stubbed widget API, 21 checks). All
suites green, contrast scan clean. Version 0.7.0, SW v92.
2026-10-15 22:47:44 +00:00

246 lines
9.1 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import { usePathname } from "next/navigation";
import { MusicIcon, PauseIcon, PlayIcon, XIcon } from "@/components/icons";
import { aeroMusicEnabled, dismissAeroMusic } from "@/lib/aero-music";
import { useI18n } from "@/lib/i18n";
import { useTheme } from "@/lib/theme";
import { cn } from "@/lib/utils";
/**
* Frutiger Aero's soundtrack. The theme is the opt-in: selecting it starts a
* random track from the Frutiger Aero playlist (Bigblamboii's set on
* SoundCloud) and every later track is drawn at random too, so the playlist
* shuffles itself.
*
* Collapsed it is a glass circle with a note; expanded it becomes the credit
* bar from the mock — pause, the track's own title, and "SoundCloud · artist".
* The embed is only ever created while Aero is on and the listener has not
* turned it off (see lib/aero-music); the player pauses, it never resumes on
* its own after a reload, because browsers require a gesture for that.
*/
const PLAYLIST_URL = "https://soundcloud.com/rustie-gamer/sets/frutiger-aero";
const API_SRC = "https://w.soundcloud.com/player/api.js";
/** Fallbacks until the widget reports the first track (the playlist's own). */
const FALLBACK_TITLE = "Frutiger Aero";
const FALLBACK_ARTIST = "SoundCloud · Bigblamboii";
/** Background music, not a concert. */
const VOLUME = 45;
type SoundCloudSound = { title?: string; user?: { username?: string } };
type SoundCloudWidget = {
bind: (event: string, handler: () => void) => void;
getSounds: (handler: (sounds: SoundCloudSound[]) => void) => void;
getCurrentSound: (handler: (sound: SoundCloudSound | null) => void) => void;
skip: (index: number) => void;
play: () => void;
pause: () => void;
setVolume: (volume: number) => void;
};
type SoundCloudApi = {
Widget: {
(element: HTMLIFrameElement, options?: Record<string, unknown>): SoundCloudWidget;
Events: { READY: string; PLAY: string; PAUSE: string; FINISH: string };
};
};
declare global {
interface Window {
SC?: SoundCloudApi;
}
}
let apiPromise: Promise<SoundCloudApi | null> | null = null;
/** The widget API script, fetched once per page and shared by every mount. */
function loadSoundCloudApi(): Promise<SoundCloudApi | null> {
if (typeof window === "undefined") return Promise.resolve(null);
if (window.SC) return Promise.resolve(window.SC);
apiPromise ??= new Promise<SoundCloudApi | null>((resolve) => {
const script = document.createElement("script");
script.src = API_SRC;
script.async = true;
script.onload = () => resolve(window.SC ?? null);
/* No network (offline, blocked): the player just never becomes ready. */
script.onerror = () => resolve(null);
document.head.appendChild(script);
});
return apiPromise;
}
export function AeroMusic() {
const { t } = useI18n();
const { theme } = useTheme();
const pathname = usePathname();
const iframe = useRef<HTMLIFrameElement>(null);
const widget = useRef<SoundCloudWidget | null>(null);
const trackCount = useRef(0);
const [allowed, setAllowed] = useState(false);
const [expanded, setExpanded] = useState(false);
const [playing, setPlaying] = useState(false);
const [ready, setReady] = useState(false);
const [track, setTrack] = useState<{ title: string; artist: string } | null>(null);
useEffect(() => {
if (theme === "aero") setAllowed(aeroMusicEnabled());
}, [theme]);
const show = theme === "aero" && allowed;
/** A random index of the playlist, so no two runs open on the same song. */
const randomIndex = () => Math.floor(Math.random() * Math.max(1, trackCount.current));
const refreshTrack = useCallback(() => {
widget.current?.getCurrentSound((sound) => {
if (!sound) return;
setTrack({
title: sound.title ?? FALLBACK_TITLE,
artist: `SoundCloud · ${sound.user?.username ?? "Bigblamboii"}`,
});
});
}, []);
const playRandom = useCallback(
(index?: number) => {
const target = index ?? randomIndex();
widget.current?.skip(target);
widget.current?.play();
refreshTrack();
},
[refreshTrack],
);
useEffect(() => {
if (!show) return;
const element = iframe.current;
if (!element) return;
let cancelled = false;
void loadSoundCloudApi().then((api) => {
if (cancelled || !api) return;
const instance = api.Widget(element);
widget.current = instance;
const { READY, PLAY, PAUSE, FINISH } = api.Widget.Events;
instance.bind(READY, () => {
if (cancelled) return;
setReady(true);
instance.setVolume(VOLUME);
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. */
playRandom();
});
});
instance.bind(PLAY, () => {
setPlaying(true);
refreshTrack();
});
instance.bind(PAUSE, () => setPlaying(false));
/* Shuffle onward: every track is drawn at random, not in order. */
instance.bind(FINISH, () => playRandom());
});
return () => {
cancelled = true;
widget.current = null;
};
}, [show, playRandom, refreshTrack]);
if (!show) return null;
const title = track?.title ?? FALLBACK_TITLE;
const artist = track?.artist ?? FALLBACK_ARTIST;
/* The composer sits at the bottom of the chat screen and the wizard has its
own action bar; everywhere else the phone tab bar is the only neighbour. */
const raised = pathname === "/chat" ? "chat" : pathname === "/create" || pathname === "/homework" ? "wizard" : undefined;
return (
<div className="aero-music" data-testid="aero-music" data-raised={raised}>
{/* The embed itself: 1px and inert, it is only the audio engine. */}
<iframe
ref={iframe}
title={t("aero.music")}
aria-hidden="true"
tabIndex={-1}
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`}
className="pointer-events-none absolute size-px opacity-0"
/>
{expanded ? (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
className="glass-pill flex max-w-[min(20rem,calc(100vw-1.5rem))] items-center gap-3 rounded-full py-1.5 pl-1.5 pr-1.5"
>
<button
type="button"
onClick={() => {
if (playing) {
widget.current?.pause();
return;
}
/* Resume where it stopped; the random draw is for starts. */
widget.current?.play();
refreshTrack();
}}
aria-label={playing ? t("aero.musicPause") : t("aero.musicPlay")}
title={playing ? t("aero.musicPause") : t("aero.musicPlay")}
className="touch-target grid size-11 shrink-0 place-items-center rounded-full bg-[var(--text)] text-[var(--page)] transition hover:opacity-90"
>
{playing ? <PauseIcon className="size-4" /> : <PlayIcon className="size-4" />}
</button>
<button
type="button"
onClick={() => setExpanded(false)}
aria-label={t("aero.musicCollapse")}
title={t("aero.musicCollapse")}
className="min-w-0 flex-1 py-1 text-left"
>
<p className="truncate text-[13px] font-semibold leading-tight text-[var(--text)]">
{ready ? title : t("aero.musicLoading")}
</p>
<p className="truncate text-[11.5px] leading-tight text-[var(--text-muted)]">{artist}</p>
</button>
<button
type="button"
onClick={() => {
widget.current?.pause();
dismissAeroMusic();
setAllowed(false);
}}
aria-label={t("aero.musicClose")}
title={t("aero.musicClose")}
className="touch-target grid size-11 shrink-0 place-items-center rounded-full text-[var(--text-muted)] transition hover:bg-[var(--hover)] hover:text-[var(--text)] sm:size-8"
>
<XIcon className="size-3.5" />
</button>
</motion.div>
) : (
<button
type="button"
onClick={() => setExpanded(true)}
aria-label={t("aero.music")}
title={t("aero.music")}
className="glass-pill touch-target grid size-11 place-items-center rounded-full"
>
<MusicIcon className={cn("size-5", playing ? "text-accent-700 dark:text-accent-200" : "text-[var(--text-muted)]")} />
</button>
)}
</div>
);
}
/** Exported for the suite: the playlist the widget must point at. */
export const AERO_PLAYLIST_URL = PLAYLIST_URL;