Initial implementation of the web emulator

This commit is contained in:
ovosimpatico
2024-11-14 10:01:10 -03:00
parent 255070feeb
commit b20d3954c6
11 changed files with 568 additions and 4 deletions

4
.env
View File

@@ -10,4 +10,6 @@ MAX_JOB_QUEUE=1000
# Changes the maximum number of pages that can be fetched for parsing. Has a massive impact on memory usage. Setting to 12 results in about 1.1GiB memory usage
MAX_FETCH_JOBS=1000
# Changes the name of your instance
INSTANCE_NAME=Myrient
INSTANCE_NAME=Myrient
# Enable the built-in emulator
EMULATOR_ENABLED=true

View File

@@ -14,6 +14,7 @@ services:
- MAX_JOB_QUEUE=1000
- MAX_FETCH_JOBS=1000
- INSTANCE_NAME=Myrient
- EMULATOR_ENABLED=true
volumes:
- ./data:/usr/src/app/data
restart: unless-stopped

69
lib/emulatorConfig.js Normal file
View File

@@ -0,0 +1,69 @@
// See https://emulatorjs.org/docs/systems for available cores
const coreMap = {
// Nintendo Systems
'Nintendo Entertainment System': 'fceumm',
'Super Nintendo Entertainment System': 'snes9x',
'Nintendo 64': 'mupen64plus_next',
'Nintendo DS': 'desmume2015',
'Nintendo Game Boy': 'gambatte',
'Nintendo Game Boy Color': 'gambatte',
'Nintendo Game Boy Advance': 'mgba',
// Sega Systems
'Sega Master System': 'smsplus',
'Sega Game Gear': 'genesis_plus_gx', // TODO: fix rom loading
'Sega Mega Drive': 'genesis_plus_gx',
'Sega CD': 'genesis_plus_gx', // TODO: add bios
'Sega 32X': 'picodrive', // Known issue: https://github.com/EmulatorJS/EmulatorJS/issues/579
'Sega Saturn': 'yabause',
// Atari Systems
'Atari 2600': 'stella2014',
'Atari 5200': 'a5200',
'Atari 7800': 'prosystem',
'Atari Jaguar': 'virtualjaguar',
'Atari Lynx': 'handy',
// Commodore Systems
'Commodore 64': 'vice_x64sc',
'Commodore 128': 'vice_x128', // Untested, Myrient has no ROMs for it
'Commodore Amiga': 'puae', // TODO: fix rom loading
'Commodore PET': 'vice_xpet', // Untested, Myrient has no ROMs for it
'Commodore Plus-4': 'vice_xplus4', // TODO: fix rom loading
'Commodore VIC-20': 'vice_xvic', // TODO: fix rom loading
// Sony Systems
'Sony PlayStation 1': 'pcsx_rearmed', // TODO: fix rom loading
// Other Systems
'Arcade': 'fbneo', // TODO: fix rom loading
'ColecoVision': 'gearcoleco', // TODO: add bios
'Panasonic 3DO': 'opera', // TODO: fix rom loading
};
const COMPATIBLE_SYSTEMS = Object.keys(coreMap);
export function isEmulatorCompatible(category) {
if (process.env.EMULATOR_ENABLED !== 'true') {
return false;
}
return COMPATIBLE_SYSTEMS.includes(category);
}
export function getEmulatorConfig(category) {
const core = coreMap[category] || 'unknown';
// Add system-specific settings
const config = {
core,
system: category,
options: {}
};
return config;
}
export function isNonGameContent(filename, nonGameTerms) {
const pattern = new RegExp(nonGameTerms.terms.join('|'), 'i');
return pattern.test(filename);
}

56
lib/nonGameTerms.json Normal file
View File

@@ -0,0 +1,56 @@
{
"terms": [
"7z",
"addon",
"artwork",
"audio",
"beta",
"box",
"boxart",
"cheat",
"config",
"cfg",
"csv",
"debug",
"dlc",
"document",
"driver",
"editor",
"emulator",
"expansion",
"firmware",
"guide",
"hack",
"html",
"ini",
"installer",
"intro",
"json",
"manual",
"mod",
"movie",
"music",
"ost",
"overlay",
"patch",
"plugin",
"preview",
"readme",
"rom",
"screenshot",
"sample",
"save",
"savestate",
"sdk",
"setup",
"soundtrack",
"terms",
"tool",
"trainer",
"txt",
"update",
"utility",
"video",
"wallpaper"
]
}

100
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"file-older-than": "^1.0.0",
"innertext": "^1.0.3",
"jsdom": "^25.0.1",
"jszip": "^3.10.1",
"minisearch": "^7.1.0",
"node-cron": "^3.0.3",
"node-fetch": "^3.3.2",
@@ -597,6 +598,12 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/css-select": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz",
@@ -1272,6 +1279,12 @@
"node": ">=0.10.0"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -1302,6 +1315,12 @@
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jake": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
@@ -1360,6 +1379,27 @@
}
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@@ -1569,6 +1609,12 @@
"node": ">= 0.8"
}
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parse5": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.0.tgz",
@@ -1605,6 +1651,12 @@
"@napi-rs/nice": "^1.0.1"
}
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1666,6 +1718,27 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/rrweb-cssom": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
@@ -1791,6 +1864,12 @@
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1824,6 +1903,21 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/string_decoder/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -1915,6 +2009,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",

View File

@@ -13,7 +13,8 @@
"node-html-parser": "^6.1.13",
"piscina": "^4.7.0",
"sanitize": "^2.1.2",
"figlet": "^1.7.0"
"figlet": "^1.7.0",
"jszip": "^3.10.1"
},
"type": "module"
}

View File

@@ -10,13 +10,17 @@ import sanitize from "sanitize";
import debugPrint from "./lib/debugprint.js";
import compression from "compression";
import { generateAsciiArt } from './lib/asciiart.js';
import { getEmulatorConfig, isEmulatorCompatible, isNonGameContent } from './lib/emulatorConfig.js';
import fetch from 'node-fetch';
let fileListPath = "./data/filelist.json";
let queryCountFile = "./data/queries.txt";
let categoryListPath = "./lib/categories.json"
let searchAlikesPath = './lib/searchalikes.json'
let nonGameTermsPath = './lib/nonGameTerms.json'
let categoryList = await FileHandler.parseJsonFile(categoryListPath);
global.searchAlikes = await FileHandler.parseJsonFile(searchAlikesPath)
let nonGameTerms = await FileHandler.parseJsonFile(nonGameTermsPath);
let crawlTime = 0;
let queryCount = 0;
let fileCount = 0;
@@ -101,7 +105,8 @@ let defaultOptions = {
queryCount: queryCount,
fileCount: fileCount,
termCount: search.miniSearch.termCount,
generateAsciiArt: generateAsciiArt
generateAsciiArt: generateAsciiArt,
isEmulatorCompatible: isEmulatorCompatible
};
function updateDefaults(){
@@ -216,6 +221,62 @@ app.get("/about", function (req, res) {
res.render(indexPage, buildOptions(page));
});
app.get("/play/:id", async function (req, res) {
// Block access if emulator is disabled
if (process.env.EMULATOR_ENABLED !== 'true') {
res.redirect('/');
return;
}
let fileId = parseInt(req.params.id);
let romFile = search.findIndex(fileId);
if (!romFile) {
res.redirect('/');
return;
}
let options = {
romFile: romFile,
emulatorConfig: getEmulatorConfig(romFile.category),
isNonGame: isNonGameContent(romFile.filename, nonGameTerms)
};
let page = "emulator";
options = buildOptions(page, options);
res.render(indexPage, options);
});
app.get("/proxy-rom/:id", async function (req, res) {
// Block access if emulator is disabled
if (process.env.EMULATOR_ENABLED !== 'true') {
res.status(403).send('Emulator feature is disabled');
return;
}
let fileId = parseInt(req.params.id);
let romFile = search.findIndex(fileId);
if (!romFile) {
res.status(404).send('ROM not found');
return;
}
try {
const response = await fetch(romFile.path);
const contentLength = response.headers.get('content-length');
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Length', contentLength);
res.setHeader('Content-Disposition', `attachment; filename="${romFile.filename}"`);
response.body.pipe(res);
} catch (error) {
console.error('Error proxying ROM:', error);
res.status(500).send('Error fetching ROM');
}
});
server.listen(process.env.PORT, process.env.BIND_ADDRESS);
server.on("listening", function () {
console.log(

View File

@@ -18,6 +18,32 @@
<a href="https://myrient.erista.me/donate/" class="btn btn-secondary">Donate to Myrient</a>
</div>
<div class="mb-4 border-top pt-3">
<h5>Built-in Emulator</h5>
<% if (process.env.EMULATOR_ENABLED === 'true') { %>
<p>This website includes a built-in emulator powered by <a href="https://emulatorjs.org/">EmulatorJS</a> that brings retro gaming directly to your browser.</p>
<p>Compatible games will feature a play button on their search result page.</p>
<p>For the best gaming experience, use a Chromium-based browser with hardware acceleration turned on.</p>
<p class="text-secondary">
<small>
<i class="fas fa-info-circle"></i>
Games are loaded directly from Myrient's public archive. Save states are stored locally in the browser.
</small>
<br>
<small>
<i class="fas fa-exclamation-triangle"></i>
ROM hacks, soundtracks, and other non-game content are not supported by the emulator and may fail to load.
</small>
</p>
<% } else { %>
<p>Web Emulator functionality was disabled by the administrator.</p>
<p>Contact the administrator or spin up your own instance of <%= process.env.INSTANCE_NAME || 'Myrient' %> Search.</p>
<% } %>
</div>
<div class="border-top pt-3">
<p>Search engine created by <a href="https://github.com/alexankitty">Alexankitty</a></p>
<p><a href="https://github.com/alexankitty/myrient-global-search">View project on GitHub</a></p>

223
views/pages/emulator.ejs Normal file
View File

@@ -0,0 +1,223 @@
<div class="container-fluid">
<!-- Header with game info -->
<div class="row mb-4 mt-3">
<div class="col-12 text-center">
<h2 class="text-white"><%= romFile.filename.replace(/\.[^/.]+$/, '') %></h2>
<p class="text-secondary"><%= romFile.category %></p>
<% if (isNonGame) { %>
<div class="alert alert-warning" role="alert">
<i class="fas fa-exclamation-triangle"></i>
Warning: This file may not be a game ROM and might not work properly in the web emulator.
See the <a href="/about" class="alert-link">About</a> page for more information.
</div>
<% } %>
</div>
</div>
<!-- Main game container with proper padding and height -->
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div id="game-wrapper" class="position-relative">
<div id="game" class="w-100"></div>
<!-- Progress bar inside game container -->
<div id="progress-container" class="progress-overlay">
<div class="progress" style="height: 25px; background-color: #2a2a2a; width: 80%; max-width: 500px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: 0%"
id="download-progress">
<span id="progress-text">Loading ROM...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Disclaimer footer -->
<div class="row mt-4 mb-3">
<div class="col-12 text-center">
<div class="alert alert-secondary" role="alert">
<small>
<i class="fas fa-info-circle"></i>
This emulator loads ROMs directly from <a href="https://myrient.erista.me/" class="alert-link">Myrient</a> and is not affiliated with them.
See the <a href="/about" class="alert-link">About</a> page for more information.
</small>
</div>
</div>
</div>
</div>
<style>
/* Only keep basic container styling */
#game-wrapper {
padding-top: 0;
background: #222;
border-radius: 8px;
overflow: hidden;
margin-bottom: 20px;
max-width: 1024px;
margin-left: auto;
margin-right: auto;
}
/* Keep only the aspect ratio for the game container */
#game {
aspect-ratio: 4/3;
max-height: 700px;
}
/* Keep alert styling for consistency */
.alert-secondary {
background-color: #2a2a2a;
border-color: #3a3a3a;
color: #999;
display: inline-block;
margin: 0 auto;
padding: 0.75rem 1.25rem;
}
.alert-secondary a.alert-link {
color: #bbb;
text-decoration: underline;
}
.alert-secondary a.alert-link:hover {
color: #fff;
}
.progress-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: none; /* Hidden by default */
justify-content: center;
align-items: center;
background-color: rgba(0, 0, 0, 0.8);
z-index: 1000;
}
.progress {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
}
#progress-text {
font-size: 14px;
font-weight: bold;
}
.alert-warning {
display: inline-block;
margin: 0 auto;
padding: 0.75rem 1.25rem;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<script>
// Display important notice immediately
console.log('%cAbout this Page', 'font-size: 20px; font-weight: bold; color: #4CAF50;');
console.log(
'%cThis page contains a game emulator that provides access to games through Myrient\'s public archive.\n' +
'We are not affiliated with or endorsed by Myrient.\n' +
'Visit the About page for more details on how this integration works and other important information.',
'font-size: 14px; color: #90CAF9;'
);
console.log(`%c${window.location.origin}/about`, 'font-size: 14px; color: #90CAF9;');
// Configure EmulatorJS
window.EJS_player = '#game';
window.EJS_core = '<%= emulatorConfig.core %>';
window.EJS_gameUrl = '/proxy-rom/<%= romFile.id %>';
window.EJS_pathtodata = 'https://cdn.emulatorjs.org/stable/data/';
window.EJS_startOnLoaded = true;
window.EJS_biosUrl = undefined;
window.EJS_gameName = '<%= romFile.filename.replace(/\.[^/.]+$/, '') %>';
window.EJS_backgroundBlur = true;
window.EJS_defaultOptions = {
'save-state-slot': 1,
'save-state-location': 'local'
};
// Handle ROMs with progress
async function loadRom() {
try {
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('download-progress');
const progressText = document.getElementById('progress-text');
progressContainer.style.display = 'flex';
// Check if the file is compressed based on extension
const isCompressed = /\.(zip)$/i.test('<%= romFile.filename %>');
// Download phase
progressText.textContent = 'Downloading ROM...';
const response = await fetch(EJS_gameUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!isCompressed) {
// For uncompressed ROMs, just return the URL directly
progressContainer.style.display = 'none';
return EJS_gameUrl;
}
// For compressed files, continue with decompression
const contentLength = response.headers.get('content-length');
const total = parseInt(contentLength, 10);
let loaded = 0;
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
const percent = Math.round((loaded / total) * 100);
progressBar.style.width = percent + '%';
progressText.textContent = `Downloading ROM: ${percent}%`;
}
// Decompression phase - keep progress at 100%
progressText.textContent = 'Decompressing the game...';
const blob = new Blob(chunks);
const zip = await JSZip.loadAsync(blob);
const files = Object.keys(zip.files);
const romFile = files.find(f => !zip.files[f].dir);
if (!romFile) {
throw new Error('No ROM file found in ZIP archive');
}
const romData = await zip.files[romFile].async('blob');
progressContainer.style.display = 'none';
return URL.createObjectURL(romData);
} catch (error) {
console.error('Error loading ROM:', error);
throw error;
}
}
loadRom()
.then(romUrl => {
window.EJS_gameUrl = romUrl;
const script = document.createElement('script');
script.src = `${window.EJS_pathtodata}loader.js`;
document.body.appendChild(script);
})
.catch(error => {
const gameDiv = document.getElementById('game');
gameDiv.innerHTML = `<div class="alert alert-danger">
Error loading game: ${error.message}
</div>`;
});
</script>

View File

@@ -45,6 +45,9 @@
<th>Size</th>
<th>Date</th>
<th>Search Score</th>
<% if (process.env.EMULATOR_ENABLED === 'true') { %>
<th>Play</th>
<% } %>
</tr>
</thead>
<% for (let x = entryStart; x < entryEnd; x++) { %>
@@ -72,6 +75,15 @@
<td>
<%= results.items[x].score.toFixed(2) %>
</td>
<% if (process.env.EMULATOR_ENABLED === 'true') { %>
<td>
<% if (isEmulatorCompatible(results.items[x].category)) { %>
<a href="/play/<%= results.items[x].id %>" class="btn btn-sm btn-secondary">Play</a>
<% } else { %>
<button class="btn btn-sm btn-secondary" disabled>----</button>
<% } %>
</td>
<% } %>
</tr>
<% } %>
</table>
@@ -128,7 +140,19 @@
</div>
<script defer>
resultTable = new DataTable('#results', {
"order": [6, 'desc'],
"order": [[6, 'desc']],
"columns": [
{ "data": "name" }, // Name
{ "data": "category" }, // Category
{ "data": "region" }, // Region
{ "data": "type" }, // Type
{ "data": "size" }, // Size
{ "data": "date" }, // Date
{ "data": "score" }, // Search Score
<% if (process.env.EMULATOR_ENABLED === 'true') { %>
{ "data": "play", "orderable": false } // Play button column
<% } %>
],
"lengthMenu": [100, { label: 'All', value: -1 }, 50, 25, 10],
"paging": false,
"filter": false,

View File

@@ -5,6 +5,7 @@
<!-- CSS (load bootstrap from a CDN) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
<style>
html, body {