2025-10-17 10:18:35 -07:00

637 lines
23 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import SongPlayer from '$lib/components/SongPlayer.svelte';
type BeatLeaderScore = {
timeset?: string | number;
leaderboard?: {
// BeatLeader tends to expose a short id for the leaderboard route
id?: string | number | null;
leaderboardId?: string | number | null;
song?: { hash?: string | null };
difficulty?: { value?: number | string | null; modeName?: string | null };
};
};
type BeatLeaderScoresResponse = {
data?: BeatLeaderScore[];
metadata?: { page?: number; itemsPerPage?: number; total?: number };
};
type Difficulty = {
name: string;
characteristic: string;
};
type SongItem = {
hash: string;
difficulties: Difficulty[];
timeset: number;
leaderboardId?: string;
};
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60;
let playerA = '';
let playerB = '';
let loading = false;
let errorMsg: string | null = null;
let results: SongItem[] = [];
let loadingMeta = false;
// Sorting and pagination state
let sortBy: 'date' | 'difficulty' = 'date';
let sortDir: 'asc' | 'desc' = 'desc';
let page = 1;
let pageSize: number | string = 24;
$: pageSizeNum = Number(pageSize) || 24;
// Configurable lookback windows (months)
let monthsA: number | string = 6; // default 6 months
let monthsB: number | string = 24; // default 24 months
// Derived lists
$: sortedResults = [...results].sort((a, b) => {
let cmp = 0;
if (sortBy === 'date') {
cmp = a.timeset - b.timeset;
} else {
const an = a.difficulties[0]?.name ?? '';
const bn = b.difficulties[0]?.name ?? '';
cmp = an.localeCompare(bn);
}
return sortDir === 'asc' ? cmp : -cmp;
});
$: totalPages = Math.max(1, Math.ceil(sortedResults.length / pageSizeNum));
$: page = Math.min(page, totalPages);
$: pageItems = sortedResults.slice((page - 1) * pageSizeNum, (page - 1) * pageSizeNum + pageSizeNum);
type MapMeta = {
songName?: string;
key?: string;
coverURL?: string;
mapper?: string;
};
let metaByHash: Record<string, MapMeta> = {};
type StarInfo = {
stars?: number;
accRating?: number;
passRating?: number;
techRating?: number;
status?: number;
};
// Keyed by `${hash}|${difficultyName}|${modeName}` for precise lookup
let starsByKey: Record<string, StarInfo> = {};
let loadingStars = false;
// Toast notification state
let toastMessage = '';
let showToast = false;
let toastTimeout: number | null = null;
// Button feedback state - track which buttons are currently "lit up"
let litButtons = new Set<string>();
function showToastMessage(message: string) {
// Clear any existing toast
if (toastTimeout) {
clearTimeout(toastTimeout);
}
toastMessage = message;
showToast = true;
// Auto-hide toast after 3 seconds
toastTimeout = setTimeout(() => {
showToast = false;
toastMessage = '';
}, 3000);
}
function lightUpButton(buttonId: string) {
litButtons.add(buttonId);
// Reassign here too to trigger reactivity immediately
litButtons = litButtons;
// Remove the lighting effect after 1 second
setTimeout(() => {
litButtons.delete(buttonId);
litButtons = litButtons; // Trigger reactivity
}, 1000);
}
async function copyBsrCommand(key: string, hash: string) {
try {
const bsrCommand = `!bsr ${key}`;
await navigator.clipboard.writeText(bsrCommand);
// Show success feedback with the actual command
showToastMessage(`Copied "${bsrCommand}" to clipboard`);
lightUpButton(`bsr-${hash}`);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
showToastMessage('Failed to copy to clipboard');
}
}
async function fetchBeatSaverMeta(hash: string): Promise<MapMeta | null> {
try {
const res = await fetch(`https://api.beatsaver.com/maps/hash/${encodeURIComponent(hash)}`);
if (!res.ok) throw new Error(String(res.status));
const data: any = await res.json();
const cover = data?.versions?.[0]?.coverURL ?? `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg`;
return {
songName: data?.metadata?.songName ?? data?.name ?? undefined,
key: data?.id ?? undefined,
coverURL: cover,
mapper: data?.uploader?.name ?? undefined
};
} catch {
// Fallback to CDN cover only
return { coverURL: `https://cdn.beatsaver.com/${hash.toLowerCase()}.jpg` };
}
}
async function loadMetaForResults(items: SongItem[]): Promise<void> {
const needed = Array.from(new Set(items.map((i) => i.hash))).filter((h) => !metaByHash[h]);
if (needed.length === 0) return;
loadingMeta = true;
for (const h of needed) {
const meta = await fetchBeatSaverMeta(h);
if (meta) metaByHash = { ...metaByHash, [h]: meta };
}
loadingMeta = false;
}
async function fetchBeatLeaderStarsByHash(hash: string): Promise<void> {
try {
const res = await fetch(`/api/beatleader?path=/leaderboards/hash/${encodeURIComponent(hash)}`);
if (!res.ok) return;
const data: any = await res.json();
const leaderboards: any[] = Array.isArray(data?.leaderboards) ? data.leaderboards : Array.isArray(data) ? data : [];
for (const lb of leaderboards) {
const diffName: string | undefined = lb?.difficulty?.difficultyName ?? lb?.difficulty?.name ?? undefined;
const modeName: string | undefined = lb?.difficulty?.modeName ?? lb?.modeName ?? 'Standard';
if (!diffName || !modeName) continue;
const normalized = normalizeDifficultyName(diffName);
const key = `${hash}|${normalized}|${modeName}`;
const info: StarInfo = {
stars: lb?.difficulty?.stars ?? lb?.stars,
accRating: lb?.difficulty?.accRating,
passRating: lb?.difficulty?.passRating,
techRating: lb?.difficulty?.techRating,
status: lb?.difficulty?.status
};
starsByKey = { ...starsByKey, [key]: info };
}
} catch {
// ignore
}
}
async function loadStarsForResults(items: SongItem[]): Promise<void> {
const neededHashes = Array.from(new Set(items.map((i) => i.hash)));
if (neededHashes.length === 0) return;
loadingStars = true;
for (const h of neededHashes) {
await fetchBeatLeaderStarsByHash(h);
}
loadingStars = false;
}
function difficultyToColor(name: string | undefined): string {
const n = (name ?? 'ExpertPlus').toLowerCase();
if (n === 'easy') return 'MediumSeaGreen';
if (n === 'normal') return '#59b0f4';
if (n === 'hard') return 'tomato';
if (n === 'expert') return '#bf2a42';
if (n === 'expertplus' || n === 'expert+' || n === 'ex+' ) return '#8f48db';
return '#8f48db';
}
function normalizeDifficultyName(value: number | string | null | undefined): string {
if (value === null || value === undefined) return 'ExpertPlus';
if (typeof value === 'string') {
const v = value.toLowerCase();
if (v.includes('expertplus') || v === 'expertplus' || v === 'ex+' || v.includes('ex+')) return 'ExpertPlus';
if (v.includes('expert')) return 'Expert';
if (v.includes('hard')) return 'Hard';
if (v.includes('normal')) return 'Normal';
if (v.includes('easy')) return 'Easy';
return value;
}
switch (value) {
case 1:
return 'Easy';
case 3:
return 'Normal';
case 5:
return 'Hard';
case 7:
return 'Expert';
case 9:
return 'ExpertPlus';
default:
return 'ExpertPlus';
}
}
function parseTimeset(ts: string | number | undefined): number {
if (ts === undefined) return 0;
if (typeof ts === 'number') return ts;
const n = Number(ts);
return Number.isFinite(n) ? n : 0;
}
function getCutoffEpochFromMonths(months: number | string): number {
const m = Number(months) || 0;
const seconds = Math.max(0, m) * 30 * 24 * 60 * 60; // approx 30 days per month
return Math.floor(Date.now() / 1000) - seconds;
}
async function fetchAllRecentScores(playerId: string, cutoffEpoch: number, maxPages = 15): Promise<BeatLeaderScore[]> {
const qs = new URLSearchParams({ diff: 'ExpertPlus', cutoffEpoch: String(cutoffEpoch), maxPages: String(maxPages) });
const url = `/api/beatleader-cache/player/${encodeURIComponent(playerId)}?${qs.toString()}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch scores for ${playerId}: ${res.status}`);
const data = (await res.json()) as BeatLeaderScoresResponse;
return data.data ?? [];
}
function incrementPlaylistCount(): number {
try {
const raw = localStorage.getItem('playlist_counts');
const obj = raw ? (JSON.parse(raw) as Record<string, number>) : {};
const key = 'beatleader_compare_players';
const next = (obj[key] ?? 0) + 1;
obj[key] = next;
localStorage.setItem('playlist_counts', JSON.stringify(obj));
return next;
} catch {
return 1;
}
}
function toPlaylistJson(songs: SongItem[]): unknown {
const count = incrementPlaylistCount();
const playlistTitle = `beatleader_compare_players-${String(count).padStart(2, '0')}`;
return {
playlistTitle,
playlistAuthor: 'SaberList Tool',
songs: songs.map((s) => ({
hash: s.hash,
difficulties: s.difficulties,
})),
description: `A's recent songs not played by B. Generated ${new Date().toISOString()}`,
allowDuplicates: false,
customData: {}
};
}
function downloadPlaylist(): void {
const payload = toPlaylistJson(results);
const title = (payload as any).playlistTitle ?? 'playlist';
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${title}.bplist`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function onCompare(ev: SubmitEvent) {
ev.preventDefault();
errorMsg = null;
results = [];
const a = playerA.trim();
const b = playerB.trim();
if (!a || !b) {
errorMsg = 'Please enter both Player A and Player B IDs.';
return;
}
loading = true;
try {
const cutoff = getCutoffEpochFromMonths(monthsA);
const cutoffB = getCutoffEpochFromMonths(monthsB);
const [aScores, bScores] = await Promise.all([
fetchAllRecentScores(a, cutoff),
fetchAllRecentScores(b, cutoffB, 100)
]);
const bLeaderboardIds = new Set<string>();
const bExpertPlusKeys = new Set<string>(); // `${hashLower}|ExpertPlus|${modeName}`
for (const s of bScores) {
const rawHash = s.leaderboard?.song?.hash ?? undefined;
const hashLower = rawHash ? String(rawHash).toLowerCase() : undefined;
const lbIdRaw = (s.leaderboard as any)?.id ?? (s.leaderboard as any)?.leaderboardId;
const lbId = lbIdRaw != null ? String(lbIdRaw) : undefined;
const bDiffValue = s.leaderboard?.difficulty?.value ?? undefined;
const bModeName = s.leaderboard?.difficulty?.modeName ?? 'Standard';
const bDiffName = normalizeDifficultyName(bDiffValue);
if (bDiffName !== 'ExpertPlus') continue; // ignore non-ExpertPlus for B
if (lbId) bLeaderboardIds.add(lbId);
if (hashLower) bExpertPlusKeys.add(`${hashLower}|ExpertPlus|${bModeName}`);
}
const runSeen = new Set<string>(); // avoid duplicates within this run
const candidates: SongItem[] = [];
for (const entry of aScores) {
const t = parseTimeset(entry.timeset);
if (!t || t < cutoff) continue;
const rawHash = entry.leaderboard?.song?.hash ?? undefined;
const diffValue = entry.leaderboard?.difficulty?.value ?? undefined;
const modeName = entry.leaderboard?.difficulty?.modeName ?? 'Standard';
const leaderboardIdRaw = (entry.leaderboard as any)?.id ?? (entry.leaderboard as any)?.leaderboardId;
const leaderboardId = leaderboardIdRaw != null ? String(leaderboardIdRaw) : undefined;
if (!rawHash) continue;
const hashLower = String(rawHash).toLowerCase();
const diffName = normalizeDifficultyName(diffValue);
if (diffName !== 'ExpertPlus') continue; // Only compare ExpertPlus for A
// B has played ExpertPlus of same map+mode if matching leaderboard or key exists
if ((leaderboardId && bLeaderboardIds.has(leaderboardId)) || bExpertPlusKeys.has(`${hashLower}|${diffName}|${modeName}`)) continue;
const key = `${rawHash}|${diffName}|${modeName}`;
if (runSeen.has(key)) continue;
runSeen.add(key);
candidates.push({
hash: rawHash,
difficulties: [{ name: diffName, characteristic: modeName ?? 'Standard' }],
timeset: t,
leaderboardId
});
}
candidates.sort((x, y) => y.timeset - x.timeset);
const limited = candidates; // return all; pagination handled client-side
results = limited;
page = 1;
// Load BeatSaver metadata (covers, titles) for tiles
loadMetaForResults(limited);
// Load BeatLeader star ratings per hash/diff
loadStarsForResults(limited);
} catch (err) {
errorMsg = err instanceof Error ? err.message : 'Unknown error';
} finally {
loading = false;
}
}
onMount(() => {
// Try prefill from URL params if present
const sp = new URLSearchParams(location.search);
playerA = sp.get('a') ?? '';
playerB = sp.get('b') ?? '';
});
</script>
<section class="py-8">
<h1 class="font-display text-3xl sm:text-4xl">BeatLeader: Compare Players</h1>
<p class="mt-2 text-muted">Maps Player A has played that Player B hasn't — configurable lookback.</p>
<form class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 items-end" on:submit|preventDefault={onCompare}>
<div>
<label class="block text-sm text-muted">Player A ID (source)
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerA} placeholder="7656119... or BL ID" required />
</label>
</div>
<div>
<label class="block text-sm text-muted">Player B ID (target)
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" bind:value={playerB} placeholder="7656119... or BL ID" required />
</label>
</div>
<div>
<label class="block text-sm text-muted">Player A lookback (months)
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="number" min="0" max="120" bind:value={monthsA} />
</label>
</div>
<div>
<label class="block text-sm text-muted">Player B lookback (months)
<input class="mt-1 w-full rounded-md border border-white/10 bg-transparent px-3 py-2 text-sm outline-none" type="number" min="0" max="120" bind:value={monthsB} />
</label>
</div>
<div>
<button class="btn-neon" disabled={loading}>
{#if loading}
Loading...
{:else}
Compare
{/if}
</button>
</div>
</form>
{#if errorMsg}
<div class="mt-4 text-danger">{errorMsg}</div>
{/if}
{#if results.length > 0}
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-center gap-3 text-sm text-muted">
<span>{results.length} songs</span>
<span>·</span>
<label class="flex items-center gap-2">Sort
<select class="rounded-md border border-white/10 bg-transparent px-2 py-1 text-sm" bind:value={sortBy}>
<option value="date">Date</option>
<option value="difficulty">Difficulty</option>
</select>
</label>
<label class="flex items-center gap-2">Dir
<select class="rounded-md border border-white/10 bg-transparent px-2 py-1 text-sm" bind:value={sortDir}>
<option value="desc">Desc</option>
<option value="asc">Asc</option>
</select>
</label>
<label class="flex items-center gap-2">Page size
<select class="rounded-md border border-white/10 bg-transparent px-2 py-1 text-sm" bind:value={pageSize}>
<option value={12}>12</option>
<option value={24}>24</option>
<option value={36}>36</option>
<option value={48}>48</option>
</select>
</label>
</div>
<div class="flex items-center gap-3">
<button class="rounded-md border border-white/10 px-3 py-2 text-sm" on:click={downloadPlaylist}>Download .bplist</button>
</div>
</div>
{#if loadingMeta}
<div class="mt-2 text-xs text-muted">Loading covers…</div>
{/if}
{#if loadingStars}
<div class="mt-2 text-xs text-muted">Loading star ratings…</div>
{/if}
<div class="mt-4 grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each pageItems as item}
<article class="card-surface overflow-hidden">
<div class="aspect-square bg-black/30">
{#if metaByHash[item.hash]?.coverURL}
<img
src={metaByHash[item.hash].coverURL}
alt={metaByHash[item.hash]?.songName ?? item.hash}
loading="lazy"
class="h-full w-full object-cover"
/>
{:else}
<div class="h-full w-full flex items-center justify-center text-xs text-muted">No cover</div>
{/if}
</div>
<div class="p-3">
<div class="font-semibold truncate" title={metaByHash[item.hash]?.songName ?? item.hash}>
{metaByHash[item.hash]?.songName ?? item.hash}
</div>
{#if metaByHash[item.hash]?.mapper}
<div class="mt-0.5 text-xs text-muted truncate">{metaByHash[item.hash]?.mapper}</div>
{/if}
<div class="mt-2 flex items-center justify-between text-[11px]">
<span class="rounded bg-white/10 px-2 py-0.5">
{item.difficulties[0]?.characteristic ?? 'Standard'} ·
<span class="rounded px-1 ml-1" style="background-color: {difficultyToColor(item.difficulties[0]?.name)}; color: #fff;">
{item.difficulties[0]?.name}
</span>
</span>
<span class="text-muted">{new Date(item.timeset * 1000).toLocaleDateString()}</span>
</div>
{#if starsByKey[`${item.hash}|${item.difficulties[0]?.name ?? 'ExpertPlus'}|${item.difficulties[0]?.characteristic ?? 'Standard'}`]?.stars}
<div class="mt-1 text-xs">
{#key `${item.hash}|${item.difficulties[0]?.name}|${item.difficulties[0]?.characteristic}`}
<span title="BeatLeader star rating">★ {starsByKey[`${item.hash}|${item.difficulties[0]?.name ?? 'ExpertPlus'}|${item.difficulties[0]?.characteristic ?? 'Standard'}`]?.stars?.toFixed(2)}</span>
{/key}
</div>
{/if}
<div class="mt-3 flex items-center gap-2">
<div class="w-1/2 flex flex-wrap gap-2">
<a
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
href={item.leaderboardId
? `https://beatleader.com/leaderboard/global/${item.leaderboardId}`
: `https://beatleader.com/leaderboard/global/${item.hash}?diff=${encodeURIComponent(item.difficulties[0]?.name ?? 'ExpertPlus')}&mode=${encodeURIComponent(item.difficulties[0]?.characteristic ?? 'Standard')}`}
target="_blank"
rel="noopener"
title="Open in BeatLeader"
>BL</a
>
<a
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20"
href={metaByHash[item.hash]?.key ? `https://beatsaver.com/maps/${metaByHash[item.hash]?.key}` : `https://beatsaver.com/search/hash/${item.hash}`}
target="_blank"
rel="noopener"
title="Open in BeatSaver"
>BSR</a
>
<button
class="rounded-md border border-white/10 px-2 py-1 text-xs hover:border-white/20 disabled:opacity-50"
class:lit-up={litButtons.has(`bsr-${item.hash}`)}
on:click={() => { const key = metaByHash[item.hash]?.key; if (key) copyBsrCommand(key, item.hash); }}
disabled={!metaByHash[item.hash]?.key}
title="Copy !bsr"
>Copy !bsr</button>
</div>
<div class="w-1/2">
<SongPlayer hash={item.hash} preferBeatLeader={true} />
</div>
</div>
</div>
</article>
{/each}
</div>
{#if totalPages > 1}
<div class="mt-6 flex items-center justify-center gap-2">
<button class="rounded-md border border-white/10 px-3 py-1 text-sm disabled:opacity-50" on:click={() => (page = Math.max(1, page - 1))} disabled={page === 1}>
Prev
</button>
<span class="text-sm text-muted">Page {page} / {totalPages}</span>
<button class="rounded-md border border-white/10 px-3 py-1 text-sm disabled:opacity-50" on:click={() => (page = Math.min(totalPages, page + 1))} disabled={page === totalPages}>
Next
</button>
</div>
{/if}
{/if}
</section>
<!-- Toast Notification -->
{#if showToast}
<div class="toast-notification" role="status" aria-live="polite">
{toastMessage}
</div>
{/if}
<style>
.text-danger { color: #dc2626; }
/* Toast notification styles */
.toast-notification {
position: fixed;
top: 20px;
right: 20px;
background: #10b981;
color: white;
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
font-size: 14px;
font-weight: 500;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Button lighting effect */
.lit-up {
background: linear-gradient(45deg, #10b981, #059669);
border-color: #10b981;
color: white;
box-shadow: 0 0 10px rgba(16, 185, 129, 0.5);
animation: pulse 0.6s ease-out;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(16, 185, 129, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
}
}
</style>