"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): SoundCloudWidget; Events: { READY: string; PLAY: string; PAUSE: string; FINISH: string }; }; }; declare global { interface Window { SC?: SoundCloudApi; } } let apiPromise: Promise | null = null; /** The widget API script, fetched once per page and shared by every mount. */ function loadSoundCloudApi(): Promise { if (typeof window === "undefined") return Promise.resolve(null); if (window.SC) return Promise.resolve(window.SC); apiPromise ??= new Promise((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(null); const widget = useRef(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 (
{/* The embed itself: 1px and inert, it is only the audio engine. */}