New web project with Beat Saber tools

This commit is contained in:
Brian Lee
2025-08-09 00:16:28 -07:00
commit 6c2066d784
41 changed files with 11037 additions and 0 deletions
+132
View File
@@ -0,0 +1,132 @@
import { writable } from 'svelte/store';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
export type SongPlayerState = {
currentHash: string | null;
playing: boolean;
duration: number;
currentTime: number;
volume: number;
};
const DEFAULT_STATE: SongPlayerState = {
currentHash: null,
playing: false,
duration: 0,
currentTime: 0,
volume: 0.3
};
let audio: HTMLAudioElement | null = null;
let lastPreferredWasBL = false;
export const currentTimeStore = tweened(0, { duration: 60, easing: cubicOut });
const { subscribe, update, set } = writable<SongPlayerState>({ ...DEFAULT_STATE });
function cleanup(): void {
if (audio) {
audio.removeEventListener('timeupdate', handleTimeUpdate as any);
audio.removeEventListener('loadedmetadata', handleMetadata as any);
audio.removeEventListener('ended', handleEnded as any);
audio.removeEventListener('pause', handlePaused as any);
try {
audio.pause();
} catch {}
try {
audio.currentTime = 0;
} catch {}
}
audio = null;
}
function handleTimeUpdate(): void {
if (!audio) return;
currentTimeStore.set(audio.currentTime);
}
function handleMetadata(): void {
if (!audio) return;
update((state) => ({ ...state, duration: isFinite(audio.duration) ? audio.duration : 0 }));
}
function handleEnded(): void {
update((state) => ({ ...state, playing: false, currentHash: null }));
}
function handlePaused(): void {
update((state) => ({ ...state, playing: false }));
}
function buildAudioUrl(hash: string, preferBeatLeader: boolean): string {
const h = hash?.toLowerCase?.() ?? hash;
// Prefer BeatSaver CDN; optionally allow BeatLeader CDN
return preferBeatLeader
? `https://cdn.songs.beatleader.com/${h}.mp3`
: `https://eu.cdn.beatsaver.com/${h}.mp3`;
}
export function togglePlay(hash: string, preferBeatLeader = false): void {
if (!hash) return;
const url = buildAudioUrl(hash, preferBeatLeader);
const altUrl = buildAudioUrl(hash, !preferBeatLeader);
lastPreferredWasBL = preferBeatLeader;
update((state) => {
const shouldPlay = hash === state.currentHash ? !state.playing : true;
const initWithUrl = (initialUrl: string) => {
cleanup();
currentTimeStore.set(0);
audio = new Audio(initialUrl);
audio.volume = state.volume;
audio.addEventListener('timeupdate', handleTimeUpdate as any);
audio.addEventListener('loadedmetadata', handleMetadata as any);
audio.addEventListener('ended', handleEnded as any);
audio.addEventListener('pause', handlePaused as any);
// Fallback to alternate CDN once on error
let triedFallback = false;
audio.addEventListener('error', () => {
if (!audio || triedFallback) return;
triedFallback = true;
try {
const wasPlaying = shouldPlay;
audio.src = altUrl;
audio.load();
if (wasPlaying) audio.play?.();
} catch {}
}, { once: false } as any);
};
if (hash !== state.currentHash) {
initWithUrl(url);
} else if (!audio) {
initWithUrl(url);
}
if (shouldPlay) {
audio?.play?.();
} else {
audio?.pause?.();
}
return { ...state, currentHash: hash, playing: shouldPlay };
});
}
export function setVolume(volume: number): void {
const clamped = Math.max(0, Math.min(1, Number.isFinite(volume) ? volume : 0.3));
update((state) => {
if (audio) audio.volume = clamped;
return { ...state, volume: clamped };
});
}
export function reset(): void {
cleanup();
set({ ...DEFAULT_STATE });
}
export const songPlayerStore = { subscribe };