3ae6f43ccb
Build & Deploy / build-and-deploy (push) Successful in 50s
Global: - Viewport meta already present; added -webkit-text-size-adjust: 100% - Container padding reduces to 12px on mobile (was 24px) - Smaller heading sizes (h1: 1.5rem, h2: 1.25rem) - Buttons get min-height 40px for touch targets - Inputs forced to 16px font-size to prevent iOS zoom - Thinner scrollbar on mobile Header: - Logo text shrinks to 1rem on mobile, icon to 28px - Header padding reduces to 12px Library: - Toolbar stacks vertically on mobile, full-width inputs - Grid forced to 2 columns on narrow screens (480px), reduced gap GameCard: - Smaller info text, padding, badge on 480px screens GameDetail: - Cover section capped at 200px width on mobile - Media thumbnails shrink to 160x90 - Scrape header stacks vertically with full-width buttons - Edit rows stack vertically on mobile Play: - DOS container caps at 50vh on mobile, smaller border-radius - Overlay text/icon sizes reduced, more compact layout
244 lines
6.1 KiB
Svelte
244 lines
6.1 KiB
Svelte
<script>
|
|
import { getGame, bundleUrl } from "../lib/api.js";
|
|
import { push } from "../lib/router.js";
|
|
|
|
let { id } = $props();
|
|
|
|
let game = $state(null);
|
|
let loading = $state(true);
|
|
let error = $state("");
|
|
let booting = $state(false);
|
|
let running = $state(false);
|
|
let started = false;
|
|
|
|
let dosContainer;
|
|
let dosCI = null;
|
|
|
|
async function load() {
|
|
loading = true;
|
|
try {
|
|
game = await getGame(id);
|
|
} catch (e) {
|
|
error = "Game not found";
|
|
}
|
|
loading = false;
|
|
}
|
|
|
|
async function startEmulator() {
|
|
if (started || !game?.ready || !dosContainer) return;
|
|
started = true;
|
|
booting = true;
|
|
|
|
// Load js-dos CSS
|
|
const link = document.createElement("link");
|
|
link.rel = "stylesheet";
|
|
link.href = "https://v8.js-dos.com/latest/js-dos.css";
|
|
document.head.appendChild(link);
|
|
|
|
// Load js-dos script from CDN
|
|
if (!window.Dos) {
|
|
try {
|
|
await new Promise((resolve, reject) => {
|
|
const s = document.createElement("script");
|
|
s.src = "https://v8.js-dos.com/latest/js-dos.js";
|
|
s.onload = resolve;
|
|
s.onerror = reject;
|
|
document.head.appendChild(s);
|
|
});
|
|
} catch {
|
|
error = "Failed to load emulator. Check your internet connection.";
|
|
booting = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Start the DOS emulator
|
|
try {
|
|
const url = bundleUrl(game);
|
|
if (!url || !window.Dos) throw new Error("Failed to resolve game bundle");
|
|
dosCI = await window.Dos(dosContainer, { url });
|
|
running = true;
|
|
} catch (e) {
|
|
error = e.message;
|
|
}
|
|
booting = false;
|
|
}
|
|
|
|
function stopEmulator() {
|
|
// Force full page reload which terminates all workers, AudioContexts
|
|
// and WASM threads. Hash is preserved so the SPA routes correctly.
|
|
window.location.hash = `#/game/${game.id}`;
|
|
window.location.reload();
|
|
}
|
|
|
|
$effect(() => { load(); });
|
|
|
|
// Auto-start when both game data and the container exist
|
|
$effect(() => {
|
|
if (game?.ready && dosContainer && !started) {
|
|
startEmulator();
|
|
}
|
|
});
|
|
|
|
// Cleanup emulator on component unmount (browser back, nav away)
|
|
$effect(() => {
|
|
return () => {
|
|
if (dosCI) {
|
|
try { dosCI.exit(); } catch (_) {}
|
|
dosCI = null;
|
|
}
|
|
if (dosContainer) {
|
|
dosContainer.innerHTML = "";
|
|
}
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="play-page">
|
|
<!-- Top bar: shown when game is loaded -->
|
|
<div class="play-header" class:active={game}>
|
|
{#if game}
|
|
<button class="link-button" onclick={stopEmulator}>← {game.title}</button>
|
|
{#if running}
|
|
<button class="btn stop-btn" onclick={stopEmulator}>⏹ Stop</button>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Error state -->
|
|
{#if error && !booting}
|
|
<div class="error-box">{error}</div>
|
|
{/if}
|
|
|
|
<!-- Loading / booting overlay (covers the canvas while loading) -->
|
|
{#if !running}
|
|
<div class="overlay">
|
|
{#if loading}
|
|
<div class="overlay-icon">⏳</div>
|
|
<p>Loading game data...</p>
|
|
{:else if booting}
|
|
<div class="overlay-icon">⚙️</div>
|
|
<h2>{game?.title || ""}</h2>
|
|
<p>Starting emulator...</p>
|
|
<p class="save-hint">💾 Game saves are stored automatically</p>
|
|
{:else if error}
|
|
<div class="overlay-icon">⚠️</div>
|
|
<h2>Failed to start</h2>
|
|
<p>{error}</p>
|
|
{:else if !game?.ready}
|
|
<div class="overlay-icon">🕹️</div>
|
|
<h2>Not ready</h2>
|
|
<p>This game hasn't been processed yet.</p>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- dos-container is always in the DOM so bind:this works on first render -->
|
|
<div bind:this={dosContainer} class="dos-container"></div>
|
|
</div>
|
|
|
|
<style>
|
|
.play-page { padding: 16px 0; position: relative; }
|
|
@media (max-width: 640px) { .play-page { padding: 8px 0; } }
|
|
.play-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 12px;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
}
|
|
.play-header.active { opacity: 1; }
|
|
.link-button {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-dim);
|
|
font-size: 0.9rem;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
font-family: var(--font-sans);
|
|
}
|
|
.link-button:hover { color: var(--phosphor); }
|
|
.stop-btn {
|
|
border-color: #cc3333;
|
|
color: #ff4444;
|
|
}
|
|
.stop-btn:hover {
|
|
background: #331111;
|
|
box-shadow: 0 0 12px #331111;
|
|
}
|
|
|
|
/* Emulator canvas — always mounted */
|
|
.dos-container {
|
|
width: 100%;
|
|
aspect-ratio: 4/3;
|
|
max-height: 80vh;
|
|
background: #000;
|
|
border-radius: var(--radius);
|
|
overflow: hidden;
|
|
border: 1px solid var(--border);
|
|
}
|
|
@media (max-width: 640px) {
|
|
.dos-container { aspect-ratio: 4/3; max-height: 50vh; border-radius: var(--radius-sm); }
|
|
}
|
|
.dos-container :global(canvas) {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
}
|
|
|
|
/* Overlay shown while loading / booting, covers the empty container */
|
|
.overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
top: 50px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
text-align: center;
|
|
background: var(--bg);
|
|
z-index: 10;
|
|
border-radius: var(--radius);
|
|
border: 1px dashed var(--border);
|
|
margin-top: 4px;
|
|
padding: 20px;
|
|
}
|
|
@media (max-width: 640px) {
|
|
.overlay { top: 44px; border-radius: var(--radius-sm); padding: 16px; }
|
|
.overlay-icon { font-size: 2rem; }
|
|
.overlay h2 { font-size: 1rem; }
|
|
.overlay p { font-size: 0.85rem; }
|
|
}
|
|
.overlay-icon { font-size: 3rem; margin-bottom: 12px; }
|
|
.overlay h2 {
|
|
color: var(--phosphor);
|
|
font-family: var(--font-mono);
|
|
margin-bottom: 4px;
|
|
}
|
|
.overlay p { color: var(--text-dim); }
|
|
.save-hint {
|
|
display: inline-block;
|
|
margin-top: 16px;
|
|
padding: 6px 16px;
|
|
border-radius: 12px;
|
|
background: var(--phosphor-burn);
|
|
color: var(--phosphor-dim);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.error-box {
|
|
position: absolute;
|
|
inset: 0;
|
|
top: 50px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-top: 4px;
|
|
background: #331111;
|
|
border: 1px solid #cc3333;
|
|
color: #ff6666;
|
|
border-radius: var(--radius);
|
|
z-index: 10;
|
|
}
|
|
</style>
|