211 lines
7 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|