From a4c85ed77adb4920d5a672e81f15556dd665c27a Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Thu, 15 Oct 2026 22:47:44 +0000 Subject: [PATCH] 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. --- README.md | 18 +++ app/globals.css | 31 +++++ app/layout.tsx | 2 + components/AeroMusic.tsx | 246 ++++++++++++++++++++++++++++++++++++++ components/icons.tsx | 20 ++++ lib/aero-music.ts | 53 ++++++++ lib/i18n-dict.ts | 12 ++ lib/legal.ts | 16 ++- lib/theme.ts | 6 + package.json | 2 +- public/sw.js | 2 +- tests/README.md | 1 + tests/aero-music-test.mjs | 164 +++++++++++++++++++++++++ tests/run-all.mjs | 1 + 14 files changed, 570 insertions(+), 4 deletions(-) create mode 100644 components/AeroMusic.tsx create mode 100644 lib/aero-music.ts create mode 100644 tests/aero-music-test.mjs 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. */} +