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.
This commit is contained in:
parent
71c56ad06f
commit
a4c85ed77a
14 changed files with 570 additions and 4 deletions
18
README.md
18
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
|
into view as you type. No strings, so no i18n; the additive change sits on top of the merged
|
||||||
scene-heat rework unchanged.
|
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
|
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
|
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
|
window edge) and a breakdown of 15 + 13 + 6 = 34, so the count was right and the *number of
|
||||||
|
|
|
||||||
|
|
@ -908,6 +908,37 @@ body:has(.overlay-open) :is(.app-header, .segmented, .tabbar, .cookie-notice) {
|
||||||
visibility: hidden;
|
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 key gate rides on the shared ice scene (see .ice-scene). */
|
||||||
|
|
||||||
/* The fixed ice scene: navy/pale base, soft glacier blobs and glossy ice
|
/* The fixed ice scene: navy/pale base, soft glacier blobs and glossy ice
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
|
|
||||||
|
import { AeroMusic } from "@/components/AeroMusic";
|
||||||
import { IceBackdrop } from "@/components/IceBackdrop";
|
import { IceBackdrop } from "@/components/IceBackdrop";
|
||||||
import { MotionPreference } from "@/components/MotionPreference";
|
import { MotionPreference } from "@/components/MotionPreference";
|
||||||
import { CookieConsent } from "@/components/CookieConsent";
|
import { CookieConsent } from "@/components/CookieConsent";
|
||||||
|
|
@ -109,6 +110,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||||
<MotionPreference>
|
<MotionPreference>
|
||||||
<IceBackdrop />
|
<IceBackdrop />
|
||||||
{children}
|
{children}
|
||||||
|
<AeroMusic />
|
||||||
<InstallSheet />
|
<InstallSheet />
|
||||||
<OfflineBanner />
|
<OfflineBanner />
|
||||||
<CookieConsent />
|
<CookieConsent />
|
||||||
|
|
|
||||||
246
components/AeroMusic.tsx
Normal file
246
components/AeroMusic.tsx
Normal file
|
|
@ -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<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;
|
||||||
|
|
@ -420,6 +420,26 @@ export function ClockIcon({ className }: IconProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Frutiger Aero's soundtrack player. */
|
||||||
|
export function MusicIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg className={className}>
|
||||||
|
<path d="M9.4 17.2V6.6l8-1.6v10.4" />
|
||||||
|
<circle cx="6.9" cy="17.6" r="2.5" />
|
||||||
|
<circle cx="14.9" cy="15.8" r="2.5" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PauseIcon({ className }: IconProps) {
|
||||||
|
return (
|
||||||
|
<Svg className={className} filled>
|
||||||
|
<rect x="7.2" y="5.6" width="3.4" height="12.8" rx="1.4" />
|
||||||
|
<rect x="13.4" y="5.6" width="3.4" height="12.8" rx="1.4" />
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** "From your notes" — a page with a folded corner and text lines. */
|
/** "From your notes" — a page with a folded corner and text lines. */
|
||||||
export function NotesIcon({ className }: IconProps) {
|
export function NotesIcon({ className }: IconProps) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
53
lib/aero-music.ts
Normal file
53
lib/aero-music.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Frutiger Aero soundtrack's little store.
|
||||||
|
*
|
||||||
|
* The player itself lives in components/AeroMusic.tsx; this is the one bit of
|
||||||
|
* state that outlives it: whether the listener turned the music off. The flag
|
||||||
|
* clears when Aero is (re)selected in Settings — picking the theme again is
|
||||||
|
* the gesture that says "play it".
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = "freezmo.aero-music.v1";
|
||||||
|
|
||||||
|
let dismissed = false;
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
function load(): void {
|
||||||
|
if (loaded) return;
|
||||||
|
loaded = true;
|
||||||
|
try {
|
||||||
|
dismissed = window.localStorage.getItem(STORAGE_KEY) === "off";
|
||||||
|
} catch {
|
||||||
|
/* private mode */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while the Aero player may run (Aero on, not turned off). */
|
||||||
|
export function aeroMusicEnabled(): boolean {
|
||||||
|
load();
|
||||||
|
return !dismissed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The listener closed the player: stop until the theme is chosen again. */
|
||||||
|
export function dismissAeroMusic(): void {
|
||||||
|
dismissed = true;
|
||||||
|
loaded = true;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, "off");
|
||||||
|
} catch {
|
||||||
|
/* private mode */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selecting Aero again is fresh consent. */
|
||||||
|
export function allowAeroMusic(): void {
|
||||||
|
dismissed = false;
|
||||||
|
loaded = true;
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* private mode */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1037,6 +1037,12 @@ const en: Record<string, string> = {
|
||||||
"settings.theme": "Theme",
|
"settings.theme": "Theme",
|
||||||
"theme.aero": "Frutiger Aero",
|
"theme.aero": "Frutiger Aero",
|
||||||
"theme.aeroNote": "Aero has no light/dark switch — pick Light or Dark to get it back.",
|
"theme.aeroNote": "Aero has no light/dark switch — pick Light or Dark to get it back.",
|
||||||
|
"aero.music": "Frutiger Aero music",
|
||||||
|
"aero.musicPlay": "Play the music",
|
||||||
|
"aero.musicPause": "Pause the music",
|
||||||
|
"aero.musicClose": "Turn the music off",
|
||||||
|
"aero.musicCollapse": "Back to the music button",
|
||||||
|
"aero.musicLoading": "Loading the soundtrack…",
|
||||||
"settings.legal": "Legal",
|
"settings.legal": "Legal",
|
||||||
"consent.title": "About cookies",
|
"consent.title": "About cookies",
|
||||||
"consent.body": "Freezmo uses no cookies and no trackers — your decks, homework and settings stay in this browser.",
|
"consent.body": "Freezmo uses no cookies and no trackers — your decks, homework and settings stay in this browser.",
|
||||||
|
|
@ -2044,6 +2050,12 @@ const cs: Record<string, string> = {
|
||||||
"settings.theme": "Téma",
|
"settings.theme": "Téma",
|
||||||
"theme.aero": "Frutiger Aero",
|
"theme.aero": "Frutiger Aero",
|
||||||
"theme.aeroNote": "Aero nemá přepínač světlý/tmavý — vyber Světlý nebo Tmavý, pokud ho chceš zpět.",
|
"theme.aeroNote": "Aero nemá přepínač světlý/tmavý — vyber Světlý nebo Tmavý, pokud ho chceš zpět.",
|
||||||
|
"aero.music": "Hudba Frutiger Aero",
|
||||||
|
"aero.musicPlay": "Přehrát hudbu",
|
||||||
|
"aero.musicPause": "Pozastavit hudbu",
|
||||||
|
"aero.musicClose": "Vypnout hudbu",
|
||||||
|
"aero.musicCollapse": "Zpět na tlačítko hudby",
|
||||||
|
"aero.musicLoading": "Načítání hudby…",
|
||||||
"settings.legal": "Právní informace",
|
"settings.legal": "Právní informace",
|
||||||
"consent.title": "O cookies",
|
"consent.title": "O cookies",
|
||||||
"consent.body": "Freezmo nepoužívá cookies ani sledování — sady, úkoly a nastavení zůstávají v tomto prohlížeči.",
|
"consent.body": "Freezmo nepoužívá cookies ani sledování — sady, úkoly a nastavení zůstávají v tomto prohlížeči.",
|
||||||
|
|
|
||||||
16
lib/legal.ts
16
lib/legal.ts
|
|
@ -223,7 +223,13 @@ const COOKIES_EN: LegalDocument = {
|
||||||
{
|
{
|
||||||
heading: "No cookies",
|
heading: "No cookies",
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
"The server sends no Set-Cookie header and the app never touches document.cookie. There are no third-party embeds, no pixels and no tag managers.",
|
"The server sends no Set-Cookie header and the app never touches document.cookie. There are no pixels and no tag managers.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "The one third-party embed: Aero music",
|
||||||
|
paragraphs: [
|
||||||
|
"The Frutiger Aero theme can play its soundtrack from a SoundCloud playlist. That player is served by soundcloud.com, which may set its own cookies or storage there; Freezmo itself still sets none, and the embed only loads while the Aero theme is on and the player has not been turned off. Pause or close the player (or use another theme) and nothing from SoundCloud is requested.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -259,7 +265,13 @@ const COOKIES_CS: LegalDocument = {
|
||||||
{
|
{
|
||||||
heading: "Žádné cookies",
|
heading: "Žádné cookies",
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
"Server neposílá hlavičku Set-Cookie a aplikace se nikdy nedotkne document.cookie. Nejsou tu žádné vložené prvky třetích stran, pixely ani správci značek.",
|
"Server neposílá hlavičku Set-Cookie a aplikace se nikdy nedotkne document.cookie. Nejsou tu žádné pixely ani správci značek.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
heading: "Jediný vložený prvek třetí strany: hudba v Aeru",
|
||||||
|
paragraphs: [
|
||||||
|
"Téma Frutiger Aero umí přehrát svůj soundtrack ze SoundCloud playlistu. Přehrávač posílá soundcloud.com, který si tam může nastavit vlastní cookies nebo úložiště; Freezmo samo pořád žádné nepoužívá a prvek se načte jen tehdy, když je zapnuté téma Aero a přehrávač nebyl vypnutý. Pozastav nebo zavři přehrávač (nebo přepni téma) a ze SoundCloudu se nestahuje nic.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import { useSyncExternalStore } from "react";
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
import { allowAeroMusic } from "./aero-music";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The theme, as a shared store. It used to be component state inside
|
* The theme, as a shared store. It used to be component state inside
|
||||||
* `useTheme()`, which meant every caller (header, rail, chat, Settings) had
|
* `useTheme()`, which meant every caller (header, rail, chat, Settings) had
|
||||||
|
|
@ -61,9 +63,13 @@ export function syncThemeColor(theme: Theme) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyTheme(next: Theme) {
|
export function applyTheme(next: Theme) {
|
||||||
|
const previous = cached;
|
||||||
document.documentElement.classList.toggle("dark", next === "dark");
|
document.documentElement.classList.toggle("dark", next === "dark");
|
||||||
document.documentElement.classList.toggle("aero", next === "aero");
|
document.documentElement.classList.toggle("aero", next === "aero");
|
||||||
syncThemeColor(next);
|
syncThemeColor(next);
|
||||||
|
/* Picking Aero (again) is the gesture that starts its soundtrack, so it
|
||||||
|
also clears an earlier "turn the music off" (lib/aero-music). */
|
||||||
|
if (next === "aero" && previous !== "aero") allowAeroMusic();
|
||||||
cached = next;
|
cached = next;
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(STORAGE_KEY, next);
|
window.localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "freezmo",
|
"name": "freezmo",
|
||||||
"version": "0.6.9",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
*
|
*
|
||||||
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
|
||||||
*/
|
*/
|
||||||
const CACHE_VERSION = "freezmo-v91";
|
const CACHE_VERSION = "freezmo-v92";
|
||||||
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
|
||||||
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ prints a per-suite summary at the end.
|
||||||
| Suite | Covers |
|
| Suite | Covers |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `regression.mjs` | Library, deck card + menu, study feed to results, homework entry, desktop shell + settings/health panel. |
|
| `regression.mjs` | Library, deck card + menu, study feed to results, homework entry, desktop shell + settings/health panel. |
|
||||||
|
| `aero-music-test.mjs` | Frutiger Aero's soundtrack player (the SoundCloud widget API is stubbed): nothing loads in light/dark, a random playlist track starts in Aero, the circle expands into the credit bar, pause/collapse/close wiring, the off choice persisting and Aero re-selecting re-arming it, phone geometry over the tab bar and the chat composer, and the control stepping aside under an immersive overlay. |
|
||||||
| `bubbles-test.mjs` | Ice-scene bubbles: stacking/layout contract, the open band (a plain clip, no mask) and a 41-phase sweep that no visible orb or bubble ever touches its edges, keyframes only transform+opacity, rise/sway/scale/fade phases, per-breakpoint counts (5/3/2), phone pacing, reduced motion, parking (hidden tab, unfocused window, dialog scrim), and the cost contract (static blobs, no blend-mode grain, no blur on static glass, no hidden blur under Aero's bake). |
|
| `bubbles-test.mjs` | Ice-scene bubbles: stacking/layout contract, the open band (a plain clip, no mask) and a 41-phase sweep that no visible orb or bubble ever touches its edges, keyframes only transform+opacity, rise/sway/scale/fade phases, per-breakpoint counts (5/3/2), phone pacing, reduced motion, parking (hidden tab, unfocused window, dialog scrim), and the cost contract (static blobs, no blend-mode grain, no blur on static glass, no hidden blur under Aero's bake). |
|
||||||
| `mobile-test.mjs` | Five-tab bar at 360/390 × EN/CS × dark/light × reduced motion (labels, targets, no hamburger/drawer), the library ⋯ sheet (Import JSON / New folder), the settings sheet (touch targets, key flow, drag-to-close, backdrop), axe contrast audits. |
|
| `mobile-test.mjs` | Five-tab bar at 360/390 × EN/CS × dark/light × reduced motion (labels, targets, no hamburger/drawer), the library ⋯ sheet (Import JSON / New folder), the settings sheet (touch targets, key flow, drag-to-close, backdrop), axe contrast audits. |
|
||||||
| `wizard-test.mjs` | Deck 3-step flow and homework flow, flat action bar, in-page mode switch, the loading lock, abort-on-leave. |
|
| `wizard-test.mjs` | Deck 3-step flow and homework flow, flat action bar, in-page mode switch, the loading lock, abort-on-leave. |
|
||||||
|
|
|
||||||
164
tests/aero-music-test.mjs
Normal file
164
tests/aero-music-test.mjs
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
/**
|
||||||
|
* Frutiger Aero's soundtrack player: only in Aero, only while enabled; a
|
||||||
|
* random track starts, the credit bar expands from the circle, pause/close
|
||||||
|
* reach the widget, and the geometry clears the phone tab bar and the chat
|
||||||
|
* composer. The SoundCloud widget API is stubbed — the real one needs the
|
||||||
|
* network and the live playlist.
|
||||||
|
*/
|
||||||
|
import { BASE, DECK, launchBrowser, makeChecker, makePage, seed } from "./lib.mjs";
|
||||||
|
|
||||||
|
const { check, finish } = makeChecker();
|
||||||
|
const browser = await launchBrowser();
|
||||||
|
|
||||||
|
/** A tiny stand-in for https://w.soundcloud.com/player/api.js. */
|
||||||
|
const STUB = `(function(){
|
||||||
|
var sounds = [
|
||||||
|
{ title: "Track One", user: { username: "Bigblamboii" } },
|
||||||
|
{ title: "Track Two", user: { username: "Bigblamboii" } },
|
||||||
|
{ title: "Track Three", user: { username: "Bigblamboii" } }
|
||||||
|
];
|
||||||
|
window.__scStub = { skipped: [], plays: 0, pauses: 0, volume: null, current: 0, sounds: sounds, iframeSrc: null };
|
||||||
|
function Widget(el) {
|
||||||
|
window.__scStub.iframeSrc = el && el.getAttribute ? el.getAttribute("src") : null;
|
||||||
|
var handlers = {};
|
||||||
|
function emit(event) { (handlers[event] || []).forEach(function (h) { h(); }); }
|
||||||
|
var api = {
|
||||||
|
bind: function (event, handler) { (handlers[event] = handlers[event] || []).push(handler); },
|
||||||
|
getSounds: function (cb) { setTimeout(function () { cb(sounds); }, 10); },
|
||||||
|
getCurrentSound: function (cb) { setTimeout(function () { cb(sounds[window.__scStub.current]); }, 0); },
|
||||||
|
skip: function (index) { window.__scStub.skipped.push(index); window.__scStub.current = index; },
|
||||||
|
play: function () { window.__scStub.plays += 1; emit("play"); },
|
||||||
|
pause: function () { window.__scStub.pauses += 1; emit("pause"); },
|
||||||
|
setVolume: function (value) { window.__scStub.volume = value; }
|
||||||
|
};
|
||||||
|
setTimeout(function () { emit("ready"); }, 20);
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
Widget.Events = { READY: "ready", PLAY: "play", PAUSE: "pause", FINISH: "finish" };
|
||||||
|
window.SC = { Widget: Widget };
|
||||||
|
})();`;
|
||||||
|
|
||||||
|
async function stubSoundCloud(page) {
|
||||||
|
await page.route("https://w.soundcloud.com/player/api.js*", (route) =>
|
||||||
|
route.fulfill({ contentType: "application/javascript", body: STUB }),
|
||||||
|
);
|
||||||
|
await page.route("https://w.soundcloud.com/player/?*", (route) =>
|
||||||
|
route.fulfill({ contentType: "text/html", body: "<!doctype html><title>player</title>" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1. the embed belongs to Aero alone ---------------------------------- */
|
||||||
|
{
|
||||||
|
const { context, page } = await makePage(browser, { width: 1280, height: 731 });
|
||||||
|
await stubSoundCloud(page);
|
||||||
|
const requests = [];
|
||||||
|
page.on("request", (request) => {
|
||||||
|
if (request.url().includes("soundcloud.com")) requests.push(request.url());
|
||||||
|
});
|
||||||
|
await seed(page, { theme: "light", decks: [] });
|
||||||
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
check("light theme: no player", (await page.locator("[data-testid=aero-music]").count()) === 0);
|
||||||
|
check("light theme: nothing is requested from SoundCloud", requests.length === 0, JSON.stringify(requests.slice(0, 2)));
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. Aero: a random track starts, credits expand ---------------------- */
|
||||||
|
{
|
||||||
|
const { context, page } = await makePage(browser, { width: 1280, height: 731 });
|
||||||
|
await stubSoundCloud(page);
|
||||||
|
await seed(page, { theme: "aero", decks: [] });
|
||||||
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const circle = page.getByRole("button", { name: "Frutiger Aero music" });
|
||||||
|
check("aero: the circle is the default state", await circle.isVisible());
|
||||||
|
check("aero: no credits while collapsed", (await page.getByText("SoundCloud ·", { exact: false }).count()) === 0);
|
||||||
|
|
||||||
|
const stub = await page.evaluate(() => window.__scStub);
|
||||||
|
const index = stub.skipped[0];
|
||||||
|
check("aero: a random track from the playlist is chosen", Number.isInteger(index) && index >= 0 && index < stub.sounds.length, String(index));
|
||||||
|
check("aero: playback starts", stub.plays >= 1, String(stub.plays));
|
||||||
|
check("aero: the embed points at the playlist", decodeURIComponent(stub.iframeSrc ?? "").includes("soundcloud.com/rustie-gamer/sets/frutiger-aero"));
|
||||||
|
check("aero: background volume", stub.volume === 45, String(stub.volume));
|
||||||
|
|
||||||
|
await circle.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const bar = await page.evaluate(() => document.querySelector("[data-testid=aero-music]")?.innerText ?? "");
|
||||||
|
check("aero: expanding credits the track and the author", bar.includes("Track") && bar.includes("SoundCloud · Bigblamboii"), JSON.stringify(bar));
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Pause the music" }).click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
check("aero: pause reaches the widget", (await page.evaluate(() => window.__scStub.pauses)) >= 1);
|
||||||
|
|
||||||
|
/* The credit area collapses back to the circle without stopping. */
|
||||||
|
await page.getByRole("button", { name: "Back to the music button" }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
check("aero: the bar collapses back to the circle", await circle.isVisible());
|
||||||
|
|
||||||
|
await circle.click();
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.getByRole("button", { name: "Turn the music off" }).click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
check("aero: turning it off hides the player", (await page.locator("[data-testid=aero-music]").count()) === 0);
|
||||||
|
check("aero: the choice is remembered", (await page.evaluate(() => localStorage.getItem("freezmo.aero-music.v1"))) === "off");
|
||||||
|
await page.reload({ waitUntil: "networkidle" });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
check("aero: it stays off after a reload", (await page.locator("[data-testid=aero-music]").count()) === 0);
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3. picking Aero again is fresh consent ------------------------------ */
|
||||||
|
{
|
||||||
|
const { context, page } = await makePage(browser, { width: 1280, height: 731 });
|
||||||
|
await stubSoundCloud(page);
|
||||||
|
await seed(page, { theme: "dark", decks: [], extra: { "freezmo.aero-music.v1": "off" } });
|
||||||
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
check("dark theme: no player", (await page.locator("[data-testid=aero-music]").count()) === 0);
|
||||||
|
await page.locator("text=Settings").first().click({ force: true });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.getByRole("button", { name: "Frutiger Aero" }).first().click();
|
||||||
|
await page.waitForTimeout(1600);
|
||||||
|
check("selecting Aero again brings the music back", (await page.locator("[data-testid=aero-music]").count()) === 1);
|
||||||
|
check("selecting Aero again starts playback", ((await page.evaluate(() => window.__scStub?.plays)) ?? 0) >= 1);
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4. geometry: phones clear the tab bar and the chat composer --------- */
|
||||||
|
{
|
||||||
|
const { context, page } = await makePage(browser, { width: 390, height: 844, deviceScaleFactor: 2, isMobile: true, hasTouch: true });
|
||||||
|
await stubSoundCloud(page);
|
||||||
|
await seed(page, { theme: "aero", decks: [] });
|
||||||
|
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||||
|
await page.waitForTimeout(1400);
|
||||||
|
const circle = await page.locator("[data-testid=aero-music]").boundingBox();
|
||||||
|
const tabbar = await page.locator("nav.tabbar").boundingBox();
|
||||||
|
check("phone: the circle sits above the tab bar", Boolean(circle && tabbar) && circle.y + circle.height <= tabbar.y + 1, JSON.stringify({ circle, tabbar }));
|
||||||
|
check("phone: the circle is a 44px touch target", Boolean(circle) && circle.width >= 43.5 && circle.height >= 43.5, JSON.stringify(circle && { w: Math.round(circle.width), h: Math.round(circle.height) }));
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/chat`, { waitUntil: "networkidle" });
|
||||||
|
await page.waitForTimeout(1600);
|
||||||
|
const chatCircle = await page.locator("[data-testid=aero-music]").boundingBox();
|
||||||
|
const composer = await page.locator(".chat-composer-band").boundingBox();
|
||||||
|
check("phone chat: the circle sits above the composer", Boolean(chatCircle && composer) && chatCircle.y + chatCircle.height <= composer.y + 1, JSON.stringify({ chatCircle, composer }));
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 5. immersive overlays step aside, the music keeps playing ----------- */
|
||||||
|
{
|
||||||
|
const { context, page } = await makePage(browser, { width: 1280, height: 731 });
|
||||||
|
await stubSoundCloud(page);
|
||||||
|
await seed(page, { theme: "aero", decks: [DECK] });
|
||||||
|
await page.goto(`${BASE}/deck/seed_deck`, { waitUntil: "networkidle" });
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const hidden = await page.evaluate(() => {
|
||||||
|
const player = document.querySelector("[data-testid=aero-music]");
|
||||||
|
return player ? getComputedStyle(player).visibility : "missing";
|
||||||
|
});
|
||||||
|
check("study feed: the control steps aside (music keeps playing)", hidden === "hidden", hidden);
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
finish();
|
||||||
|
|
@ -14,6 +14,7 @@ import path from "node:path";
|
||||||
const SUITES = [
|
const SUITES = [
|
||||||
"regression",
|
"regression",
|
||||||
"activities-test",
|
"activities-test",
|
||||||
|
"aero-music-test",
|
||||||
"bubbles-test",
|
"bubbles-test",
|
||||||
"mobile-test",
|
"mobile-test",
|
||||||
"wizard-test",
|
"wizard-test",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue