Skip to content

Commit a24b600

Browse files
author
Kiosk
committed
upload ROM to library from game menu settings
- POST /api/upload-rom: multipart form upload (rom file, system, players) - GET /api/systems: list available preset systems - game-menu: upload modal with system dropdown, player selector, drag-drop file zone - auto-creates player directories, refreshes presets after upload - works on both screen and controller modes
1 parent c981be3 commit a24b600

2 files changed

Lines changed: 176 additions & 1 deletion

File tree

game-menu.html

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -736,6 +736,37 @@
736736
</label>
737737
</div>
738738
</div>
739+
740+
<div class="setting-row" style="margin-top: 12px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 12px;">
741+
<button id="uploadRomBtn" style="width:100%;padding:10px;background:rgba(26,175,255,0.15);border:1px solid rgba(26,175,255,0.3);border-radius:8px;color:#1AAFFF;font-size:14px;font-weight:600;cursor:pointer;transition:all 0.15s;">📁 Upload ROM to Library</button>
742+
</div>
743+
</div>
744+
745+
<!-- Upload ROM Modal -->
746+
<div id="uploadModal" style="display:none;position:absolute;inset:0;background:rgba(0,0,0,0.85);z-index:100;padding:20px;overflow-y:auto;">
747+
<div style="max-width:320px;margin:0 auto;">
748+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
749+
<h3 style="font-size:16px;font-weight:600;color:#fff;margin:0;">Upload ROM</h3>
750+
<button id="uploadModalClose" style="background:none;border:none;color:#888;font-size:20px;cursor:pointer;padding:4px 8px;">✕</button>
751+
</div>
752+
<div style="margin-bottom:12px;">
753+
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">System</label>
754+
<select id="uploadSystem" style="width:100%;padding:8px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.15);border-radius:6px;color:#fff;font-size:14px;"></select>
755+
</div>
756+
<div style="margin-bottom:12px;">
757+
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">Players</label>
758+
<div id="uploadPlayers" style="display:flex;gap:8px;flex-wrap:wrap;"></div>
759+
</div>
760+
<div style="margin-bottom:16px;">
761+
<label style="font-size:12px;color:#888;display:block;margin-bottom:4px;">ROM File</label>
762+
<div id="uploadDropZone" style="border:2px dashed rgba(255,255,255,0.15);border-radius:8px;padding:24px;text-align:center;cursor:pointer;transition:all 0.15s;">
763+
<div id="uploadFileName" style="color:#888;font-size:13px;">Tap to select or drop file</div>
764+
</div>
765+
<input type="file" id="uploadFileInput" accept=".zip,.7z,.nes,.smc,.sfc,.gb,.gba,.gbc,.n64,.z64,.v64,.bin,.cue,.iso,.md,.gen,.smd,.nds" style="display:none;">
766+
</div>
767+
<button id="uploadSubmit" disabled style="width:100%;padding:12px;background:#1AAFFF;border:none;border-radius:8px;color:#fff;font-size:14px;font-weight:600;cursor:pointer;opacity:0.4;transition:all 0.15s;">Upload</button>
768+
<div id="uploadProgress" style="display:none;margin-top:12px;text-align:center;color:#888;font-size:12px;"></div>
769+
</div>
739770
</div>
740771
741772
<div class="loading-overlay" id="loadingOverlay">
@@ -1212,6 +1243,100 @@
12121243
if (this._showingSettings) this.updateResolutionOptions();
12131244
};
12141245

1246+
// ── Upload ROM Modal ──────────────────────────────────────
1247+
const uploadModal = this.shadowRoot.getElementById('uploadModal');
1248+
const uploadSystem = this.shadowRoot.getElementById('uploadSystem');
1249+
const uploadPlayers = this.shadowRoot.getElementById('uploadPlayers');
1250+
const uploadFileInput = this.shadowRoot.getElementById('uploadFileInput');
1251+
const uploadDropZone = this.shadowRoot.getElementById('uploadDropZone');
1252+
const uploadFileName = this.shadowRoot.getElementById('uploadFileName');
1253+
const uploadSubmit = this.shadowRoot.getElementById('uploadSubmit');
1254+
const uploadProgress = this.shadowRoot.getElementById('uploadProgress');
1255+
let uploadSelectedFile = null;
1256+
let uploadSelectedPlayers = '1p';
1257+
1258+
// Open modal
1259+
this.shadowRoot.getElementById('uploadRomBtn').onclick = () => {
1260+
uploadModal.style.display = 'block';
1261+
uploadSelectedFile = null;
1262+
uploadFileName.textContent = 'Tap to select or drop file';
1263+
uploadSubmit.disabled = true;
1264+
uploadSubmit.style.opacity = '0.4';
1265+
uploadProgress.style.display = 'none';
1266+
// Populate systems
1267+
fetch('/api/systems').then(r => r.json()).then(systems => {
1268+
uploadSystem.innerHTML = systems.map(s =>
1269+
'<option value="' + s + '">' + (this.coreNames[s] || s) + '</option>'
1270+
).join('');
1271+
});
1272+
renderPlayerBtns();
1273+
};
1274+
1275+
// Close modal
1276+
this.shadowRoot.getElementById('uploadModalClose').onclick = () => {
1277+
uploadModal.style.display = 'none';
1278+
};
1279+
1280+
// Player buttons
1281+
const renderPlayerBtns = () => {
1282+
let html = '';
1283+
for (let i = 1; i <= 4; i++) {
1284+
const val = i + 'p';
1285+
const active = uploadSelectedPlayers === val;
1286+
html += '<button data-p="' + val + '" style="flex:1;padding:8px;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;transition:all 0.15s;' +
1287+
(active ? 'background:#1AAFFF;color:#fff;border:1px solid #1AAFFF;' : 'background:rgba(255,255,255,0.08);color:#888;border:1px solid rgba(255,255,255,0.12);') +
1288+
'">' + i + 'P</button>';
1289+
}
1290+
uploadPlayers.innerHTML = html;
1291+
uploadPlayers.querySelectorAll('button').forEach(btn => {
1292+
btn.onclick = () => { uploadSelectedPlayers = btn.dataset.p; renderPlayerBtns(); };
1293+
});
1294+
};
1295+
1296+
// File selection
1297+
uploadDropZone.onclick = () => uploadFileInput.click();
1298+
uploadDropZone.ondragover = (e) => { e.preventDefault(); uploadDropZone.style.borderColor = '#1AAFFF'; };
1299+
uploadDropZone.ondragleave = () => { uploadDropZone.style.borderColor = 'rgba(255,255,255,0.15)'; };
1300+
uploadDropZone.ondrop = (e) => {
1301+
e.preventDefault();
1302+
uploadDropZone.style.borderColor = 'rgba(255,255,255,0.15)';
1303+
if (e.dataTransfer.files.length) { uploadSelectedFile = e.dataTransfer.files[0]; uploadFileName.textContent = uploadSelectedFile.name; uploadSubmit.disabled = false; uploadSubmit.style.opacity = '1'; }
1304+
};
1305+
uploadFileInput.onchange = () => {
1306+
if (uploadFileInput.files.length) { uploadSelectedFile = uploadFileInput.files[0]; uploadFileName.textContent = uploadSelectedFile.name; uploadSubmit.disabled = false; uploadSubmit.style.opacity = '1'; }
1307+
};
1308+
1309+
// Submit upload
1310+
uploadSubmit.onclick = async () => {
1311+
if (!uploadSelectedFile) return;
1312+
uploadSubmit.disabled = true;
1313+
uploadSubmit.textContent = 'Uploading...';
1314+
uploadProgress.style.display = 'block';
1315+
uploadProgress.textContent = 'Uploading ' + uploadSelectedFile.name + '...';
1316+
try {
1317+
const form = new FormData();
1318+
form.append('rom', uploadSelectedFile);
1319+
form.append('system', uploadSystem.value);
1320+
form.append('players', uploadSelectedPlayers);
1321+
const resp = await fetch('/api/upload-rom', { method: 'POST', body: form });
1322+
const data = await resp.json();
1323+
if (data.ok) {
1324+
uploadProgress.textContent = '✓ Uploaded to ' + data.path;
1325+
uploadProgress.style.color = '#4CAF50';
1326+
setTimeout(() => { uploadModal.style.display = 'none'; this.loadPresets(); }, 1500);
1327+
} else {
1328+
uploadProgress.textContent = '✕ ' + (data.error || 'Upload failed');
1329+
uploadProgress.style.color = '#f44';
1330+
uploadSubmit.disabled = false;
1331+
}
1332+
} catch (e) {
1333+
uploadProgress.textContent = '✕ ' + e.message;
1334+
uploadProgress.style.color = '#f44';
1335+
uploadSubmit.disabled = false;
1336+
}
1337+
uploadSubmit.textContent = 'Upload';
1338+
};
1339+
12151340
// Game height slider
12161341
this.gameHeightSlider.oninput = (e) => {
12171342
const value = parseInt(e.target.value);

server.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { serve, file } from "bun";
22
import { join, extname, basename } from "path";
33
import { networkInterfaces } from "os";
44
import { reverse } from "dns/promises";
5-
import { readdir, access } from "fs/promises";
5+
import { readdir, access, stat, mkdir } from "fs/promises";
66
import { parseArgs } from "util";
77
import { execSync } from "child_process";
88
import { readFileSync } from "fs";
@@ -512,6 +512,56 @@ const serverConfig = {
512512
}
513513
}
514514

515+
// ── ROM Upload API ─────────────────────────────────────────────────────
516+
if (pathname === "/api/upload-rom" && req.method === "POST") {
517+
try {
518+
const formData = await req.formData();
519+
const file = formData.get("rom") as File | null;
520+
const system = formData.get("system") as string | null;
521+
const players = formData.get("players") as string | null;
522+
523+
if (!file || !system || !players) {
524+
return new Response(JSON.stringify({ ok: false, error: "Missing rom, system, or players" }), { status: 400, headers: { "Content-Type": "application/json", ...getHeaders(req) } });
525+
}
526+
527+
// Validate system exists
528+
const presetsDir = join(ROOT_DIR, "presets");
529+
const systemDir = join(presetsDir, system);
530+
try { await stat(systemDir); } catch {
531+
return new Response(JSON.stringify({ ok: false, error: `Unknown system: ${system}` }), { status: 400, headers: { "Content-Type": "application/json", ...getHeaders(req) } });
532+
}
533+
534+
// Validate players format
535+
if (!/^\d+p$/.test(players)) {
536+
return new Response(JSON.stringify({ ok: false, error: `Invalid players format: ${players}` }), { status: 400, headers: { "Content-Type": "application/json", ...getHeaders(req) } });
537+
}
538+
539+
// Create player directory if needed
540+
const targetDir = join(systemDir, players);
541+
await mkdir(targetDir, { recursive: true });
542+
543+
// Write file
544+
const targetPath = join(targetDir, file.name);
545+
const arrayBuffer = await file.arrayBuffer();
546+
await Bun.write(targetPath, arrayBuffer);
547+
548+
return new Response(JSON.stringify({ ok: true, path: `presets/${system}/${players}/${file.name}`, size: file.size }), { headers: { "Content-Type": "application/json", ...getHeaders(req) } });
549+
} catch (e: any) {
550+
return new Response(JSON.stringify({ ok: false, error: e.message }), { status: 500, headers: { "Content-Type": "application/json", ...getHeaders(req) } });
551+
}
552+
}
553+
554+
if (pathname === "/api/systems" && req.method === "GET") {
555+
const presetsDir = join(ROOT_DIR, "presets");
556+
try {
557+
const entries = await readdir(presetsDir, { withFileTypes: true });
558+
const systems = entries.filter(e => e.isDirectory() && e.name !== "bios").map(e => e.name);
559+
return new Response(JSON.stringify(systems), { headers: { "Content-Type": "application/json", ...getHeaders(req) } });
560+
} catch {
561+
return new Response(JSON.stringify([]), { headers: { "Content-Type": "application/json", ...getHeaders(req) } });
562+
}
563+
}
564+
515565
// ── Native RetroArch API ──────────────────────────────────────────────
516566
if (pathname === "/api/native/status") {
517567
if (!native) return new Response(JSON.stringify({ state: "idle", supported: false, cores: {} }), { headers: { "Content-Type": "application/json", ...getHeaders(req) } });

0 commit comments

Comments
 (0)