feat(web): play a random track from the Frutiger Aero SoundCloud playlist in aero mode (v2.2.22)
This commit is contained in:
parent
e4530afd98
commit
be33317d54
12 changed files with 387 additions and 83 deletions
211
src/app/components/AeroMusic.tsx
Normal file
211
src/app/components/AeroMusic.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue