From c36ac7680987f943faacf4e5bcd1d07dde23d781 Mon Sep 17 00:00:00 2001
From: Mihawk <163614687+mihawk-99@users.noreply.github.com>
Date: Wed, 7 Oct 2026 11:26:58 -0400
Subject: [PATCH] WebUI: drawn icons for regions, devices and media sources
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Flags for USA, Europe, Japan and Korea and a globe for World, on the region choice
(now flag chips, like the other choices) and in place of a game card's region tag; a
console icon for PS5 and a PC icon for This PC → PS5; a badge for each source:
RetroArch's own invader for libretro thumbnails, and original drawings for
ScreenScraper, LaunchBox and EmuMovies (not their owners' logos, which are theirs to
license). Inline SVG, no image requests; the console icon is a generic outline.
Checked against the console at 1440 px (the Download Media tab, a scraped SNES card).
Command: tools/verify.sh: all five PASS (149 tests).
---
webui/app.css | 2 ++
webui/app.js | 51 ++++++++++++++++++++++++++++++++++++++++++++----
webui/index.html | 4 ++--
3 files changed, 51 insertions(+), 6 deletions(-)
diff --git a/webui/app.css b/webui/app.css
index 4b7e549..2a72b0a 100644
--- a/webui/app.css
+++ b/webui/app.css
@@ -41,3 +41,5 @@ main{max-width:100rem;margin-inline:auto}
@media(max-width:768px){.media-row{grid-template-columns:1fr}.media-hint,#scrape-system{grid-column:1}.media-row-kinds .media-label{padding-top:0}.kind-tiles{grid-template-columns:repeat(auto-fill,minmax(6.8rem,1fr))}.job-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.segmented{flex-wrap:wrap}.media-bar{flex-wrap:wrap}.media-bar .primary{width:100%}}
.kinds-area .media-hint{grid-column:auto}
.media-head p{margin-top:0}@media(max-width:768px){.segmented{display:flex;width:100%}.segmented label{flex:1}.segmented span{text-align:center;padding:.45rem .5rem}}
+.flag{width:1.35rem;height:.9rem;border-radius:.15rem;box-shadow:0 0 0 .0555556rem rgba(0,0,0,.18);flex:none;vertical-align:middle}.flag.globe{width:1rem;height:1rem;border-radius:50%;box-shadow:none}.device{width:1.25rem;height:1.25rem;flex:none}.device.arrow{width:.9rem;height:.9rem;opacity:.7}.source-logo{width:1.15rem;height:1.15rem;flex:none;border-radius:.3rem}img.source-logo{object-fit:contain}.segmented span[data-icon]{display:inline-flex;align-items:center;gap:.4rem}.segmented span[data-icon] b{font-weight:500}.region-pick{display:flex;align-items:center;gap:.6rem;font-size:.88rem;flex-wrap:wrap}.chip[aria-checked=true] .source-logo{box-shadow:0 0 0 .111111rem rgba(255,255,255,.7)}.version-tags .version-tag{display:inline-flex;align-items:center;gap:.2rem}.version-tags .version-tag.flags{border:0;padding:0}.version-tags .version-tag.flags .flag{width:1.15rem;height:.78rem}
+@media(max-width:768px){.region-pick{width:100%}.region-pick .segmented span{padding:.4rem .3rem;font-size:.78rem;gap:.25rem}.region-pick .flag{width:1.1rem;height:.75rem}}
diff --git a/webui/app.js b/webui/app.js
index 476f2a8..c959423 100644
--- a/webui/app.js
+++ b/webui/app.js
@@ -719,6 +719,47 @@ window.addEventListener('hashchange', navigate);
setInterval(() => { if (!document.hidden) reconnect(); }, 10000);
navigate(); drawTransfers(); reconnect(); checkRelease();
+
+// The WebUI's drawn icons (inline SVG, no image requests): flags for regions, the
+// console and the PC, and a badge for each media source. The sources' badges are
+// original drawings, not their owners' logos.
+const EU_STARS = Array.from({ length: 12 }, (_, i) => { const a = i * Math.PI / 6; return ``; }).join('');
+const US_STRIPES = Array.from({ length: 7 }, (_, i) => ``).join('');
+const US_STARS = Array.from({ length: 12 }, (_, i) => ``).join('');
+const ICONS = {
+ 'flag-us': ``,
+ 'flag-eu': ``,
+ 'flag-jp': '',
+ 'flag-kr': '',
+ globe: '',
+ ps5: '',
+ pc: '',
+ arrow: '',
+ 'source-libretro': '',
+ 'source-screenscraper': '',
+ 'source-launchbox': '',
+ 'source-emumovies': '' };
+function iconNode(name) {
+ const template = document.createElement('template');
+ template.innerHTML = ICONS[name] || '';
+ return template.content.firstElementChild;
+}
+// A region tag of a game's name, as a flag ("USA", "Europe", "USA, Europe"...).
+const REGION_FLAGS = { USA: 'flag-us', Europe: 'flag-eu', Japan: 'flag-jp', Korea: 'flag-kr', World: 'globe' };
+function tagNode(tag) {
+ const node = element('span', undefined, 'version-tag');
+ const flags = tag.split(/,\s*/).map(part => REGION_FLAGS[part]).filter(Boolean);
+ if (flags.length && flags.length === tag.split(/,\s*/).length) { for (const flag of flags) node.append(iconNode(flag)); node.title = tag; node.setAttribute('aria-label', tag); node.classList.add('flags'); }
+ else node.textContent = tag;
+ return node;
+}
+// The page's own [data-icon] spans get their drawing once.
+for (const span of $$('[data-icon]')) {
+ const name = span.dataset.icon, label = span.textContent;
+ span.replaceChildren();
+ span.append(iconNode(name === 'pc-to-ps5' ? 'pc' : name)); // the words say where it goes
+ span.append(element('b', label));
+}
// The games and their media (src/scraper.h): the shared library every frontend reads.
const KIND_NAMES = { cover: 'Cover', screenshot: 'Screenshot', title: 'Title', logo: 'Logo', video: 'Video' };
function gameName(game) { return game.name || game.label; }
@@ -766,7 +807,7 @@ function drawGames() {
const badges = element('div', undefined, 'media-badges');
for (const kind of game.media) badges.append(element('span', KIND_NAMES[kind] || kind));
const tags = element('div', undefined, 'version-tags');
- for (const tag of gameTags(game)) tags.append(element('span', tag));
+ for (const tag of gameTags(game)) tags.append(tagNode(tag));
card.title = game.path.split('/').pop();
info.append(element('strong', gameName(game)), tags, element('small', system.name), badges);
card.append(pick, cover, info); grid.append(card);
@@ -819,7 +860,9 @@ function drawMethod() {
function drawSources() {
const box = $('#scrape-sources'); box.replaceChildren();
for (const source of scraperSettings.sources) {
- const chip = element('button', source.name, 'chip'); chip.type = 'button';
+ const chip = element('button', undefined, 'chip'); chip.type = 'button';
+ const logo = iconNode('source-' + source.id); if (logo) chip.append(logo);
+ chip.append(element('span', source.name));
chip.setAttribute('role', 'radio'); chip.setAttribute('aria-checked', source.id === scrapeSource);
if (!source.available) { chip.disabled = true; chip.append(element('em', 'Coming', 'soon-tag')); }
chip.addEventListener('click', () => { scrapeSource = source.id; drawSources(); drawKinds(); });
@@ -877,7 +920,7 @@ async function openMedia() {
for (const radio of $$('input[name="scrape-method"]')) radio.checked = radio.value === scraperSettings.mode;
scrapeSource = scraperSettings.sources.some(s => s.id === scraperSettings.source && s.available) ? scraperSettings.source : 'libretro';
scrapeKinds = new Set(scraperSettings.kinds);
- $('#scrape-region').value = scraperSettings.region;
+ for (const radio of $$('input[name="scrape-region"]')) radio.checked = radio.value === scraperSettings.region;
const select = $('#scrape-system'), chosen = select.value || $('#games-system').value;
select.replaceChildren(...library.systems.map(s => new Option(`${s.name} (${s.games.length})`, s.id)));
if (chosen) select.value = chosen;
@@ -897,7 +940,7 @@ $('#scrape-start').addEventListener('click', async () => {
const lines = scope === 'selected' ? [...selectedGames].map(([path, system]) => `${system}\t${path}`)
: library.systems.filter(s => scope === 'all' || s.id === $('#scrape-system').value).map(s => `${s.id}\t`);
if (!kinds.length || !lines.length) { drawSummary(); return; }
- const query = new URLSearchParams({ mode: method, source: scrapeSource, kinds: kinds.join(','), region: $('#scrape-region').value, language: scraperSettings.language || 'en', overwrite: $('#scrape-overwrite').checked ? '1' : '0' });
+ const query = new URLSearchParams({ mode: method, source: scrapeSource, kinds: kinds.join(','), region: $('input[name="scrape-region"]:checked')?.value || 'us', language: scraperSettings.language || 'en', overwrite: $('#scrape-overwrite').checked ? '1' : '0' });
$('#scrape-start').disabled = true; $('#scrape-result').textContent = '';
try {
await api('/api/scraper/settings?' + query, { method: 'POST' });
diff --git a/webui/index.html b/webui/index.html
index aa6f2f5..b8f0c24 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -25,11 +25,11 @@
Download media
Box art, screenshots and more for your games, kept on your PS5 and shared by RetroArch, EmulationStation and this page. Nothing you already have is downloaded again.