432 lines
13 KiB
JavaScript
432 lines
13 KiB
JavaScript
/**
|
|
* RetroArch Web Player
|
|
*
|
|
* This provides the basic JavaScript for the RetroArch web player.
|
|
*/
|
|
|
|
const canvas = document.getElementById("canvas");
|
|
const webplayerPreview = document.getElementById("webplayer-preview");
|
|
const menuBar = document.getElementById("navbar");
|
|
const menuHider = document.getElementById("menuhider");
|
|
const coreSelector = document.getElementById("core-selector");
|
|
const coreSelectorCurrent = document.getElementById("current-core");
|
|
const dropdownBox = document.getElementById("dropdown-box");
|
|
const btnFiles = document.getElementById("btnFiles");
|
|
const btnRun = document.getElementById("btnRun");
|
|
const btnMenu = document.getElementById("btnMenu");
|
|
const btnFullscreen = document.getElementById("btnFullscreen");
|
|
const btnHelp = document.getElementById("btnHelp");
|
|
const btnAdd = document.getElementById("btnAdd");
|
|
const icnRun = document.getElementById("icnRun");
|
|
const icnAdd = document.getElementById("icnAdd");
|
|
const modalContainer = document.getElementById("modals");
|
|
const modalWindow = document.getElementById("modal-window");
|
|
const modalTitle = document.getElementById("modal-title");
|
|
const modalClose = document.getElementById("modal-close");
|
|
const fileManagerPanel = document.getElementById("fileManagerPanel");
|
|
|
|
const progressTrackers = {
|
|
"main": {bar: document.getElementById("progressBarMain"), text: document.getElementById("progressTextMain")},
|
|
"modal": {bar: document.getElementById("progressBarModal"), text: document.getElementById("progressTextModal")}
|
|
};
|
|
|
|
const modals = {
|
|
"help": {title: "Basics", width: "750px", element: document.getElementById("helpModal")},
|
|
"files": {title: "File Management", width: "400px", element: document.getElementById("filesModal")}
|
|
};
|
|
|
|
// Attempt to disable some default browser keys.
|
|
const disableKeys = {
|
|
9: "tab",
|
|
13: "enter",
|
|
16: "shift",
|
|
18: "alt",
|
|
27: "esc",
|
|
33: "rePag",
|
|
34: "avPag",
|
|
35: "end",
|
|
36: "home",
|
|
37: "left",
|
|
38: "up",
|
|
39: "right",
|
|
40: "down",
|
|
112: "F1",
|
|
113: "F2",
|
|
114: "F3",
|
|
115: "F4",
|
|
116: "F5",
|
|
117: "F6",
|
|
118: "F7",
|
|
119: "F8",
|
|
120: "F9",
|
|
121: "F10",
|
|
122: "F11",
|
|
123: "F12"
|
|
};
|
|
|
|
let fsLoadPromise;
|
|
|
|
// all methods provided by the worker that we may require
|
|
const workerHandlers = {FS: ["init", "writeFile", "readFile", "mkdirTree", "readdir", "readdirTree", "rm", "stat"], helper: ["loadFS", "zipDirs"]};
|
|
|
|
const worker = new Worker("libretro.worker.js");
|
|
let workerMessageQueue = [];
|
|
worker.onmessage = (msg) => {
|
|
switch (msg.data?.type) {
|
|
case "noReturn":
|
|
window[msg.data?.func]?.apply(null, msg.data?.args);
|
|
break;
|
|
case "ret":
|
|
const ind = workerMessageQueue.findIndex(i => msg.data?.id in i);
|
|
if (ind < 0) break;
|
|
const promise = workerMessageQueue.splice(ind, 1)[0][msg.data.id];
|
|
if (msg.data.err) {
|
|
promise.reject(msg.data?.ret);
|
|
} else {
|
|
promise.resolve(msg.data?.ret);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
function handleWorkerFunc(handler, method, args) {
|
|
return new Promise((resolve, reject) => {
|
|
const id = "" + Math.random();
|
|
workerMessageQueue.push({[id]: {resolve: resolve, reject: reject}});
|
|
worker.postMessage({id: id, handler: handler, method: method, args: args});
|
|
});
|
|
}
|
|
|
|
// add the global functions from workerHandlers
|
|
// this makes the methods here appear identical to the implementation in the worker
|
|
for (let [handler, methods] of Object.entries(workerHandlers)) {
|
|
let methodHandlers = {};
|
|
for (let method of methods) {
|
|
methodHandlers[method] = async function() {
|
|
return await handleWorkerFunc(handler, method, Array.from(arguments));
|
|
}
|
|
}
|
|
window[handler] = methodHandlers;
|
|
}
|
|
|
|
// console.log alias for worker to use
|
|
function debugLog() {
|
|
console.log.apply(null, Array.from(arguments));
|
|
}
|
|
|
|
// n is the name of the bar ("main" or "modal")
|
|
// progress in range [0, 1]
|
|
function setProgress(n, progress) {
|
|
const progressBar = progressTrackers[n]?.bar;
|
|
if (!progressBar) return;
|
|
if (isNaN(progress)) progress = 0;
|
|
progressBar.style.height = progress ? "4px" : "0px";
|
|
progressBar.style.setProperty("--progressbarpercent", (progress * 100) + "%");
|
|
}
|
|
|
|
function setProgressColor(n, color) {
|
|
const progressBar = progressTrackers[n]?.bar;
|
|
if (!progressBar) return;
|
|
progressBar.style.setProperty("--progressbarcolor", color || "#1fb01a");
|
|
}
|
|
|
|
function setProgressText(n, text) {
|
|
const progressText = progressTrackers[n]?.text;
|
|
if (!progressText) return;
|
|
progressText.textContent = text ?? "";
|
|
}
|
|
|
|
// "help" or "files"
|
|
function openModal(which) {
|
|
if (which in modals) {
|
|
for (const modal of Object.values(modals)) {
|
|
modal.element.style.display = "none";
|
|
}
|
|
modalTitle.textContent = modals[which].title ?? "";
|
|
modalWindow.style.width = modals[which].width ?? "750px";
|
|
modals[which].element.style.display = "block";
|
|
modalContainer.style.display = "block";
|
|
}
|
|
}
|
|
|
|
modalClose.addEventListener("click", function() {
|
|
modalContainer.style.display = "none";
|
|
});
|
|
|
|
var Module = {
|
|
noInitialRun: true,
|
|
arguments: ["-v", "--menu"],
|
|
noImageDecoding: true,
|
|
noAudioDecoding: true,
|
|
|
|
retroArchSend: function(msg) {
|
|
this.EmscriptenSendCommand(msg);
|
|
},
|
|
retroArchRecv: function() {
|
|
return this.EmscriptenReceiveCommandReply();
|
|
},
|
|
preRun: [
|
|
function(module) {
|
|
module.ENV["OPFS_MOUNT"] = "/home/web_user";
|
|
}
|
|
],
|
|
locateFile: function(path, prefix) {
|
|
if (path.endsWith(".js")) return typeof this.mainScriptUrlOrBlob == "string" ? this.mainScriptUrlOrBlob : URL.createObjectURL(this.mainScriptUrlOrBlob);
|
|
return path;
|
|
},
|
|
onRuntimeInitialized: function() {
|
|
appInitialized();
|
|
},
|
|
print: function(text) {
|
|
console.log("stdout:", text);
|
|
},
|
|
printErr: function(text) {
|
|
console.log("stderr:", text);
|
|
},
|
|
canvas: canvas,
|
|
totalDependencies: 0,
|
|
monitorRunDependencies: function(left) {
|
|
this.totalDependencies = Math.max(this.totalDependencies, left);
|
|
}
|
|
};
|
|
|
|
// read File object to an ArrayBuffer
|
|
function readFile(file) {
|
|
return new Promise((resolve, reject) => {
|
|
let reader = new FileReader();
|
|
reader.onload = function() {
|
|
resolve(this.result);
|
|
}
|
|
reader.onerror = function(e) {
|
|
reject(e);
|
|
}
|
|
reader.readAsArrayBuffer(file);
|
|
});
|
|
}
|
|
|
|
// accept (optional) can be used to specify file extensions (string, comma delimited)
|
|
// returns an array of {path: String, data: ArrayBuffer}
|
|
function uploadFiles(accept) {
|
|
return new Promise((resolve, reject) => {
|
|
let input = document.createElement("input");
|
|
input.type = "file";
|
|
input.setAttribute("multiple", "");
|
|
if (accept) input.accept = accept;
|
|
input.style.setProperty("display", "none", "important");
|
|
document.body.appendChild(input);
|
|
input.addEventListener("change", async function() {
|
|
let files = [];
|
|
for (const file of this.files) {
|
|
files.push({path: file.name, data: await readFile(file)});
|
|
}
|
|
document.body.removeChild(input);
|
|
resolve(files);
|
|
});
|
|
input.oncancel = function() {
|
|
document.body.removeChild(input);
|
|
resolve([]);
|
|
}
|
|
input.click();
|
|
});
|
|
}
|
|
|
|
// prompt user to upload file(s) to a dir in OPFS, e.g. "/retroarch/content"
|
|
async function uploadFilesToDir(dir, accept) {
|
|
const files = await uploadFiles(accept);
|
|
for (const file of files) {
|
|
await FS.writeFile(dir + "/" + file.path, new Uint8Array(file.data));
|
|
console.log("file upload complete: " + file.path);
|
|
}
|
|
}
|
|
|
|
// download data (ArrayBuffer/DataView) with file name and optional mime type
|
|
function downloadFile(data, name, mime) {
|
|
let a = document.createElement("a");
|
|
a.download = name;
|
|
a.href = URL.createObjectURL(new Blob([data], {type: mime || "application/octet-stream"}));
|
|
a.click();
|
|
window.setTimeout(function() {
|
|
URL.revokeObjectURL(a.href);
|
|
}, 2000);
|
|
}
|
|
|
|
// click handler for the file manager modal
|
|
async function fileManagerEvent(target) {
|
|
const action = target?.dataset?.action;
|
|
if (!action) return;
|
|
target.classList.add("disabled");
|
|
let data;
|
|
switch (action) {
|
|
case "upload_saves":
|
|
await uploadFilesToDir("/retroarch/saves");
|
|
break;
|
|
case "upload_states":
|
|
await uploadFilesToDir("/retroarch/states");
|
|
break;
|
|
case "upload_system":
|
|
await uploadFilesToDir("/retroarch/system");
|
|
break;
|
|
case "download_sss":
|
|
data = await helper.zipDirs("/retroarch/saves", "/retroarch/states", "/retroarch/screenshots");
|
|
downloadFile(data, "saves_states_screenshots.zip", "application/zip");
|
|
break;
|
|
case "download_all":
|
|
data = await helper.zipDirs("/retroarch/saves", "/retroarch/states", "/retroarch/screenshots", "/retroarch/content");
|
|
downloadFile(data, "all.zip", "application/zip");
|
|
break;
|
|
case "delete_sss":
|
|
await FS.rm("/retroarch/saves", "/retroarch/states", "/retroarch/screenshots");
|
|
break;
|
|
case "delete_content":
|
|
await FS.rm("/retroarch/content");
|
|
break;
|
|
case "delete_config":
|
|
await FS.rm("/.config/retroarch");
|
|
break;
|
|
case "delete_assets":
|
|
await FS.rm("/retroarch/.bundle-timestamp", "/retroarch/assets", "/retroarch/autoconfig",
|
|
"/retroarch/database", "/retroarch/filters", "/retroarch/info", "/retroarch/overlays", "/retroarch/shaders");
|
|
break;
|
|
case "delete_all":
|
|
await FS.rm("/retroarch", "/.config/retroarch");
|
|
break;
|
|
}
|
|
target.classList.remove("disabled");
|
|
}
|
|
|
|
function appIsSmallScreen() {
|
|
return window.matchMedia("(max-width: 720px)").matches;
|
|
}
|
|
|
|
// used for the menu hider
|
|
function adjustMenuHeight() {
|
|
const actualMenuHeight = menuHider.checked ? 0 : 65;
|
|
document.body.style.setProperty("--actualmenuheight", actualMenuHeight + "px", "important")
|
|
}
|
|
|
|
function startRetroArch() {
|
|
// show the "changes you made may not be saved" warning
|
|
window.addEventListener("beforeunload", function(e) { e.preventDefault(); });
|
|
|
|
window.addEventListener("keydown", function(e) {
|
|
if (disableKeys[e.which]) e.preventDefault();
|
|
});
|
|
|
|
webplayerPreview.classList.add("hide");
|
|
btnRun.classList.add("hide");
|
|
|
|
btnMenu.classList.remove("disabled");
|
|
btnMenu.addEventListener("click", function() {
|
|
Module.retroArchSend("MENU_TOGGLE");
|
|
});
|
|
|
|
btnFullscreen.classList.remove("disabled");
|
|
btnFullscreen.addEventListener("click", function() {
|
|
Module.retroArchSend("FULLSCREEN_TOGGLE");
|
|
});
|
|
|
|
// refocus the canvas so that keyboard events work
|
|
menuBar.addEventListener("pointerdown", function() {
|
|
setTimeout(function() {
|
|
Module.canvas.focus();
|
|
}, 0);
|
|
}, false);
|
|
|
|
Module.callMain(Module.arguments);
|
|
}
|
|
|
|
// called when the emscripten module has loaded
|
|
async function appInitialized() {
|
|
console.log("WASM runtime initialized");
|
|
await fsLoadPromise;
|
|
console.log("FS initialized");
|
|
setProgress("main");
|
|
setProgressText("main");
|
|
icnRun.classList.remove("fa-spinner", "fa-spin");
|
|
icnRun.classList.add("fa-play");
|
|
// Make the Preview image clickable to start RetroArch.
|
|
webplayerPreview.classList.add("loaded");
|
|
webplayerPreview.addEventListener("click", function() {
|
|
startRetroArch();
|
|
});
|
|
btnRun.classList.remove("disabled");
|
|
btnRun.addEventListener("click", function() {
|
|
startRetroArch();
|
|
});
|
|
}
|
|
|
|
async function downloadScript(src) {
|
|
let resp = await fetch(src);
|
|
let blob = await resp.blob();
|
|
return blob;
|
|
}
|
|
|
|
async function loadCore(core) {
|
|
// Make the core the selected core in the UI.
|
|
const coreTitle = document.querySelector('#core-selector a[data-core="' + core + '"]')?.textContent;
|
|
if (coreTitle) coreSelectorCurrent.textContent = coreTitle;
|
|
const fileExt = (core == "retroarch") ? ".js" : "_libretro.js";
|
|
const url = URL.createObjectURL(await downloadScript("./" + core + fileExt));
|
|
Module.mainScriptUrlOrBlob = url;
|
|
import(url).then(script => {
|
|
script.default(Module).then(mod => {
|
|
Module = mod;
|
|
}).catch(err => { console.error("Couldn't instantiate module", err); throw err; });
|
|
}).catch(err => { console.error("Couldn't load script", err); throw err; });
|
|
}
|
|
|
|
// When the browser has loaded everything.
|
|
document.addEventListener("DOMContentLoaded", async function() {
|
|
// watch the menu toggle checkbox
|
|
menuHider.addEventListener("change", adjustMenuHeight);
|
|
if (appIsSmallScreen()) menuHider.checked = true;
|
|
adjustMenuHeight();
|
|
|
|
// make it easier to exit the core selector drop-down menu
|
|
document.addEventListener("click", function(e) {
|
|
if (!coreSelector.parentElement.contains(e.target)) dropdownBox.checked = false;
|
|
});
|
|
|
|
// disable default right click action
|
|
canvas.addEventListener("contextmenu", function(e) {
|
|
e.preventDefault();
|
|
}, false);
|
|
|
|
// init the OPFS
|
|
await FS.init();
|
|
fsLoadPromise = helper.loadFS();
|
|
|
|
btnFiles.addEventListener("click", function() {
|
|
openModal("files");
|
|
});
|
|
|
|
btnHelp.addEventListener("click", function() {
|
|
openModal("help");
|
|
});
|
|
|
|
btnAdd.classList.remove("disabled");
|
|
btnAdd.addEventListener("click", async function() {
|
|
btnAdd.classList.add("disabled");
|
|
icnAdd.classList.remove("fa-plus");
|
|
icnAdd.classList.add("fa-spinner", "fa-spin");
|
|
await uploadFilesToDir("/retroarch/content");
|
|
btnAdd.classList.remove("disabled");
|
|
icnAdd.classList.remove("fa-spinner", "fa-spin");
|
|
icnAdd.classList.add("fa-plus");
|
|
});
|
|
|
|
fileManagerPanel.addEventListener("click", function(e) {
|
|
fileManagerEvent(e.target);
|
|
});
|
|
|
|
// Switch the core when selecting one.
|
|
coreSelector.addEventListener("click", function(e) {
|
|
const coreChoice = e.target.dataset?.core;
|
|
if (coreChoice) localStorage.setItem("core", coreChoice);
|
|
});
|
|
|
|
// Find which core to load.
|
|
const core = localStorage.getItem("core") || "gambatte";
|
|
loadCore(core);
|
|
});
|