Vellum/web/src/lib/components/GamePlayer.svelte
mithral b82090ed29 Phase 7: games — ROM scanning, platforms, cloud saves, EmulatorJS
The RomM replacement.

- registry: a games media type with ~35 ROM extensions across NES, SNES, GB/GBA,
  N64, NDS, Genesis/Master System/Game Gear, PC Engine, Neo Geo Pocket,
  WonderSwan, Atari, Coleco, Intellivision, and PlayStation.
- games: platform detection from the file extension, mapping each to an
  EmulatorJS core. Platforms with no browser core are recognised but marked
  not-playable so the UI offers a download instead of a broken player.
- migration 0005: game_saves keyed by (user, item, slot). Save states are user
  data, not media — two people playing the same ROM keep their own progress.
- save endpoints with a 16 MiB cap and a validated slot name; the upload is
  read through a LimitReader so an unbounded body is refused rather than
  buffered.
- igdb provider: Twitch client-credentials flow with the token cached for its
  ~2 month lifetime, Apicalypse query body, and cover URLs normalised from
  protocol-relative t_thumb to https t_cover_big.
- searchTitle: strips "(USA)", "[!]", "(Rev 1)" and similar release tags before
  searching. "Sonic the Hedgehog (USA)" matched nothing; "Sonic the Hedgehog"
  matches immediately. Stored titles keep the original.
- web: GamePlayer embeds EmulatorJS and adds Load/Save-to-server buttons.

Verified live against IGDB: Sonic matched (id 316900) with real cover art; a
4 KB save uploaded, listed, downloaded byte-identical, and deleted.
2026-10-09 21:54:49 -04:00

194 lines
5.2 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { api, ApiError } from '../api'
let { itemId }: { itemId: number } = $props()
// EmulatorJS loads its cores and data files from this path. It is a CDN by
// default, so playing a game needs outbound internet; the ROM itself never
// leaves the server.
const DATA_PATH = 'https://cdn.emulatorjs.org/stable/data/'
let platform = $state('')
let core = $state('')
let playable = $state(false)
let loading = $state(true)
let error = $state<string | null>(null)
let status = $state('')
let busy = $state(false)
let scriptEl: HTMLScriptElement | null = null
onMount(async () => {
try {
const g = await api.game(itemId)
platform = g.platform
core = g.core
playable = g.playable
if (playable) {
await boot()
}
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not open this game'
} finally {
loading = false
}
})
onDestroy(() => {
scriptEl?.remove()
const w = window as any
w.EJS_emulator = undefined
})
async function boot() {
const w = window as any
// EmulatorJS reads these globals when loader.js runs.
w.EJS_player = '#ejs-host'
w.EJS_core = core
w.EJS_gameUrl = api.fileUrl(itemId)
w.EJS_gameName = `vellum-${itemId}`
w.EJS_pathtodata = DATA_PATH
w.EJS_startOnLoaded = true
w.EJS_color = '#6d7cff'
w.EJS_backgroundColor = '#0a0a0c'
await loadScript(DATA_PATH + 'loader.js')
}
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = src
s.async = true
s.onload = () => resolve()
s.onerror = () => reject(new Error('could not load the emulator'))
document.body.appendChild(s)
scriptEl = s
})
}
function emulator(): any {
return (window as any).EJS_emulator
}
// Save states are the emulator's full snapshot, uploaded as raw bytes and
// kept per user, so two people playing the same ROM keep separate progress.
async function saveToServer() {
const emu = emulator()
if (!emu?.gameManager?.getState) {
status = 'The emulator is still starting.'
return
}
busy = true
status = ''
try {
const state: Uint8Array = emu.gameManager.getState()
const res = await fetch(api.saveUrl(itemId), {
method: 'PUT',
credentials: 'same-origin',
// Cast: the DOM lib types BlobPart as ArrayBuffer-backed, while
// Uint8Array is generic over ArrayBufferLike (which includes
// SharedArrayBuffer). The runtime value is always a normal buffer.
body: new Blob([state as BlobPart]),
})
if (!res.ok) throw new Error(`save failed (${res.status})`)
const info = await res.json()
status = `Saved ${Math.round(info.size / 1024)} KB to the server.`
} catch (err) {
status = err instanceof Error ? err.message : 'could not save'
} finally {
busy = false
}
}
async function loadFromServer() {
const emu = emulator()
if (!emu?.gameManager?.loadState) {
status = 'The emulator is still starting.'
return
}
busy = true
status = ''
try {
const res = await fetch(api.saveUrl(itemId), { credentials: 'same-origin' })
if (res.status === 404) {
status = 'No save on the server yet.'
return
}
if (!res.ok) throw new Error(`load failed (${res.status})`)
const bytes = new Uint8Array(await res.arrayBuffer())
emu.gameManager.loadState(bytes)
status = 'Loaded the save from the server.'
} catch (err) {
status = err instanceof Error ? err.message : 'could not load the save'
} finally {
busy = false
}
}
</script>
{#if error}
<div class="alert">{error}</div>
{:else if loading}
<div class="stage center"><div class="spinner"></div></div>
{:else if !playable}
<div class="empty">
<h3>{platform || 'Unrecognised ROM'}</h3>
<p>
{#if platform}
There is no in-browser emulator for {platform} yet.
{:else}
The file extension does not identify a known platform.
{/if}
</p>
<a class="btn" href={api.fileUrl(itemId)} download>Download the ROM</a>
</div>
{:else}
<div class="bar">
<span class="badge">{platform}</span>
<span class="spacer"></span>
<button class="btn" onclick={loadFromServer} disabled={busy}>Load save</button>
<button class="btn" onclick={saveToServer} disabled={busy}>Save to server</button>
</div>
{#if status}
<p class="status muted">{status}</p>
{/if}
<div class="stage">
<div id="ejs-host"></div>
</div>
{/if}
<style>
.bar {
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.spacer {
flex: 1;
}
.status {
margin: 0 0 var(--space-3);
font-size: 13px;
}
.stage {
height: calc(100vh - 60px - 150px);
min-height: 420px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: #000;
overflow: hidden;
}
.stage :global(#ejs-host) {
width: 100%;
height: 100%;
}
.center {
display: grid;
place-items: center;
}
</style>