foxygit / infodisplay Log in
commit f9f8e796951cb44b01d2ae99ca21078767e52ab7
Author:     Jens Kristoffersson <jens.se@icloud.com>
AuthorDate: Sun Apr 5 14:54:43 2026 +0200
Commit:     Jens Kristoffersson <jens.se@icloud.com>
CommitDate: Sun Apr 5 14:54:43 2026 +0200

    update
---
 kiosk/addons/emulator/__init__.py          |  16 +++
 kiosk/addons/emulator/routes.py            | 181 +++++++++++++++++++++++++
 kiosk/app.py                               |  21 ++-
 kiosk/static/addons/emulator/panel.js      | 203 +++++++++++++++++++++++++++++
 kiosk/static/admin.js                      |  31 ++++-
 kiosk/static/display.js                    |   7 +-
 kiosk/templates/addons/emulator/panel.html |  75 +++++++++++
 kiosk/templates/admin.html                 |  16 ++-
 kiosk/templates/emulator.html              |  50 +++++++
 9 files changed, 587 insertions(+), 13 deletions(-)

diff --git a/kiosk/addons/emulator/__init__.py b/kiosk/addons/emulator/__init__.py
new file mode 100644
index 0000000..8c92b76
--- /dev/null
+++ b/kiosk/addons/emulator/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+    "name":            "emulator",
+    "label":           "Emulator",
+    "version":         "1.0",
+    "order":           7,
+    "has_panel":       True,
+    "has_overlay":     False,
+    "has_socketio":    False,
+    "state_prefix":    "emulator_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
diff --git a/kiosk/addons/emulator/routes.py b/kiosk/addons/emulator/routes.py
new file mode 100644
index 0000000..30a1d01
--- /dev/null
+++ b/kiosk/addons/emulator/routes.py
@@ -0,0 +1,181 @@
+from pathlib import Path
+from flask import Blueprint, jsonify, request, abort, render_template, send_file
+import db
+import extensions
+from auth import login_required
+from config import ALLOWED_ROOTS
+
+bp = Blueprint('emulator', __name__)
+
+# Filändelse → EJS_core-namn
+_EXT_TO_CORE = {
+    '.sfc': 'snes', '.smc': 'snes',
+    '.nes': 'nes',
+    '.gba': 'gba',
+    '.gbc': 'gbc', '.gb': 'gb',
+    '.n64': 'n64', '.z64': 'n64', '.v64': 'n64',
+    '.nds': 'nds',
+    '.md':  'segaMD', '.gen': 'segaMD', '.smd': 'segaMD',
+    '.bin': 'psx',   '.iso': 'psx',
+    '.psp': 'psp',
+    '.a26': 'atari2600',
+    '.gba': 'gba',
+}
+
+_CORE_LABELS = {
+    'snes':      'SNES',
+    'nes':       'NES / Famicom',
+    'gba':       'Game Boy Advance',
+    'gbc':       'Game Boy Color',
+    'gb':        'Game Boy',
+    'n64':       'Nintendo 64',
+    'nds':       'Nintendo DS',
+    'segaMD':    'Sega Mega Drive',
+    'psx':       'PlayStation 1',
+    'psp':       'PSP',
+    'atari2600': 'Atari 2600',
+}
+
+
+def _safe_path(path_str):
+    """Returnerar ett validerat Path-objekt eller None om utanför ALLOWED_ROOTS."""
+    try:
+        p = Path(path_str).resolve()
+        if any(str(p).startswith(str(r)) for r in ALLOWED_ROOTS):
+            return p
+    except Exception:
+        pass
+    return None
+
+
+# --- Spelsida (laddas i display-iframe) ---
+
+@bp.route('/emulator')
+def emulator_page():
+    rom_path = request.args.get('rom', '')
+    core     = request.args.get('core', '')
+    # Validera path
+    if not rom_path or not _safe_path(rom_path):
+        abort(403)
+    if core not in _CORE_LABELS:
+        core = _EXT_TO_CORE.get(Path(rom_path).suffix.lower(), 'snes')
+    ejs_path = db.get_state().get('emulator_ejs_path',
+                                  'https://cdn.emulatorjs.org/stable/data/')
+    return render_template('emulator.html',
+                           rom_path=rom_path,
+                           rom_name=Path(rom_path).name,
+                           core=core,
+                           core_label=_CORE_LABELS.get(core, core),
+                           ejs_path=ejs_path)
+
+
+# --- ROM-fil (serveras till webbläsaren) ---
+
+@bp.route('/api/emulator/rom')
+def serve_rom():
+    path_str = request.args.get('path', '')
+    p = _safe_path(path_str)
+    if not p or not p.is_file():
+        abort(404)
+    if p.suffix.lower() not in _EXT_TO_CORE:
+        abort(403)
+    return send_file(p)
+
+
+# --- ROM-lista ---
+
+@bp.route('/api/emulator/roms')
+@login_required
+def list_roms():
+    folder_str = request.args.get('path', db.get_state().get('emulator_rom_dir', ''))
+    if not folder_str:
+        return jsonify([])
+    folder = _safe_path(folder_str)
+    if not folder or not folder.is_dir():
+        return jsonify([])
+    roms = []
+    for f in sorted(folder.rglob('*')):
+        if f.is_file() and f.suffix.lower() in _EXT_TO_CORE:
+            core = _EXT_TO_CORE[f.suffix.lower()]
+            roms.append({
+                'name':       f.name,
+                'path':       str(f),
+                'core':       core,
+                'core_label': _CORE_LABELS.get(core, core),
+                'size_mb':    round(f.stat().st_size / 1_048_576, 1),
+            })
+    return jsonify(roms)
+
+
+# --- Starta emulator på display ---
+
+@bp.route('/api/emulator/launch', methods=['POST'])
+@login_required
+def launch():
+    data     = request.get_json() or {}
+    rom_path = str(data.get('rom', '')).strip()
+    core     = str(data.get('core', '')).strip()
+
+    if not rom_path or not _safe_path(rom_path):
+        return jsonify({'error': 'Ogiltig ROM-sökväg'}), 400
+    if core not in _CORE_LABELS:
+        core = _EXT_TO_CORE.get(Path(rom_path).suffix.lower(), 'snes')
+
+    db.set_state('emulator_active',  '1')
+    db.set_state('emulator_rom',     Path(rom_path).name)
+    db.set_state('emulator_rom_path', rom_path)
+    db.set_state('emulator_core',    core)
+
+    url = f'/emulator?rom={rom_path}&core={core}'
+    extensions.socketio.emit('switch', {
+        'type':      'url',
+        'source':    url,
+        'scheduled': False,
+    })
+    return '', 204
+
+
+# --- Stoppa emulator ---
+
+@bp.route('/api/emulator/stop', methods=['POST'])
+@login_required
+def stop():
+    db.set_state('emulator_active', '0')
+    extensions.socketio.emit('switch', {
+        'type':      'idle',
+        'source':    '',
+        'scheduled': False,
+    })
+    return '', 204
+
+
+# --- Status ---
+
+@bp.route('/api/emulator/status')
+@login_required
+def status():
+    state = db.get_state()
+    return jsonify({
+        'active':     state.get('emulator_active', '0') == '1',
+        'rom':        state.get('emulator_rom', ''),
+        'core':       state.get('emulator_core', ''),
+        'core_label': _CORE_LABELS.get(state.get('emulator_core', ''), ''),
+        'rom_dir':    state.get('emulator_rom_dir', ''),
+        'ejs_path':   state.get('emulator_ejs_path',
+                                'https://cdn.emulatorjs.org/stable/data/'),
+    })
+
+
+# --- Spara inställningar ---
+
+@bp.route('/api/emulator/settings', methods=['POST'])
+@login_required
+def save_settings():
+    data     = request.get_json() or {}
+    rom_dir  = str(data.get('rom_dir', '')).strip()
+    ejs_path = str(data.get('ejs_path', '')).strip()
+    if rom_dir:
+        db.set_state('emulator_rom_dir', rom_dir)
+    if ejs_path:
+        db.set_state('emulator_ejs_path', ejs_path)
+    return '', 204
diff --git a/kiosk/app.py b/kiosk/app.py
index ef547a8..e7eae5e 100644
--- a/kiosk/app.py
+++ b/kiosk/app.py
@@ -51,15 +51,31 @@ def _load_kiosk_conf():
 _load_kiosk_conf()

 app = Flask(__name__)
-app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24))
+
+def _get_secret_key():
+    """Hämta en persistent SECRET_KEY från DB eller miljövariabel."""
+    env_key = os.environ.get('SECRET_KEY')
+    if env_key:
+        return env_key
+    # Spara i DB så att sessioner överlever omstart
+    import db as _db
+    _db.init_db()
+    key = _db.get_state().get('_secret_key')
+    if not key:
+        key = os.urandom(32).hex()
+        _db.set_state('_secret_key', key)
+    return key
+
+app.config['SECRET_KEY'] = _get_secret_key()
 socketio = SocketIO(app, async_mode='eventlet', cors_allowed_origins='*')

 import extensions as _ext
 _ext.socketio = socketio

-# Ladda addons direkt vid import så Blueprints registreras oavsett startmetod
+# Ladda addons och starta scheduler direkt vid import, oavsett startmetod
 db.init_db()
 discover_and_load(app, socketio)
+sched.init_scheduler(socketio)

 KIOSK_USER = os.environ.get('KIOSK_USER', 'admin')
 KIOSK_PASS = os.environ.get('KIOSK_PASS', 'changeme')
@@ -314,5 +330,4 @@ if __name__ == '__main__':
     _apply_saved_audio()
     # Apply display settings in background after sway/Wayland is up
     eventlet.spawn(_apply_settings_after_cage)
-    sched.init_scheduler(socketio)
     socketio.run(app, host='0.0.0.0', port=5000)
diff --git a/kiosk/static/addons/emulator/panel.js b/kiosk/static/addons/emulator/panel.js
new file mode 100644
index 0000000..6cdc398
--- /dev/null
+++ b/kiosk/static/addons/emulator/panel.js
@@ -0,0 +1,203 @@
+/* Emulator admin-panel */
+(function () {
+
+  let _roms        = [];   // alla hittade ROMs
+  let _selectedRom = null; // { name, path, core, core_label }
+
+  // --- Init ---
+
+  async function emuInit() {
+    const s = await fetch('/api/emulator/status').then(r => r.json());
+    document.getElementById('emu-rom-dir').value  = s.rom_dir  || '';
+    document.getElementById('emu-ejs-path').value = s.ejs_path || '';
+    if (s.active) {
+      _showNowPlaying(s.rom, s.core_label);
+    }
+  }
+
+  // --- Bläddra efter ROM-mapp ---
+
+  function emuBrowseFolder() {
+    openBrowser(function (path) {
+      // openBrowser väljer fil; ta dess mapp
+      const dir = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : path;
+      document.getElementById('emu-rom-dir').value = dir;
+      emuScanRoms();
+    });
+  }
+
+  // --- Skanna ROM-mapp ---
+
+  async function emuScanRoms() {
+    const dir = document.getElementById('emu-rom-dir').value.trim();
+    if (!dir) return;
+
+    _setStatus('Skannar...');
+    await fetch('/api/emulator/settings', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ rom_dir: dir }),
+    });
+
+    const roms = await fetch(`/api/emulator/roms?path=${encodeURIComponent(dir)}`).then(r => r.json());
+    _roms = roms;
+    _renderRomList(roms);
+    _setStatus('');
+  }
+
+  // --- Filtrera ROM-lista ---
+
+  function emuFilterRoms(query) {
+    const q = query.toLowerCase();
+    _renderRomList(q ? _roms.filter(r => r.name.toLowerCase().includes(q)) : _roms);
+  }
+
+  // --- Rendera ROM-lista ---
+
+  function _renderRomList(roms) {
+    const wrap = document.getElementById('emu-rom-wrap');
+    const list = document.getElementById('emu-rom-list');
+    const none = document.getElementById('emu-no-roms');
+
+    if (!roms.length) {
+      wrap.style.display = 'none';
+      none.style.display = 'block';
+      return;
+    }
+
+    none.style.display = 'none';
+    wrap.style.display = 'block';
+
+    // Gruppera per system
+    const byCore = {};
+    roms.forEach(r => {
+      (byCore[r.core_label] = byCore[r.core_label] || []).push(r);
+    });
+
+    list.innerHTML = Object.entries(byCore).map(([label, group]) => `
+      <div style="padding:.35rem .6rem;font-size:.72rem;font-weight:700;color:#555;
+                  text-transform:uppercase;letter-spacing:.06em;background:#0a0a0a;
+                  border-bottom:1px solid #1a1a1a">${label}</div>
+      ${group.map(r => `
+        <div class="emu-rom-item" data-path="${_esc(r.path)}" data-core="${_esc(r.core)}"
+             data-name="${_esc(r.name)}" data-core-label="${_esc(r.core_label)}"
+             onclick="emuSelectRom(this)"
+             style="padding:.45rem .75rem;cursor:pointer;font-size:.88rem;
+                    display:flex;align-items:center;justify-content:space-between;
+                    border-bottom:1px solid #1a1a1a;transition:background .1s">
+          <span style="font-family:monospace;white-space:nowrap;overflow:hidden;
+                       text-overflow:ellipsis;max-width:80%">${_esc(r.name)}</span>
+          <span style="color:#444;font-size:.75rem;flex-shrink:0">${r.size_mb} MB</span>
+        </div>
+      `).join('')}
+    `).join('');
+
+    // Hover-effekt
+    list.querySelectorAll('.emu-rom-item').forEach(el => {
+      el.addEventListener('mouseenter', () => el.style.background = '#1a1a1a');
+      el.addEventListener('mouseleave', () => {
+        el.style.background = _selectedRom && el.dataset.path === _selectedRom.path
+          ? '#1e1b4b' : '';
+      });
+    });
+  }
+
+  // --- Välj ROM ---
+
+  function emuSelectRom(el) {
+    _selectedRom = {
+      path:       el.dataset.path,
+      core:       el.dataset.core,
+      name:       el.dataset.name,
+      core_label: el.dataset.coreLabel,
+    };
+
+    // Markera vald rad
+    document.querySelectorAll('.emu-rom-item').forEach(e => {
+      e.style.background = e === el ? '#1e1b4b' : '';
+    });
+
+    document.getElementById('emu-selected-name').textContent       = _selectedRom.name;
+    document.getElementById('emu-selected-core-label').textContent = _selectedRom.core_label;
+    document.getElementById('emu-selected-wrap').style.display     = 'block';
+    document.getElementById('emu-launch-btn').disabled             = false;
+    _setStatus('');
+  }
+
+  // --- Starta spel ---
+
+  async function emuLaunch() {
+    if (!_selectedRom) return;
+    _setStatus('Startar...');
+    const r = await fetch('/api/emulator/launch', {
+      method:  'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body:    JSON.stringify({ rom: _selectedRom.path, core: _selectedRom.core }),
+    });
+    if (r.ok) {
+      _showNowPlaying(_selectedRom.name, _selectedRom.core_label);
+      _setStatus('');
+    } else {
+      _setStatus('Kunde inte starta spelet');
+    }
+  }
+
+  // --- Stoppa ---
+
+  async function emuStop() {
+    await fetch('/api/emulator/stop', { method: 'POST' });
+    document.getElementById('emu-now-playing').style.display = 'none';
+    _setStatus('');
+  }
+
+  // --- Spara avancerade inställningar ---
+
+  async function emuSaveSettings() {
+    const ejs_path = document.getElementById('emu-ejs-path').value.trim();
+    await fetch('/api/emulator/settings', {
+      method:  'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body:    JSON.stringify({ ejs_path }),
+    });
+    _setStatus('Sparat');
+    setTimeout(() => _setStatus(''), 2000);
+  }
+
+  // --- Helpers ---
+
+  function _showNowPlaying(rom, coreLabel) {
+    document.getElementById('emu-now-playing').style.display     = 'flex';
+    document.getElementById('emu-now-playing-text').textContent  =
+      `${coreLabel ? coreLabel + ' — ' : ''}${rom}`;
+  }
+
+  function _setStatus(msg) {
+    document.getElementById('emu-status').textContent = msg;
+  }
+
+  function _esc(s) {
+    return String(s)
+      .replace(/&/g, '&amp;')
+      .replace(/"/g, '&quot;')
+      .replace(/</g, '&lt;')
+      .replace(/>/g, '&gt;');
+  }
+
+  // --- Exponera till global scope (anropas från panel.html) ---
+
+  window.emuScanRoms     = emuScanRoms;
+  window.emuBrowseFolder = emuBrowseFolder;
+  window.emuFilterRoms   = emuFilterRoms;
+  window.emuSelectRom    = emuSelectRom;
+  window.emuLaunch       = emuLaunch;
+  window.emuStop         = emuStop;
+  window.emuSaveSettings = emuSaveSettings;
+
+  // Init när DOM är redo
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', emuInit);
+  } else {
+    emuInit();
+  }
+
+})();
diff --git a/kiosk/static/admin.js b/kiosk/static/admin.js
index de0920e..5f54a4f 100644
--- a/kiosk/static/admin.js
+++ b/kiosk/static/admin.js
@@ -236,18 +236,37 @@ function sendReorder(tbody) {
   }).then(r => { if (r.ok) laddaJobb(); });
 }

-function _jobTypeChanged(formId, wrapId, statusId) {
-  const form = document.getElementById(formId);
-  const wrap = document.getElementById(wrapId);
-  if (!form || !wrap) return;
-  const isPlaylist = form.type.value === 'youtube_playlist';
-  wrap.style.display = isPlaylist ? 'flex' : 'none';
+function _jobTypeChanged(formId, wrapId, statusId, emuWrapId) {
+  const form    = document.getElementById(formId);
+  const wrap    = document.getElementById(wrapId);
+  const emuWrap = emuWrapId ? document.getElementById(emuWrapId) : null;
+  if (!form) return;
+
+  const type       = form.type.value;
+  const isPlaylist = type === 'youtube_playlist';
+  const isEmulator = type === 'emulator';
+
+  if (wrap)    wrap.style.display    = isPlaylist ? 'flex' : 'none';
+  if (emuWrap) emuWrap.style.display = isEmulator ? 'flex' : 'none';
+
   if (!isPlaylist && statusId) {
     const s = document.getElementById(statusId);
     if (s) s.textContent = '';
   }
 }

+// Öppnar ROM-bläddraren och fyller source-fältet i det angivna formuläret
+function openEmuBrowser(formId) {
+  const form    = document.getElementById(formId);
+  const nameEl  = document.getElementById(
+    formId === 'job-form' ? 'job-emu-name' : 'edit-emu-name'
+  );
+  openBrowser(function (path, _type) {
+    form.source.value = path;
+    if (nameEl) nameEl.textContent = path.split('/').pop();
+  });
+}
+
 async function extractJobPlaylist(formId) {
   const form = document.getElementById(formId);
   const isAdd = formId === 'job-form';
diff --git a/kiosk/static/display.js b/kiosk/static/display.js
index 9ea47de..f282e0b 100644
--- a/kiosk/static/display.js
+++ b/kiosk/static/display.js
@@ -599,7 +599,7 @@ socket.on('switch', (data) => {
     document.getElementById('frame').src = window.location.origin + '/chat-display';
     showLayer('iframe');

-  } else if (data.type === 'url') {
+  } else if (data.type === 'url' || data.type === 'youtube') {
     document.getElementById('frame').src = data.source;
     showLayer('iframe');

@@ -617,6 +617,11 @@ socket.on('switch', (data) => {
   } else if (data.type === 'youtube_playlist') {
     _startVideoPlaylist(data.source);

+  } else if (data.type === 'emulator') {
+    document.getElementById('frame').src =
+      '/emulator?rom=' + encodeURIComponent(data.source);
+    showLayer('iframe');
+
   } else if (data.type === 'pdf' || data.type === 'game') {
     document.getElementById('frame').src = data.source;
     showLayer('iframe');
diff --git a/kiosk/templates/addons/emulator/panel.html b/kiosk/templates/addons/emulator/panel.html
new file mode 100644
index 0000000..8204e26
--- /dev/null
+++ b/kiosk/templates/addons/emulator/panel.html
@@ -0,0 +1,75 @@
+    <script src="/static/addons/emulator/panel.js"></script>
+
+    <!-- Sektion: Emulator -->
+    <section>
+      <h2>Emulator</h2>
+      <p style="font-size:.85rem;color:#555;margin-bottom:.75rem">
+        Kör retro-spel direkt i display-fönstret via EmulatorJS (WebAssembly). Stöder SNES, NES, GBA, N64, PS1, Sega m.fl.
+      </p>
+
+      <!-- Spelar nu-banner -->
+      <div id="emu-now-playing" style="display:none;margin-bottom:.75rem;padding:.45rem .85rem;background:rgba(139,92,246,0.12);border:1px solid rgba(139,92,246,0.3);border-radius:7px;align-items:center;gap:.6rem;font-size:.88rem;color:#c4b5fd;">
+        <span>🎮</span>
+        <span id="emu-now-playing-text" style="flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">Spelar nu...</span>
+        <button class="secondary" style="padding:.2rem .6rem;font-size:.8rem" onclick="emuStop()">Avsluta</button>
+      </div>
+
+      <h3>ROM-mapp</h3>
+      <div class="prop-grid">
+        <label>Mapp</label>
+        <div class="row">
+          <input id="emu-rom-dir" type="text" placeholder="/home/mrfox/roms" style="flex:1"
+                 onkeydown="if(event.key==='Enter')emuScanRoms()">
+          <button type="button" class="secondary" onclick="emuBrowseFolder()">Bläddra</button>
+          <button type="button" onclick="emuScanRoms()">Skanna</button>
+        </div>
+      </div>
+
+      <!-- ROM-lista -->
+      <div id="emu-rom-wrap" style="display:none;margin:.75rem 0">
+        <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.4rem">
+          <h3 style="margin:0">Hittade spel</h3>
+          <input id="emu-filter" type="text" placeholder="Filtrera..." style="max-width:12rem;font-size:.85rem"
+                 oninput="emuFilterRoms(this.value)">
+        </div>
+        <div id="emu-rom-list" style="max-height:280px;overflow-y:auto;background:#111;border-radius:6px;border:1px solid #222;"></div>
+      </div>
+
+      <div id="emu-no-roms" style="display:none;color:#555;font-size:.85rem;font-style:italic;margin:.5rem 0">
+        Inga ROM-filer hittades i mappen.
+      </div>
+
+      <!-- Valt spel -->
+      <div id="emu-selected-wrap" style="display:none;margin:.75rem 0">
+        <h3>Valt spel</h3>
+        <div class="prop-grid">
+          <label>Spel</label>
+          <span id="emu-selected-name" style="font-size:.9rem;color:#e2e8f0;font-family:monospace"></span>
+          <label>System</label>
+          <span id="emu-selected-core-label" style="font-size:.9rem;color:#94a3b8"></span>
+        </div>
+      </div>
+
+      <div class="row" style="margin-top:.75rem">
+        <button id="emu-launch-btn" onclick="emuLaunch()" disabled>Starta spel</button>
+        <button class="secondary" onclick="emuStop()">Avsluta</button>
+        <span id="emu-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+
+      <!-- Avancerat -->
+      <details style="margin-top:1.25rem">
+        <summary style="cursor:pointer;color:#555;font-size:.85rem;user-select:none">Avancerat</summary>
+        <div class="prop-grid" style="margin-top:.75rem">
+          <label>EmulatorJS-källa</label>
+          <div>
+            <input id="emu-ejs-path" type="text" style="width:100%;font-size:.85rem;font-family:monospace"
+                   placeholder="https://cdn.emulatorjs.org/stable/data/">
+            <div style="font-size:.75rem;color:#444;margin-top:.3rem">
+              CDN (standard) eller lokal sökväg om du hostar EmulatorJS själv, t.ex. <code>/static/emulator/data/</code>
+            </div>
+          </div>
+          <label></label>
+          <button class="secondary" onclick="emuSaveSettings()" style="width:fit-content">Spara</button>
+        </div>
+      </details>
+    </section>
diff --git a/kiosk/templates/admin.html b/kiosk/templates/admin.html
index 7eb40c4..e7c4d9d 100644
--- a/kiosk/templates/admin.html
+++ b/kiosk/templates/admin.html
@@ -71,7 +71,7 @@
         <div class="form-grid">
           <input name="label" type="text" placeholder="Namn" required>
           <input name="duration" type="number" min="0.01" step="any" placeholder="Minuter (ex. 0.5)" required>
-          <select name="type" onchange="_jobTypeChanged('job-form','job-extract-wrap','job-extract-status')">
+          <select name="type" onchange="_jobTypeChanged('job-form','job-extract-wrap','job-extract-status','job-emu-wrap')">
             <option value="url">URL / HTML</option>
             <option value="youtube">YouTube (video)</option>
             <option value="youtube_playlist">YouTube-spellista (video)</option>
@@ -79,15 +79,20 @@
             <option value="image">Bild</option>
             <option value="pdf">PDF / Presentation</option>
             <option value="kiosk_chat">Kiosk Livechat</option>
+            <option value="emulator">Emulator (spel)</option>
           </select>
           <div class="source-with-browse">
             <input name="source" type="text" placeholder="URL eller välj fil" required>
             <button type="button" class="secondary" onclick="openBrowser(setSchemaCalla)">Bläddra</button>
           </div>
-          <div id="job-extract-wrap" style="display:none;grid-column:1/-1;display:none;flex-direction:column;gap:.25rem">
+          <div id="job-extract-wrap" style="display:none;grid-column:1/-1;flex-direction:column;gap:.25rem">
             <button type="button" class="secondary" onclick="extractJobPlaylist('job-form')">Extrahera spellista</button>
             <span id="job-extract-status" style="font-size:.75rem;color:#444"></span>
           </div>
+          <div id="job-emu-wrap" style="display:none;grid-column:1/-1;flex-direction:column;gap:.25rem">
+            <button type="button" class="secondary" onclick="openEmuBrowser('job-form')">Välj ROM-fil</button>
+            <span id="job-emu-name" style="font-size:.8rem;color:#94a3b8;font-family:monospace"></span>
+          </div>
           <label style="display:flex;align-items:center;gap:.5rem;color:#aaa;font-size:.9rem">
             <input type="checkbox" name="show_chat"> Visa livechat (YouTube)
           </label>
@@ -108,7 +113,7 @@
               <div class="form-grid">
                 <input name="label" type="text" placeholder="Namn" required>
                 <input name="duration" type="number" min="0.01" step="any" placeholder="Minuter" required>
-                <select name="type" onchange="_jobTypeChanged('edit-form','edit-extract-wrap','edit-extract-status')">
+                <select name="type" onchange="_jobTypeChanged('edit-form','edit-extract-wrap','edit-extract-status','edit-emu-wrap')">
                   <option value="url">URL / HTML</option>
                   <option value="youtube">YouTube (video)</option>
                   <option value="youtube_playlist">YouTube-spellista (video)</option>
@@ -116,12 +121,17 @@
                   <option value="image">Bild</option>
                   <option value="pdf">PDF / Presentation</option>
                   <option value="kiosk_chat">Kiosk Livechat</option>
+                  <option value="emulator">Emulator (spel)</option>
                 </select>
                 <input name="source" type="text" placeholder="URL eller filväg" required>
                 <div id="edit-extract-wrap" style="display:none;grid-column:1/-1;flex-direction:column;gap:.25rem">
                   <button type="button" class="secondary" onclick="extractJobPlaylist('edit-form')">Extrahera spellista</button>
                   <span id="edit-extract-status" style="font-size:.75rem;color:#444"></span>
                 </div>
+                <div id="edit-emu-wrap" style="display:none;grid-column:1/-1;flex-direction:column;gap:.25rem">
+                  <button type="button" class="secondary" onclick="openEmuBrowser('edit-form')">Välj ROM-fil</button>
+                  <span id="edit-emu-name" style="font-size:.8rem;color:#94a3b8;font-family:monospace"></span>
+                </div>
               </div>
               <div style="margin-top:.6rem"><button type="submit">Spara</button></div>
             </form>
diff --git a/kiosk/templates/emulator.html b/kiosk/templates/emulator.html
new file mode 100644
index 0000000..6f5f72e
--- /dev/null
+++ b/kiosk/templates/emulator.html
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+  <meta charset="UTF-8">
+  <title>{{ core_label }} — {{ rom_name }}</title>
+  <style>
+    * { margin: 0; padding: 0; box-sizing: border-box; }
+    html, body {
+      width: 100%; height: 100%;
+      background: #000;
+      overflow: hidden;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+    #game {
+      width: 100%;
+      height: 100%;
+    }
+  </style>
+</head>
+<body>
+  <div id="game"></div>
+
+  <script>
+    EJS_player      = '#game';
+    EJS_core        = '{{ core }}';
+    EJS_gameUrl     = '/api/emulator/rom?path={{ rom_path | urlencode }}';
+    EJS_pathtodata  = '{{ ejs_path }}';
+    EJS_language    = 'sv-SE';
+    EJS_color       = '#8b5cf6';
+
+    EJS_Buttons = {
+      playPause:    true,
+      restart:      true,
+      mute:         true,
+      settings:     true,
+      fullscreen:   true,
+      saveState:    true,
+      loadState:    true,
+      screenRecord: false,
+      gamepad:      true,
+      cheat:        false,
+      volume:       true,
+      saveSavFiles: true,
+    };
+  </script>
+  <script src="{{ ejs_path }}loader.js"></script>
+</body>
+</html>