freezmo/components/AeroMusic.tsx
Martin Dvorak d95c5c7151 Podcast mode: notes read aloud with a lyric-style word highlight
The third segment of the note-powered wizards (Study deck / Homework /
Podcast) takes the same intake - photos, documents, pasted text, the share
target - and turns it into a spoken episode.

- prompts: PODCAST_SYSTEM_PROMPT writes for the ear (plain prose, no markdown,
  no visual references), chaptered into 3-8 sections around a length target.
- /api/podcast mirrors /api/generate's plumbing (rate limit, model gate,
  provider keys, heartbeats, the 2x deadline, the optional vision pass).
- storage: frezmo.podcasts.v1, zod-validated, included in the backup and the
  encrypted sync payload; a new library tab lists episodes with listen /
  rename / delete-with-undo, and the wizard switch deep-links in.
- player: the transcript is word-wrapped spans and the word under the voice
  rides a lyric-style highlight that keeps itself centred. Two clocks: exact
  onboundary events where the browser reports them, a per-word length timer
  where it does not (iOS Safari); the first boundary retires the timer.
  Pause = cancel + re-speak from the word, which makes resume, chapter jumps
  and tap-any-word-to-seek the same operation; the position saves on pause,
  page-hide and unmount, so reopening resumes. Speed rebuilds the engine and
  keeps playing; the Aero soundtrack pauses itself when the voice starts; a
  browser with no voice gets an honest note and a readable transcript.
- the script sits on a glass pane (plain paragraphs over Aero's grass failed
  contrast; the scan now covers the player too).

tests/podcast-test.mjs (17th suite) stubs the model and the speech engine and
drives the highlight by hand: 26 checks. All suites green, contrast clean.
Version 0.7.3, SW v95.
2026-10-16 11:42:15 +00:00

297 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 === "/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;