dvorakmortonxd.dev/src/app/components/AeroMusic.tsx

211 lines
7 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
/* Frutiger Aero soundtrack: a random track from the "Frutiger Aero" playlist on
SoundCloud. A hidden SoundCloud iframe is driven through their Widget API so
the playlist autoplays, jumps to a random track and re-randomises whenever
playback moves on; a small glass chip shows what's playing and gives the
visitor a play/pause control. The iframe is only mounted by `WebLayout` while
aero mode is active, so unmounting it stops the music. */
const PLAYLIST_URL = "https://soundcloud.com/rustie-gamer/sets/frutiger-aero";
const API_SRC = "https://w.soundcloud.com/player/api.js";
const VOLUME = 35;
/* Ignore PLAY events for this long after we pick a track: a `skip()` can still
report the previous index for a moment, which would otherwise bounce us into
an endless re-pick. */
const PICK_GUARD_MS = 3000;
type SoundCloudSound = {
title?: string;
user?: { username?: string };
};
type SoundCloudWidget = {
play: () => void;
pause: () => void;
skip: (index: number) => void;
setVolume: (volume: number) => void;
getSounds: (callback: (sounds: SoundCloudSound[]) => void) => void;
getCurrentSound: (callback: (sound: SoundCloudSound | null) => void) => void;
getCurrentSoundIndex: (callback: (index: number) => void) => void;
bind: (event: string, listener: () => void) => void;
};
type SoundCloudGlobal = {
Widget: ((element: HTMLIFrameElement) => SoundCloudWidget) & {
Events: { READY: string; PLAY: string; FINISH: string };
};
};
type Track = { title: string; artist: string };
const PLACEHOLDER: Track = { title: "Frutiger Aero", artist: "SoundCloud playlist" };
declare global {
interface Window {
SC?: SoundCloudGlobal;
}
}
const WIDGET_SRC =
"https://w.soundcloud.com/player/?url=" +
encodeURIComponent(PLAYLIST_URL) +
"&color=%2364e3ff&auto_play=true&hide_related=true&show_comments=false" +
"&show_user=false&show_reposts=false&show_teaser=false&visual=false";
let apiPromise: Promise<SoundCloudGlobal> | null = null;
/** Loads SoundCloud's Widget API once; later mounts reuse the same promise. */
function loadWidgetApi(): Promise<SoundCloudGlobal> {
if (window.SC) return Promise.resolve(window.SC);
apiPromise ??= new Promise<SoundCloudGlobal>((resolve, reject) => {
const script = document.createElement("script");
script.dataset.soundcloudApi = "";
script.src = API_SRC;
script.async = true;
script.addEventListener("load", () => {
if (window.SC) resolve(window.SC);
else reject(new Error("SoundCloud widget API did not initialise"));
});
script.addEventListener("error", () =>
reject(new Error("SoundCloud widget API failed to load")),
);
document.head.appendChild(script);
});
return apiPromise;
}
function toTrack(sound: SoundCloudSound | null | undefined): Track {
if (!sound) return PLACEHOLDER;
return {
title: sound.title || PLACEHOLDER.title,
artist: sound.user?.username || "SoundCloud",
};
}
export default function AeroMusic() {
const frameRef = useRef<HTMLIFrameElement>(null);
const widgetRef = useRef<SoundCloudWidget | null>(null);
const [track, setTrack] = useState<Track>(PLACEHOLDER);
const [playing, setPlaying] = useState(true);
useEffect(() => {
let cancelled = false;
/* The index we deliberately jumped to, and when — see PICK_GUARD_MS. */
let chosen = -1;
let pickedAt = 0;
let syncTimer = 0;
loadWidgetApi()
.then((SC) => {
const frame = frameRef.current;
if (cancelled || !frame) return;
const widget = SC.Widget(frame);
widgetRef.current = widget;
/* The chip mirrors what the player is actually on, so a skip that gets
ignored can't leave it lying. Partial reads are ignored rather than
blanking the label. */
const syncTrack = () => {
widget.getCurrentSound((sound) => {
if (cancelled || !sound || !sound.title) return;
setTrack({ title: sound.title, artist: sound.user?.username || "SoundCloud" });
});
};
const playRandomTrack = () => {
widget.getSounds((sounds) => {
if (cancelled || !sounds || sounds.length === 0) return;
let index = Math.floor(Math.random() * sounds.length);
/* Don't just replay whatever is already on. */
if (sounds.length > 1 && index === chosen) index = (index + 1) % sounds.length;
chosen = index;
pickedAt = Date.now();
widget.skip(index);
widget.play();
/* Show it straight away, then settle on the truth once the player
has caught up. */
setTrack(toTrack(sounds[index]));
window.clearTimeout(syncTimer);
syncTimer = window.setTimeout(syncTrack, 900);
});
};
widget.bind(SC.Widget.Events.READY, () => {
widget.setVolume(VOLUME);
playRandomTrack();
});
/* The set plays on in its own order — grab every chance to re-randomise
instead of following the playlist track by track. */
widget.bind(SC.Widget.Events.PLAY, () => {
setPlaying(true);
syncTrack();
if (Date.now() - pickedAt < PICK_GUARD_MS) return;
widget.getCurrentSoundIndex((index) => {
if (cancelled || index === chosen) return;
playRandomTrack();
});
});
/* Fires at the end of the set (or of the single selected track). */
widget.bind(SC.Widget.Events.FINISH, playRandomTrack);
/* Cover the case where READY already fired before we bound it. */
widget.setVolume(VOLUME);
playRandomTrack();
})
.catch(() => {
/* Offline, blocked or SoundCloud is down — the theme just stays silent. */
});
return () => {
cancelled = true;
window.clearTimeout(syncTimer);
widgetRef.current?.pause();
widgetRef.current = null;
};
}, []);
const toggle = () => {
const widget = widgetRef.current;
if (!widget) return;
if (playing) {
widget.pause();
setPlaying(false);
} else {
widget.play();
setPlaying(true);
}
};
return (
<>
<iframe
ref={frameRef}
className="web-aero-music-frame"
src={WIDGET_SRC}
title={`Frutiger Aero playlist on SoundCloud — ${track.title} by ${track.artist}`}
allow="autoplay"
loading="eager"
/>
<button
type="button"
className="web-aero-music"
onClick={toggle}
aria-pressed={playing}
title={playing ? "Pause the Frutiger Aero soundtrack" : "Play the Frutiger Aero soundtrack"}
>
<span className="web-aero-music-icon" aria-hidden="true">
{playing ? "❚❚" : "▶"}
</span>
<span className="web-aero-music-text">
<span className="web-aero-music-title">{track.title}</span>
<span className="web-aero-music-artist">{track.artist} · SoundCloud</span>
</span>
</button>
</>
);
}