New web project with Beat Saber tools
This commit is contained in:
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user