RetroArch/pkg/emscripten/libretro-thread/libretro.js

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);
});