diff --git a/README.md b/README.md index 9d2e143..da0c374 100644 --- a/README.md +++ b/README.md @@ -1492,6 +1492,24 @@ Things I chose while building, with the reasoning: into view as you type. No strings, so no i18n; the additive change sits on top of the merged scene-heat rework unchanged. +101. **Frutiger Aero has its soundtrack.** Picking the theme starts a random track from the + Frutiger Aero playlist (Bigblamboii's set on SoundCloud) and draws every following track at + random too, so the set shuffles itself. The player is the credit bar from the user's mock: a + 44px glass circle with a note by default, expanding into pause + the track's own title + + "SoundCloud · artist" (the widget reports both; the fallbacks are the playlist's own title and + author). Details that matter: the SoundCloud embed and its widget API are only ever created + 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 + 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). + 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 window edge) and a breakdown of 15 + 13 + 6 = 34, so the count was right and the *number of diff --git a/app/globals.css b/app/globals.css index fc248dc..8f8d188 100644 --- a/app/globals.css +++ b/app/globals.css @@ -908,6 +908,37 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) { visibility: hidden; } +/* Frutiger Aero's soundtrack player: a fixed glass circle (expanded, the + credit bar) in the bottom-right corner. It clears the phone tab bar, the + chat composer and the wizard action bar, and it steps aside with the rest + of the chrome while an immersive overlay (study feed, review, exam) is + open — the music keeps playing, the control comes back when you do. */ +.aero-music { + position: fixed; + right: max(0.75rem, env(safe-area-inset-right, 0px)); + bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px)); + z-index: 40; +} +@media (max-width: 639px) { + .aero-music { + /* Above the phone tab bar (5.25rem + safe area), plus the gutter. */ + bottom: calc(6rem + env(safe-area-inset-bottom, 0px)); + } +} +/* The chat composer owns the bottom of that screen (tab bar and all). */ +.aero-music[data-raised="chat"] { + bottom: calc(11.5rem + env(safe-area-inset-bottom, 0px)); +} +/* The wizard's action bar on phones. */ +.aero-music[data-raised="wizard"] { + bottom: calc(5.5rem + env(safe-area-inset-bottom, 0px)); +} +body:has(.overlay-open) .aero-music, +/* The one-time notice shares this corner; it wins while it is up. */ +body:has(.cookie-notice) .aero-music { + visibility: hidden; +} + /* The key gate rides on the shared ice scene (see .ice-scene). */ /* The fixed ice scene: navy/pale base, soft glacier blobs and glossy ice diff --git a/app/layout.tsx b/app/layout.tsx index bf34070..2ecd23f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; +import { AeroMusic } from "@/components/AeroMusic"; import { IceBackdrop } from "@/components/IceBackdrop"; import { MotionPreference } from "@/components/MotionPreference"; import { CookieConsent } from "@/components/CookieConsent"; @@ -109,6 +110,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {children} + diff --git a/components/AeroMusic.tsx b/components/AeroMusic.tsx new file mode 100644 index 0000000..a012a14 --- /dev/null +++ b/components/AeroMusic.tsx @@ -0,0 +1,246 @@ +"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. */} +