New web project with Beat Saber tools
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
const links = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/tools', label: 'Tools' },
|
||||
{ href: '/guides', label: 'Guides' }
|
||||
];
|
||||
let open = false;
|
||||
const toggle = () => (open = !open);
|
||||
const close = () => (open = false);
|
||||
const year = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<header class="sticky top-0 z-40 backdrop-blur supports-[backdrop-filter]:bg-surface/50 border-b border-white/10">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-14 items-center justify-between">
|
||||
<a href="/" class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neon" style="box-shadow: 0 0 12px rgba(34,211,238,0.60);"></span>
|
||||
<span class="font-display text-lg tracking-widest">
|
||||
<span class="neon-text">PLEBSABER</span><span class="text-muted">.stream</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav class="hidden md:flex items-center gap-6">
|
||||
{#each links as link}
|
||||
<a href={link.href} class="text-muted hover:text-white transition">{link.label}</a>
|
||||
{/each}
|
||||
<a href="/tools" class="btn-neon">Launch Tools</a>
|
||||
</nav>
|
||||
|
||||
<button class="md:hidden btn-neon px-3 py-1.5" on:click={toggle} aria-expanded={open} aria-controls="mobile-nav">
|
||||
Menu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if open}
|
||||
<div id="mobile-nav" class="md:hidden border-t border-white/10 bg-surface/80">
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-3 grid gap-3">
|
||||
{#each links as link}
|
||||
<a href={link.href} on:click={close} class="text-muted hover:text-white transition">{link.label}</a>
|
||||
{/each}
|
||||
<a href="/tools" on:click={close} class="btn-neon w-max">Launch Tools</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { songPlayerStore, currentTimeStore, togglePlay, setVolume } from '$lib/stores/songPlayer';
|
||||
|
||||
export let hash: string;
|
||||
export let preferBeatLeader = false;
|
||||
|
||||
let isCurrent = false;
|
||||
$: isCurrent = $songPlayerStore?.currentHash === hash;
|
||||
$: currentTime = isCurrent ? $currentTimeStore : 0;
|
||||
|
||||
function onToggle() {
|
||||
togglePlay(hash, preferBeatLeader);
|
||||
}
|
||||
|
||||
function onVolumeInput(e: Event) {
|
||||
const v = Number((e.target as HTMLInputElement).value);
|
||||
setVolume(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="player">
|
||||
<button class="play" on:click={onToggle} aria-label="Play/Pause">
|
||||
{#if isCurrent && $songPlayerStore?.playing}
|
||||
❚❚
|
||||
{:else}
|
||||
▶
|
||||
{/if}
|
||||
</button>
|
||||
<div class="timeline" title="Progress">
|
||||
<div class="progress" style="width: {($songPlayerStore?.currentHash ? (currentTime / ($songPlayerStore?.duration || 1)) : 0) * 100}%"></div>
|
||||
</div>
|
||||
<div class="time">
|
||||
{Math.floor(currentTime / 60)}:{String(Math.floor(currentTime % 60)).padStart(2, '0')} /
|
||||
{Math.floor(($songPlayerStore?.duration || 0) / 60)}:{String(Math.floor(($songPlayerStore?.duration || 0) % 60)).padStart(2, '0')}
|
||||
</div>
|
||||
<div class="volume">
|
||||
<input type="range" min="0" max="1" step="0.01" value={$songPlayerStore?.volume} on:input={onVolumeInput} />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.player { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.play { width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; background: transparent; color: white; cursor: pointer; }
|
||||
.timeline { flex: 1; height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; }
|
||||
.progress { height: 100%; background: rgba(255,255,255,0.6); }
|
||||
.time { font-size: 11px; opacity: 0.8; min-width: 80px; text-align: right; }
|
||||
.volume input { width: 80px; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
export * from './server/playlist';
|
||||
@@ -0,0 +1,121 @@
|
||||
const BASE_URL = 'https://api.beatleader.com';
|
||||
|
||||
// Simple in-memory cache for GET requests to BeatLeader
|
||||
// Caches JSON responses by URL for a short TTL to reduce backend load
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
type CacheEntry = { expiresAt: number; data: unknown };
|
||||
const responseCache: Map<string, CacheEntry> = new Map();
|
||||
|
||||
async function fetchJsonCached(fetchFn: typeof fetch, url: string, ttlMs = CACHE_TTL_MS): Promise<unknown> {
|
||||
const now = Date.now();
|
||||
const cached = responseCache.get(url);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
const res = await fetchFn(url);
|
||||
if (!res.ok) throw new Error(`BeatLeader request failed: ${res.status}`);
|
||||
const data = await res.json();
|
||||
responseCache.set(url, { expiresAt: now + ttlMs, data });
|
||||
return data;
|
||||
}
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
function buildQuery(params: QueryParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
const qs = searchParams.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
export class BeatLeaderAPI {
|
||||
private readonly fetchFn: typeof fetch;
|
||||
|
||||
constructor(fetchFn: typeof fetch) {
|
||||
this.fetchFn = fetchFn;
|
||||
}
|
||||
|
||||
async getPlayer(playerId: string): Promise<unknown> {
|
||||
const url = `${BASE_URL}/player/${encodeURIComponent(playerId)}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
}
|
||||
|
||||
async getPlayerScores(
|
||||
playerId: string,
|
||||
params: {
|
||||
page?: number;
|
||||
count?: number;
|
||||
leaderboardContext?: string;
|
||||
sortBy?: string | number;
|
||||
order?: 'asc' | 'desc' | string;
|
||||
search?: string;
|
||||
diff?: string;
|
||||
mode?: string;
|
||||
requirements?: string;
|
||||
type?: string;
|
||||
hmd?: string;
|
||||
modifiers?: string;
|
||||
stars_from?: string | number;
|
||||
stars_to?: string | number;
|
||||
eventId?: string | number;
|
||||
includeIO?: boolean;
|
||||
} = {}
|
||||
): Promise<unknown> {
|
||||
const query = buildQuery({
|
||||
page: params.page,
|
||||
count: params.count,
|
||||
leaderboardContext: params.leaderboardContext,
|
||||
sortBy: params.sortBy,
|
||||
order: params.order,
|
||||
search: params.search,
|
||||
diff: params.diff,
|
||||
mode: params.mode,
|
||||
requirements: params.requirements,
|
||||
type: params.type,
|
||||
hmd: params.hmd,
|
||||
modifiers: params.modifiers,
|
||||
stars_from: params.stars_from,
|
||||
stars_to: params.stars_to,
|
||||
eventId: params.eventId,
|
||||
includeIO: params.includeIO
|
||||
});
|
||||
|
||||
const url = `${BASE_URL}/player/${encodeURIComponent(playerId)}/scores${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
}
|
||||
|
||||
async getLeaderboard(
|
||||
hash: string,
|
||||
options: { diff?: string; mode?: string; page?: number; count?: number } = {}
|
||||
): Promise<unknown> {
|
||||
const diff = options.diff ?? 'ExpertPlus';
|
||||
const mode = options.mode ?? 'Standard';
|
||||
const query = buildQuery({ page: options.page, count: options.count });
|
||||
const url = `${BASE_URL}/v5/scores/${encodeURIComponent(hash)}/${encodeURIComponent(
|
||||
diff
|
||||
)}/${encodeURIComponent(mode)}${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
}
|
||||
|
||||
async getRankedLeaderboards(params: { stars_from?: number; stars_to?: number; page?: number; count?: number } = {}): Promise<unknown> {
|
||||
const query = buildQuery({
|
||||
page: params.page,
|
||||
count: params.count,
|
||||
type: 'ranked',
|
||||
stars_from: params.stars_from,
|
||||
stars_to: params.stars_to
|
||||
});
|
||||
const url = `${BASE_URL}/leaderboards${query}`;
|
||||
return fetchJsonCached(this.fetchFn, url);
|
||||
}
|
||||
}
|
||||
|
||||
export function createBeatLeaderAPI(fetchFn: typeof fetch): BeatLeaderAPI {
|
||||
return new BeatLeaderAPI(fetchFn);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
const BASE_URL = 'https://api.beatsaver.com';
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
function buildQuery(params: QueryParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
const qs = searchParams.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
// Minimal shapes for returned data
|
||||
export interface CuratedSongInfo {
|
||||
hash: string;
|
||||
key: string;
|
||||
songName: string;
|
||||
}
|
||||
|
||||
export interface MapperMapInfo extends CuratedSongInfo {
|
||||
date?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentMapInfo extends CuratedSongInfo {
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
interface BeatSaverApiOptions {
|
||||
cacheExpiryDays?: number;
|
||||
cacheDir?: string;
|
||||
maxRetries?: number;
|
||||
initialBackoffMs?: number;
|
||||
maxBackoffMs?: number;
|
||||
backoffFactor?: number;
|
||||
}
|
||||
|
||||
export class BeatSaverAPI {
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private readonly cacheExpiryMs: number;
|
||||
private readonly cacheDir: string;
|
||||
private readonly maxRetries: number;
|
||||
private readonly initialBackoffMs: number;
|
||||
private readonly maxBackoffMs: number;
|
||||
private readonly backoffFactor: number;
|
||||
|
||||
constructor(fetchFn: typeof fetch, options: BeatSaverApiOptions = {}) {
|
||||
this.fetchFn = fetchFn;
|
||||
this.cacheExpiryMs = (options.cacheExpiryDays ?? 1) * 24 * 60 * 60 * 1000;
|
||||
this.maxRetries = options.maxRetries ?? 5;
|
||||
this.initialBackoffMs = options.initialBackoffMs ?? 1000;
|
||||
this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
|
||||
this.backoffFactor = options.backoffFactor ?? 2;
|
||||
this.cacheDir = options.cacheDir ?? this.determineCacheDir();
|
||||
this.ensureCacheDir();
|
||||
}
|
||||
|
||||
// Public API
|
||||
async getCuratedSongs(useCache: boolean = true): Promise<CuratedSongInfo[]> {
|
||||
const cachePath = this.pathJoin(this.cacheDir, 'curated_songs.json');
|
||||
if (useCache) {
|
||||
const cached = await this.readCache<CuratedSongInfo[]>(cachePath);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const processed: CuratedSongInfo[] = [];
|
||||
let page = 0;
|
||||
|
||||
while (true) {
|
||||
const url = `${BASE_URL}/search/text/${page}${buildQuery({ sortOrder: 'Curated', curated: 'true' })}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getCuratedSongs failed: ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
for (const song of data?.docs ?? []) {
|
||||
for (const version of song?.versions ?? []) {
|
||||
processed.push({
|
||||
hash: version?.hash,
|
||||
key: song?.id,
|
||||
songName: song?.metadata?.songName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages: number = data?.info?.pages ?? 0;
|
||||
if (page >= totalPages - 1) break;
|
||||
page += 1;
|
||||
await this.sleep(1000);
|
||||
}
|
||||
|
||||
// Do not expire curated songs cache (mirror Python approach). We still write once and always use it if present.
|
||||
await this.writeCache(cachePath, processed);
|
||||
return processed;
|
||||
}
|
||||
|
||||
async getFollowedMappers(userId: number = 243016, useCache: boolean = true): Promise<unknown> {
|
||||
const cachePath = this.pathJoin(this.cacheDir, `followed_mappers_${userId}.json`);
|
||||
if (useCache && (await this.isCacheValid(cachePath))) {
|
||||
const cached = await this.readCache<unknown>(cachePath);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const url = `${BASE_URL}/users/followedBy/${encodeURIComponent(String(userId))}/0`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getFollowedMappers failed: ${res.status}`);
|
||||
const mappers = await res.json();
|
||||
await this.writeCache(cachePath, mappers);
|
||||
return mappers;
|
||||
}
|
||||
|
||||
async getMapperMaps(mapperId: number, useCache: boolean = true): Promise<MapperMapInfo[]> {
|
||||
const cachePath = this.pathJoin(this.cacheDir, `mapper_${mapperId}_maps.json`);
|
||||
if (useCache && (await this.isCacheValid(cachePath))) {
|
||||
const cached = await this.readCache<MapperMapInfo[]>(cachePath);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const processed: MapperMapInfo[] = [];
|
||||
let page = 0;
|
||||
while (true) {
|
||||
const url = `${BASE_URL}/search/text/${page}${buildQuery({ collaborator: String(mapperId), automapper: 'true', sortOrder: 'Latest' })}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getMapperMaps failed: ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
for (const song of data?.docs ?? []) {
|
||||
for (const version of song?.versions ?? []) {
|
||||
processed.push({
|
||||
hash: version?.hash,
|
||||
key: song?.id,
|
||||
songName: song?.metadata?.songName,
|
||||
date: song?.lastPublishedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages: number = data?.info?.pages ?? 0;
|
||||
if (page >= totalPages - 1) break;
|
||||
page += 1;
|
||||
await this.sleep(1000);
|
||||
}
|
||||
|
||||
await this.writeCache(cachePath, processed);
|
||||
return processed;
|
||||
}
|
||||
|
||||
async getMapsByEnvironment(
|
||||
environmentName: string,
|
||||
options: { maxPages?: number; useCache?: boolean } = {}
|
||||
): Promise<EnvironmentMapInfo[]> {
|
||||
const useCache = options.useCache ?? true;
|
||||
const cachePath = this.pathJoin(this.cacheDir, `environment_${environmentName}_maps.json`);
|
||||
if (useCache && (await this.isCacheValid(cachePath))) {
|
||||
const cached = await this.readCache<EnvironmentMapInfo[]>(cachePath);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const processed: EnvironmentMapInfo[] = [];
|
||||
let page = 0;
|
||||
const maxPages = options.maxPages ?? undefined;
|
||||
|
||||
while (true) {
|
||||
const url = `${BASE_URL}/search/text/${page}${buildQuery({ environments: `${environmentName}Environment` })}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getMapsByEnvironment failed: ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
for (const song of data?.docs ?? []) {
|
||||
for (const version of song?.versions ?? []) {
|
||||
processed.push({
|
||||
hash: version?.hash,
|
||||
key: song?.id,
|
||||
songName: song?.metadata?.songName,
|
||||
environment: environmentName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages: number = data?.info?.pages ?? 0;
|
||||
if (page >= totalPages - 1) break;
|
||||
page += 1;
|
||||
if (maxPages !== undefined && page >= maxPages) break;
|
||||
await this.sleep(1000);
|
||||
}
|
||||
|
||||
await this.writeCache(cachePath, processed);
|
||||
return processed;
|
||||
}
|
||||
|
||||
async getMapByHash(mapHash: string): Promise<unknown> {
|
||||
const url = `${BASE_URL}/maps/hash/${encodeURIComponent(mapHash)}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`BeatSaver getMapByHash failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Internal utilities
|
||||
private async request(url: string, init?: RequestInit): Promise<Response> {
|
||||
let attempt = 0;
|
||||
let backoffMs = this.initialBackoffMs;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const res = await this.fetchFn(url, init);
|
||||
if (res.status === 429) {
|
||||
attempt += 1;
|
||||
if (attempt > this.maxRetries) return res; // surface 429 if retries exceeded
|
||||
|
||||
const retryAfterHeader = res.headers.get('Retry-After');
|
||||
const retryAfterSec = retryAfterHeader ? Number(retryAfterHeader) : NaN;
|
||||
const waitMs = Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : backoffMs;
|
||||
await this.sleep(waitMs);
|
||||
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
attempt += 1;
|
||||
if (attempt > this.maxRetries) throw err;
|
||||
await this.sleep(backoffMs);
|
||||
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isCacheValid(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await this.fsPromises().stat(filePath);
|
||||
const ageMs = Date.now() - stat.mtimeMs;
|
||||
return ageMs < this.cacheExpiryMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async readCache<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const data = await this.fsPromises().readFile(filePath, 'utf-8');
|
||||
return JSON.parse(data) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeCache(filePath: string, data: unknown): Promise<void> {
|
||||
const fs = this.fsPromises();
|
||||
try {
|
||||
await fs.writeFile(filePath, JSON.stringify(data));
|
||||
} catch {
|
||||
// best-effort cache write; ignore
|
||||
}
|
||||
}
|
||||
|
||||
private determineCacheDir(): string {
|
||||
// Prefer ~/.cache/saberlist/beatsaver, fallback to CWD .cache
|
||||
const os = this.osModule();
|
||||
const path = this.pathModule();
|
||||
const home = os.homedir?.();
|
||||
const homeCache = home ? path.join(home, '.cache') : null;
|
||||
if (homeCache) {
|
||||
const saberlist = path.join(homeCache, 'saberlist');
|
||||
const beatsaver = path.join(saberlist, 'beatsaver');
|
||||
return beatsaver;
|
||||
}
|
||||
return this.pathJoin(process.cwd(), '.cache');
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
const fs = this.fsModule();
|
||||
const path = this.pathModule();
|
||||
const base = this.cacheDir;
|
||||
const parts = base.split(path.sep);
|
||||
let cur = parts[0] || path.sep;
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
cur = this.pathJoin(cur, parts[i]);
|
||||
if (!fs.existsSync(cur)) {
|
||||
try {
|
||||
fs.mkdirSync(cur);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private pathJoin(...segments: string[]): string {
|
||||
return this.pathModule().join(...segments);
|
||||
}
|
||||
|
||||
// Lazy require Node builtins to keep SSR-friendly import graph
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('fs') as typeof import('fs');
|
||||
}
|
||||
private pathModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('path') as typeof import('path');
|
||||
}
|
||||
private osModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('os') as typeof import('os');
|
||||
}
|
||||
}
|
||||
|
||||
export function createBeatSaverAPI(fetchFn: typeof fetch, options: BeatSaverApiOptions = {}): BeatSaverAPI {
|
||||
return new BeatSaverAPI(fetchFn, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
// Utilities for building Beat Saber playlists server-side
|
||||
|
||||
export interface Difficulty {
|
||||
name: string;
|
||||
characteristic: string;
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
hash: string;
|
||||
difficulties: Difficulty[];
|
||||
key?: string;
|
||||
levelId?: string;
|
||||
songName?: string;
|
||||
}
|
||||
|
||||
export interface CustomData {
|
||||
syncURL?: string | null;
|
||||
owner?: string | null;
|
||||
id?: string | null;
|
||||
hash?: string | null;
|
||||
shared?: boolean | null;
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
playlistTitle: string;
|
||||
songs: Song[];
|
||||
playlistAuthor?: string;
|
||||
image?: string | null;
|
||||
coverImage?: string | null;
|
||||
description?: string;
|
||||
allowDuplicates?: boolean;
|
||||
customData?: CustomData | null;
|
||||
}
|
||||
|
||||
type StandardizedSongInput = {
|
||||
hash: string;
|
||||
difficulties?: Difficulty[];
|
||||
key?: string;
|
||||
levelId?: string;
|
||||
songName?: string;
|
||||
};
|
||||
|
||||
interface PlaylistBuilderOptions {
|
||||
coversDir?: string;
|
||||
historyFile?: string;
|
||||
outputDir?: string;
|
||||
}
|
||||
|
||||
export class PlaylistBuilder {
|
||||
private readonly coversDir: string;
|
||||
private readonly historyFile: string;
|
||||
private readonly outputDir: string;
|
||||
private history: { cover_history: string[] };
|
||||
|
||||
constructor(options: PlaylistBuilderOptions = {}) {
|
||||
const cwd = this.processModule().cwd();
|
||||
this.coversDir = options.coversDir ?? this.pathModule().join(cwd, 'covers');
|
||||
this.historyFile = options.historyFile ?? this.pathModule().join(cwd, 'playlist_history.json');
|
||||
this.outputDir = options.outputDir ?? cwd;
|
||||
|
||||
this.ensureDirectory(this.coversDir);
|
||||
this.ensureDirectory(this.outputDir);
|
||||
this.history = this.loadHistory();
|
||||
this.saveHistory();
|
||||
}
|
||||
|
||||
async createPlaylist(
|
||||
playlistData: StandardizedSongInput[],
|
||||
playlistTitle: string = 'playlist',
|
||||
playlistAuthor: string = 'SaberList Tool'
|
||||
): Promise<string> {
|
||||
const songs: Song[] = (playlistData ?? []).map((song) => ({
|
||||
hash: song.hash,
|
||||
difficulties: (song.difficulties ?? []).map((d) => ({ name: d.name, characteristic: d.characteristic })),
|
||||
key: song.key,
|
||||
levelId: song.levelId,
|
||||
songName: song.songName
|
||||
}));
|
||||
|
||||
const coverPath = this.getRandomUnusedCover();
|
||||
const imageBase64 = coverPath ? await this.encodeImage(coverPath) : null;
|
||||
const imageDataUri = imageBase64 ? `data:image/png;base64,${imageBase64}` : null;
|
||||
|
||||
const playlist: Playlist = {
|
||||
playlistTitle,
|
||||
playlistAuthor,
|
||||
songs,
|
||||
image: imageDataUri,
|
||||
coverImage: coverPath ?? null,
|
||||
description: `Playlist created by SaberList Tool on ${new Date().toISOString()}`,
|
||||
allowDuplicates: false,
|
||||
customData: {}
|
||||
};
|
||||
|
||||
// Remove undefined fields recursively before writing
|
||||
const cleaned = this.removeUndefined(playlist);
|
||||
const filename = this.pathModule().join(
|
||||
this.outputDir,
|
||||
`${playlistTitle.replace(/\s+/g, '_')}.bplist`
|
||||
);
|
||||
await this.writeJsonFile(filename, cleaned);
|
||||
this.consoleModule().log(`Playlist created: ${filename}`);
|
||||
return filename;
|
||||
}
|
||||
|
||||
async splitPlaylist(inputPlaylistPath: string, songsPerPlaylist: number = 50): Promise<string[]> {
|
||||
const fs = this.fsPromises();
|
||||
try {
|
||||
await fs.access(inputPlaylistPath);
|
||||
} catch {
|
||||
this.consoleModule().error(`Input playlist file not found: ${inputPlaylistPath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(inputPlaylistPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as Playlist;
|
||||
|
||||
const playlistTitle = data.playlistTitle ?? 'Split Playlist';
|
||||
const playlistAuthor = data.playlistAuthor ?? 'SaberList Tool';
|
||||
const playlistDescription = data.description ?? '';
|
||||
const playlistImage = data.image ?? null;
|
||||
const playlistCoverImage = data.coverImage ?? null;
|
||||
const playlistCustomData = data.customData ?? null;
|
||||
|
||||
const songs = (data.songs ?? []) as Song[];
|
||||
const totalSongs = songs.length;
|
||||
if (totalSongs === 0) {
|
||||
this.consoleModule().warn('No songs found in the input playlist.');
|
||||
return [];
|
||||
}
|
||||
|
||||
const numPlaylists = Math.floor((totalSongs + songsPerPlaylist - 1) / songsPerPlaylist);
|
||||
const created: string[] = [];
|
||||
|
||||
for (let i = 0; i < numPlaylists; i++) {
|
||||
const startIdx = i * songsPerPlaylist;
|
||||
const endIdx = Math.min((i + 1) * songsPerPlaylist, totalSongs);
|
||||
const subsetSongs = songs.slice(startIdx, endIdx);
|
||||
|
||||
const subset: Playlist = {
|
||||
playlistTitle: `${playlistTitle} (${i + 1}/${numPlaylists})`,
|
||||
playlistAuthor,
|
||||
songs: subsetSongs,
|
||||
image: playlistImage,
|
||||
coverImage: playlistCoverImage,
|
||||
description: playlistDescription,
|
||||
allowDuplicates: false,
|
||||
customData: playlistCustomData
|
||||
};
|
||||
|
||||
const cleaned = this.removeUndefined(subset);
|
||||
const filename = this.pathModule().join(
|
||||
this.outputDir,
|
||||
`${playlistTitle.replace(/\s+/g, '_')}_${i + 1}_${numPlaylists}.bplist`
|
||||
);
|
||||
await this.writeJsonFile(filename, cleaned);
|
||||
this.consoleModule().log(
|
||||
`Created split playlist: ${filename} with ${subsetSongs.length} songs`
|
||||
);
|
||||
created.push(filename);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
// Internals
|
||||
private ensureDirectory(dir: string): void {
|
||||
const fs = this.fsModule();
|
||||
if (!fs.existsSync(dir)) {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
this.consoleModule().log(`Created directory: ${dir}`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private loadHistory(): { cover_history: string[] } {
|
||||
const fs = this.fsModule();
|
||||
try {
|
||||
if (fs.existsSync(this.historyFile)) {
|
||||
const raw = fs.readFileSync(this.historyFile, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as { cover_history?: string[] };
|
||||
return { cover_history: parsed.cover_history ?? [] };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { cover_history: [] };
|
||||
}
|
||||
|
||||
private saveHistory(): void {
|
||||
const fs = this.fsModule();
|
||||
try {
|
||||
fs.writeFileSync(this.historyFile, JSON.stringify(this.history));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private getRandomUnusedCover(): string | null {
|
||||
const fs = this.fsModule();
|
||||
const path = this.pathModule();
|
||||
let available: string[] = [];
|
||||
try {
|
||||
available = (fs.readdirSync(this.coversDir) as string[]).filter(
|
||||
(f) => f.endsWith('.jpg') && !this.history.cover_history.includes(f)
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (!available || available.length === 0) {
|
||||
this.consoleModule().warn('No unused cover images available. Using no cover.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const selected = available[Math.floor(Math.random() * available.length)];
|
||||
this.history.cover_history.push(selected);
|
||||
this.saveHistory();
|
||||
return path.join(this.coversDir, selected);
|
||||
}
|
||||
|
||||
private async encodeImage(imagePath: string): Promise<string> {
|
||||
const fs = this.fsPromises();
|
||||
const buf = await fs.readFile(imagePath);
|
||||
return buf.toString('base64');
|
||||
}
|
||||
|
||||
private async writeJsonFile(pathname: string, data: unknown): Promise<void> {
|
||||
const fs = this.fsPromises();
|
||||
await fs.writeFile(pathname, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
private removeUndefined<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((v) => this.removeUndefined(v)) as unknown as T;
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (v === undefined) continue;
|
||||
out[k] = this.removeUndefined(v as never);
|
||||
}
|
||||
return out as T;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Lazy Node built-ins for SSR friendliness
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('fs') as typeof import('fs');
|
||||
}
|
||||
private pathModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('path') as typeof import('path');
|
||||
}
|
||||
private processModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('process') as typeof import('process');
|
||||
}
|
||||
private consoleModule() {
|
||||
return console;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
const BASE_URL = 'https://scoresaber.com/api';
|
||||
|
||||
type QueryParams = Record<string, string | number | boolean | undefined | null>;
|
||||
|
||||
function buildQuery(params: QueryParams): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') return;
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
const qs = searchParams.toString();
|
||||
return qs ? `?${qs}` : '';
|
||||
}
|
||||
|
||||
interface ScoreSaberApiOptions {
|
||||
cacheExpiryDays?: number;
|
||||
cacheDir?: string;
|
||||
maxRetries?: number;
|
||||
initialBackoffMs?: number;
|
||||
maxBackoffMs?: number;
|
||||
backoffFactor?: number;
|
||||
}
|
||||
|
||||
export class ScoreSaberAPI {
|
||||
private readonly fetchFn: typeof fetch;
|
||||
private readonly cacheExpiryMs: number;
|
||||
private readonly cacheDir: string;
|
||||
private readonly maxRetries: number;
|
||||
private readonly initialBackoffMs: number;
|
||||
private readonly maxBackoffMs: number;
|
||||
private readonly backoffFactor: number;
|
||||
|
||||
constructor(fetchFn: typeof fetch, options: ScoreSaberApiOptions = {}) {
|
||||
this.fetchFn = fetchFn;
|
||||
this.cacheExpiryMs = (options.cacheExpiryDays ?? 1) * 24 * 60 * 60 * 1000;
|
||||
this.maxRetries = options.maxRetries ?? 5;
|
||||
this.initialBackoffMs = options.initialBackoffMs ?? 1000;
|
||||
this.maxBackoffMs = options.maxBackoffMs ?? 60_000;
|
||||
this.backoffFactor = options.backoffFactor ?? 2;
|
||||
this.cacheDir = options.cacheDir ?? this.determineCacheDir();
|
||||
this.ensureCacheDir();
|
||||
}
|
||||
|
||||
async getRankedMaps(params: {
|
||||
minStar?: number;
|
||||
maxStar?: number;
|
||||
useCache?: boolean;
|
||||
maxPages?: number;
|
||||
delayMsBetweenPages?: number;
|
||||
} = {}): Promise<unknown[]> {
|
||||
const minStar = params.minStar ?? 5;
|
||||
const maxStar = params.maxStar ?? 10;
|
||||
const useCache = params.useCache ?? true;
|
||||
const maxPages = params.maxPages ?? undefined;
|
||||
const delayMs = params.delayMsBetweenPages ?? 500;
|
||||
|
||||
const cachePath = this.pathJoin(this.cacheDir, `ranked_maps_${minStar}_${maxStar}.json`);
|
||||
if (useCache && (await this.isCacheValid(cachePath))) {
|
||||
const cached = await this.readCache<unknown[]>(cachePath);
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const all: unknown[] = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
if (maxPages !== undefined && page > maxPages) break;
|
||||
const url = `${BASE_URL}/leaderboards${buildQuery({
|
||||
minStar,
|
||||
maxStar,
|
||||
unique: 'true',
|
||||
ranked: 'true',
|
||||
page
|
||||
})}`;
|
||||
const res = await this.request(url);
|
||||
if (!res.ok) throw new Error(`ScoreSaber getRankedMaps failed: ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
|
||||
const leaderboards: unknown[] = data?.leaderboards ?? [];
|
||||
if (!leaderboards || leaderboards.length === 0) break;
|
||||
all.push(...leaderboards);
|
||||
|
||||
page += 1;
|
||||
await this.sleep(delayMs);
|
||||
}
|
||||
|
||||
await this.writeCache(cachePath, all);
|
||||
return all;
|
||||
}
|
||||
|
||||
async clearCache(minStar?: number, maxStar?: number): Promise<void> {
|
||||
const fs = this.fsPromises();
|
||||
if (minStar !== undefined && maxStar !== undefined) {
|
||||
const file = this.pathJoin(this.cacheDir, `ranked_maps_${minStar}_${maxStar}.json`);
|
||||
try {
|
||||
await fs.unlink(file);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(this.cacheDir, { withFileTypes: true });
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => fs.unlink(this.pathJoin(this.cacheDir, e.name)))
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
getCacheDir(): string {
|
||||
return this.cacheDir;
|
||||
}
|
||||
|
||||
// Internal utilities
|
||||
private async request(url: string, init?: RequestInit): Promise<Response> {
|
||||
let attempt = 0;
|
||||
let backoffMs = this.initialBackoffMs;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const res = await this.fetchFn(url, init);
|
||||
if (res.status === 429) {
|
||||
attempt += 1;
|
||||
if (attempt > this.maxRetries) return res; // surface 429 if retries exceeded
|
||||
|
||||
const retryAfterHeader = res.headers.get('Retry-After');
|
||||
const retryAfterSec = retryAfterHeader ? Number(retryAfterHeader) : NaN;
|
||||
const waitMs = Number.isFinite(retryAfterSec) ? retryAfterSec * 1000 : backoffMs;
|
||||
await this.sleep(waitMs);
|
||||
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
|
||||
continue;
|
||||
}
|
||||
return res;
|
||||
} catch (err) {
|
||||
attempt += 1;
|
||||
if (attempt > this.maxRetries) throw err;
|
||||
await this.sleep(backoffMs);
|
||||
backoffMs = Math.min(backoffMs * this.backoffFactor, this.maxBackoffMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async isCacheValid(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await this.fsPromises().stat(filePath);
|
||||
const ageMs = Date.now() - stat.mtimeMs;
|
||||
return ageMs < this.cacheExpiryMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async readCache<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const data = await this.fsPromises().readFile(filePath, 'utf-8');
|
||||
return JSON.parse(data) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeCache(filePath: string, data: unknown): Promise<void> {
|
||||
const fs = this.fsPromises();
|
||||
try {
|
||||
await fs.writeFile(filePath, JSON.stringify(data));
|
||||
} catch {
|
||||
// best-effort cache write; ignore
|
||||
}
|
||||
}
|
||||
|
||||
private determineCacheDir(): string {
|
||||
// Prefer ~/.cache/saberlist/scoresaber, fallback to CWD .cache
|
||||
const os = this.osModule();
|
||||
const path = this.pathModule();
|
||||
const home = os.homedir?.();
|
||||
const homeCache = home ? path.join(home, '.cache') : null;
|
||||
if (homeCache) {
|
||||
const saberlist = path.join(homeCache, 'saberlist');
|
||||
const scoresaber = path.join(saberlist, 'scoresaber');
|
||||
return scoresaber;
|
||||
}
|
||||
return this.pathJoin(process.cwd(), '.cache', 'scoresaber');
|
||||
}
|
||||
|
||||
private ensureCacheDir(): void {
|
||||
const fs = this.fsModule();
|
||||
const path = this.pathModule();
|
||||
const base = this.cacheDir;
|
||||
const parts = base.split(path.sep);
|
||||
let cur = parts[0] || path.sep;
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
cur = this.pathJoin(cur, parts[i]);
|
||||
if (!fs.existsSync(cur)) {
|
||||
try {
|
||||
fs.mkdirSync(cur);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private pathJoin(...segments: string[]): string {
|
||||
return this.pathModule().join(...segments);
|
||||
}
|
||||
|
||||
// Lazy require Node builtins to keep SSR-friendly import graph
|
||||
private fsPromises() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
return fs.promises as import('fs').Promises;
|
||||
}
|
||||
private fsModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('fs') as typeof import('fs');
|
||||
}
|
||||
private pathModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('path') as typeof import('path');
|
||||
}
|
||||
private osModule() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('os') as typeof import('os');
|
||||
}
|
||||
}
|
||||
|
||||
export function createScoreSaberAPI(
|
||||
fetchFn: typeof fetch,
|
||||
options: ScoreSaberApiOptions = {}
|
||||
): ScoreSaberAPI {
|
||||
return new ScoreSaberAPI(fetchFn, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -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