"It should play by default when the Frutiger theme loads in." The embed URL now carries auto_play=true as a second try alongside the immediate play() call, and when the browser still refuses (a reload carries no gesture), the retry rides the next pointerdown/keydown anywhere OUTSIDE the player - the first tap on anything starts the music. A 700ms throttle keeps a flurry of taps from skipping through the set, taps on the player's own controls are left to their handlers (no blip on the x), and an explicit pause or dismiss is respected: no later gesture undoes it. Looking at the player (expanding the circle) starts a still-pending soundtrack too. aero-music-test simulates the refusal (__scAllowPlay) and pins all three paths: one attempt anyway, the next tap starts it, an explicit pause stays. 28 checks. Version 0.7.1, SW v93.
286 lines
11 KiB
TypeScript
286 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]);
|
|
|
|
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;
|