freezmo/components/AeroMusic.tsx
Martin Dvorak 724a068bec Aero music: the button works on the podcast player
"The music thing button with the Frutiger Aero doesn't work on the podcasts."
The podcast page is a full-screen overlay at z-45 and the control sat at z-40:
visible through the transparent page, but every tap landed on the overlay.

The control now takes a data-raised="player" position on /podcast/[id] -
z-index 46, docked to the header's corner (top under the header pill, right
mirroring the max-w-3xl geometry) where neither the transport bar nor the
voice panel lives - and the transcript gained pt-32 so the episode card clears
it. Study/review/exam feeds keep their "steps aside" behaviour.

aero-music-test: a podcast section asserts the control exists, that
elementFromPoint at its centre hits the button, and that a tap expands the
credit bar. 31 checks. Version 0.7.9, SW v101.
2026-10-16 21:04:39 +00:00

304 lines
11 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);
/** 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);
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],
);
/** 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;
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;
/* 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));
/* Shuffle onward: every track is drawn at random, not in order. */
instance.bind(FINISH, () => playRandom());
});
return () => {
cancelled = true;
widget.current = null;
};
}, [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]);
/* Something with a voice started (the podcast player): the soundtrack
steps aside rather than talking over it. */
useEffect(() => {
const onForeground = () => {
widget.current?.pause();
setAutoStart(false);
};
window.addEventListener("freezmo:foreground-audio", onForeground);
return () => window.removeEventListener("freezmo:foreground-audio", onForeground);
}, []);
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.startsWith("/podcast/")
? "player"
: 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=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"
/>
{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={() => {
/* An explicit choice either way: no gesture retries after it. */
setAutoStart(false);
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);
/* 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"
>
<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;