foxygit / infodisplay Log in
commit 7b2b540839b5a5bc97353ad8a458080b1e908ce2
Author:     Jens Kristoffersson <jens.se@icloud.com>
AuthorDate: Sat Apr 4 16:28:36 2026 +0200
Commit:     Jens Kristoffersson <jens.se@icloud.com>
CommitDate: Sat Apr 4 16:28:36 2026 +0200

    uppdatering
---
 README.md                                   |  236 +++-
 kiosk/addon_loader.py                       |   81 ++
 kiosk/addons/__init__.py                    |    0
 kiosk/addons/bluetooth/__init__.py          |   16 +
 kiosk/addons/bluetooth/routes.py            |  198 +++
 kiosk/addons/clock/__init__.py              |   16 +
 kiosk/addons/clock/routes.py                |   61 +
 kiosk/addons/filebrowser/__init__.py        |   16 +
 kiosk/addons/filebrowser/routes.py          |   88 ++
 kiosk/addons/livechat/__init__.py           |   21 +
 kiosk/addons/livechat/events.py             |   84 ++
 kiosk/addons/livechat/routes.py             |  283 +++++
 kiosk/addons/music/__init__.py              |   16 +
 kiosk/addons/music/routes.py                |  159 +++
 kiosk/addons/settings/__init__.py           |   16 +
 kiosk/addons/settings/routes.py             |  273 ++++
 kiosk/addons/sleep/__init__.py              |   31 +
 kiosk/addons/sleep/panel.html               |   20 +
 kiosk/addons/sleep/routes.py                |   40 +
 kiosk/addons/system/__init__.py             |   16 +
 kiosk/addons/system/routes.py               |   33 +
 kiosk/addons/ticker/__init__.py             |   16 +
 kiosk/addons/ticker/routes.py               |  127 ++
 kiosk/addons/wifi/__init__.py               |   22 +
 kiosk/addons/wifi/routes.py                 |  195 +++
 kiosk/app.py                                | 1791 ++-------------------------
 kiosk/auth.py                               |   15 +
 kiosk/config.py                             |   16 +
 kiosk/db.py                                 |   28 +
 kiosk/extensions.py                         |    7 +
 kiosk/static/addons/.gitkeep                |    0
 kiosk/static/addons/sleep/admin.js          |   25 +
 kiosk/templates/addons/bluetooth/panel.html |   34 +
 kiosk/templates/addons/clock/panel.html     |   58 +
 kiosk/templates/addons/livechat/panel.html  |   86 ++
 kiosk/templates/addons/music/panel.html     |   67 +
 kiosk/templates/addons/settings/panel.html  |   48 +
 kiosk/templates/addons/sleep/panel.html     |   21 +
 kiosk/templates/addons/system/panel.html    |    9 +
 kiosk/templates/addons/ticker/panel.html    |  149 +++
 kiosk/templates/addons/wifi/panel.html      |   63 +
 kiosk/templates/admin.html                  |  555 +--------
 kiosk/templates/display.html                |    3 +
 kiosk/utils/__init__.py                     |    0
 kiosk/utils/display.py                      |  147 +++
 kiosk/utils/system.py                       |   13 +
 kiosk/utils/youtube.py                      |   30 +
 47 files changed, 2943 insertions(+), 2286 deletions(-)

diff --git a/README.md b/README.md
index 67c4cf7..2e7f12e 100644
--- a/README.md
+++ b/README.md
@@ -156,36 +156,231 @@ Dator startar

 ```
 display/
-├── install.sh                  # Installationsscript
-├── start.sh                    # Gammal cage-baserad start (ej använd)
+├── install.sh                   # Installationsscript
 └── kiosk/
-    ├── start.sh                # Startar Flask (sätter env-vars, exec python3 app.py)
-    ├── kiosk-launcher.sh       # Startar Flask + sway, genererar output-config
-    ├── sway.conf               # Sway-konfiguration (hide_cursor, fullscreen etc.)
-    ├── sway-apps.sh            # Startar Chromium på rätt workspace
-    ├── hdmi-monitor.sh         # Övervakar HDMI-anslutning
-    ├── hdmi-wait.sh            # Väntar på HDMI vid start
-    ├── kiosk.conf              # Genereras av admin (ljud/skärm-inställningar)
-    ├── kiosk.db                # SQLite-databas
-    ├── app.py                  # Flask-app, API-endpoints, WebSocket
-    ├── db.py                   # Databashjälpfunktioner
-    ├── scheduler.py            # Schemarotation (APScheduler)
-    ├── uploads/                # Uppladdade mediefiler
+    ├── app.py                   # Flask-app: core routes, WebSocket, startup
+    ├── auth.py                  # login_required-dekoratör
+    ├── config.py                # Delade konstanter (ALLOWED_ROOTS, UPLOAD_DIR m.m.)
+    ├── db.py                    # SQLite-helpers + AddonDB
+    ├── extensions.py            # Delad socketio-referens (undviker cirkulär import)
+    ├── addon_loader.py          # Addon-discovery och Blueprint-registrering
+    ├── scheduler.py             # Schemarotation (APScheduler)
+    ├── start.sh                 # Startar Flask (sätter env-vars)
+    ├── kiosk-launcher.sh        # Startar Flask + sway, genererar output-config
+    ├── sway.conf                # Sway-konfiguration
+    ├── sway-apps.sh             # Startar Chromium på rätt workspace
+    ├── hdmi-monitor.sh          # Övervakar HDMI-anslutning
+    ├── hdmi-wait.sh             # Väntar på HDMI vid start
+    ├── kiosk.conf               # Genereras av admin (ljud/skärm-inställningar)
+    ├── kiosk.db                 # SQLite-databas
+    ├── addons/                  # Addon-paket (ett paket per funktion)
+    │   ├── ticker/
+    │   ├── clock/
+    │   ├── music/
+    │   ├── livechat/
+    │   ├── sleep/
+    │   ├── wifi/
+    │   ├── bluetooth/
+    │   ├── filebrowser/
+    │   ├── settings/
+    │   └── system/
+    ├── utils/
+    │   ├── display.py           # sway_env, apply_display_mode, apply_saved_audio
+    │   ├── system.py            # local_ip
+    │   └── youtube.py           # yt_extract, playlist_cache
+    ├── uploads/                 # Uppladdade mediefiler
     ├── static/
     │   ├── admin.js
     │   ├── display.js
     │   ├── style.css
-    │   └── socket.io.min.js    # Laddas ner av install.sh
+    │   ├── socket.io.min.js
+    │   └── addons/              # Statiska filer per addon (overlay.js m.m.)
     └── templates/
-        ├── admin.html
+        ├── admin.html           # Skaltemplate — inkluderar addon-paneler dynamiskt
         ├── display.html
         ├── login.html
         ├── chat_display.html
-        └── chat_mobile.html
+        ├── chat_mobile.html
+        └── addons/              # HTML-fragment per addon-panel
+            ├── ticker/panel.html
+            ├── clock/panel.html
+            └── ...
 ```

 ---

+## Skapa ett addon
+
+Addons är vanliga Python-paket under `kiosk/addons/`. De autodiscoveras vid uppstart — ingen registrering behövs.
+
+### 1. Skapa paketmappen
+
+```
+kiosk/addons/mitt_addon/
+    __init__.py
+    routes.py
+```
+
+### 2. Deklarera manifestet i `__init__.py`
+
+```python
+ADDON = {
+    "name":            "mitt_addon",   # måste matcha mappnamnet
+    "label":           "Mitt Addon",   # visas i adminpanelen
+    "version":         "1.0",
+    "order":           10,             # sorteringsordning i adminpanelen
+    "has_panel":       True,           # True om panel.html finns
+    "has_overlay":     False,          # True om static/addons/<name>/overlay.js finns
+    "has_socketio":    False,          # True om register_socketio() finns
+    "state_prefix":    "mitt_addon_",  # prefix för alla DB-nycklar
+    "default_enabled": True,
+}
+
+def create_blueprint():
+    from .routes import bp
+    return bp
+
+# Behövs bara om has_socketio = True:
+def register_socketio(socketio):
+    from .events import MittNamespace
+    socketio.on_namespace(MittNamespace('/mitt_addon'))
+```
+
+### 3. Definiera routes i `routes.py`
+
+```python
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+from auth import login_required
+
+bp = Blueprint('mitt_addon', __name__, url_prefix='/api/mitt_addon')
+
+@bp.route('/settings', methods=['GET'])
+def get_settings():
+    state = db.get_state()
+    return jsonify({
+        'enabled': state.get('mitt_addon_enabled', '0') == '1',
+        'value':   state.get('mitt_addon_value', ''),
+    })
+
+@bp.route('/settings', methods=['POST'])
+@login_required
+def set_settings():
+    data    = request.get_json() or {}
+    enabled = bool(data.get('enabled', False))
+    value   = str(data.get('value', ''))
+    db.set_state('mitt_addon_enabled', '1' if enabled else '0')
+    db.set_state('mitt_addon_value',   value)
+    extensions.socketio.emit('mitt_addon_update', {'enabled': enabled, 'value': value})
+    return '', 204
+```
+
+**Tips — `AddonDB` för namnrymdsavgränsad DB-åtkomst:**
+
+```python
+from db import AddonDB
+adb = AddonDB('mitt_addon_')   # prefixar alla nycklar automatiskt
+
+adb.set('enabled', '1')        # lagras som "mitt_addon_enabled"
+adb.get('value', 'default')    # läser "mitt_addon_value"
+adb.get_all()                  # {'enabled': '1', 'value': '...'} (utan prefix)
+```
+
+### 4. Adminpanel — `templates/addons/mitt_addon/panel.html`
+
+HTML-fragmentet inkluderas automatiskt i `admin.html` om `has_panel = True`. Det behöver ingen `<html>`/`<body>` — bara innehållet:
+
+```html
+<section>
+  <h2>Mitt Addon</h2>
+  <div class="prop-grid">
+    <label>Aktivera</label>
+    <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+      <input type="checkbox" id="mitt-addon-enabled">
+      Aktivera funktionen
+    </label>
+
+    <label>Värde</label>
+    <input type="text" id="mitt-addon-value" placeholder="...">
+  </div>
+  <div class="row" style="margin-top:.75rem">
+    <button onclick="sparaMittAddon()">Spara</button>
+    <span id="mitt-addon-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+  </div>
+</section>
+```
+
+### 5. Display-overlay — `static/addons/mitt_addon/overlay.js` *(valfritt)*
+
+Om `has_overlay = True` laddas skriptet automatiskt på display-sidan. Skriptet skapar sina egna DOM-element och lyssnar på SocketIO-events:
+
+```javascript
+(function () {
+  const el = document.createElement('div');
+  el.id = 'mitt-addon-overlay';
+  el.style.cssText = 'display:none;position:fixed;z-index:9000;pointer-events:none;';
+  document.body.appendChild(el);
+
+  const socket = window._kioskSocket; // definieras i display.js
+
+  socket.on('mitt_addon_update', (data) => {
+    el.style.display = data.enabled ? 'block' : 'none';
+    el.textContent = data.value;
+  });
+})();
+```
+
+### 6. SocketIO-events — `events.py` *(valfritt)*
+
+Behövs bara om addons behöver lyssna på WebSocket-events (inte bara sända):
+
+```python
+from flask import request
+from flask_socketio import Namespace
+import extensions
+
+class MittNamespace(Namespace):
+    def on_connect(self):
+        extensions.socketio.emit('mitt_status', {'ok': True}, to=request.sid,
+                                  namespace='/mitt_addon')
+
+    def on_mitt_event(self, data):
+        # hantera inkommande event
+        extensions.socketio.emit('mitt_svar', {'echo': data}, namespace='/mitt_addon')
+```
+
+### Referens — ADDON-manifest
+
+| Fält | Typ | Beskrivning |
+|---|---|---|
+| `name` | str | Unikt ID — måste matcha mappnamnet |
+| `label` | str | Visningsnamn i adminpanel |
+| `version` | str | Fri versionssträng |
+| `order` | int | Sorteringsordning för panelen (lägre = högre upp) |
+| `has_panel` | bool | `True` om `templates/addons/<name>/panel.html` finns |
+| `has_overlay` | bool | `True` om `static/addons/<name>/overlay.js` finns |
+| `has_socketio` | bool | `True` om `register_socketio()` är definierad i `__init__.py` |
+| `state_prefix` | str | Prefix för alla nycklar som addons sparar i DB |
+| `default_enabled` | bool | Om addons är aktiverat första gången (kan ändras i admin) |
+
+### Aktivera / inaktivera ett addon
+
+Varje addon kan slås av och på via API (kräver admin-inloggning):
+
+```bash
+# Inaktivera
+curl -X POST http://localhost:5000/api/addons/mitt_addon/toggle \
+     -H "Cookie: session=..."
+
+# Lista status för alla addons
+curl http://localhost:5000/api/addons -H "Cookie: session=..."
+```
+
+Ändringen sparas i databasen och träder i kraft vid nästa omstart.
+
+---
+
 ## Konfiguration

 ### kiosk.conf
@@ -212,6 +407,7 @@ export SECRET_KEY=en-lång-hemlig-sträng
 | Komponent | Teknologi |
 |---|---|
 | Backend | Python 3, Flask, Flask-SocketIO, eventlet |
+| Addon-system | Flask Blueprints + `pkgutil.iter_modules()` |
 | Schemaläggare | APScheduler |
 | Databas | SQLite |
 | Frontend | Vanilla JS, Socket.IO |
@@ -252,3 +448,9 @@ Kontrollera att `seat seat0 hide_cursor 3000` finns i `sway.conf` (läggs dit av

 **Translate-popup i Chromium:**
 `sway-apps.sh` pre-seedar `Default/Preferences` med `"translate": {"enabled": false}` innan Chromium startar. Om det ändå visas: ta bort `/tmp/kiosk-display` och starta om.
+
+**Ett addon laddas inte:**
+Kontrollera att mappnamnet matchar `ADDON["name"]` och att `__init__.py` innehåller ett `ADDON`-dict. Addons loggas vid uppstart:
+```
+[addon_loader] Laddade addon: mitt_addon (Mitt Addon)
+```
diff --git a/kiosk/addon_loader.py b/kiosk/addon_loader.py
new file mode 100644
index 0000000..2a5aacf
--- /dev/null
+++ b/kiosk/addon_loader.py
@@ -0,0 +1,81 @@
+"""
+Addon-loader för kiosk.
+
+Skannar kiosk/addons/ efter underpaket med ett ADDON-manifest (dict i __init__.py).
+Registrerar aktiverade addons som Flask-Blueprints och SocketIO-handlers vid uppstart.
+"""
+
+import pkgutil
+import importlib
+import db
+
+_loaded_addons = []   # lista av ADDON-dicts i laddningsordning
+
+
+def discover_and_load(app, socketio):
+    """
+    Skanna addons/-paketet och ladda aktiverade addons.
+    Anropas efter att app och socketio skapats, innan socketio.run().
+    """
+    import addons  # kiosk/addons/__init__.py
+
+    for finder, name, is_pkg in pkgutil.iter_modules(addons.__path__):
+        if not is_pkg:
+            continue
+
+        try:
+            mod = importlib.import_module(f'addons.{name}')
+        except Exception as e:
+            print(f'[addon_loader] Kunde inte importera addon "{name}": {e}')
+            continue
+
+        manifest = getattr(mod, 'ADDON', None)
+        if not manifest:
+            continue
+
+        # Kolla aktiverat i DB; faller tillbaka på default_enabled
+        state_key = f'addon_{name}_enabled'
+        db_val = db.get_state().get(state_key)
+        if db_val is None:
+            enabled = manifest.get('default_enabled', True)
+        else:
+            enabled = db_val == '1'
+
+        if not enabled:
+            print(f'[addon_loader] Addon "{name}" är inaktiverat, hoppar över.')
+            continue
+
+        # Registrera Flask Blueprint
+        if hasattr(mod, 'create_blueprint'):
+            try:
+                bp = mod.create_blueprint()
+                app.register_blueprint(bp)
+            except Exception as e:
+                print(f'[addon_loader] Blueprint-fel för addon "{name}": {e}')
+                continue
+
+        # Registrera SocketIO-handlers
+        if manifest.get('has_socketio') and hasattr(mod, 'register_socketio'):
+            try:
+                mod.register_socketio(socketio)
+            except Exception as e:
+                print(f'[addon_loader] SocketIO-fel för addon "{name}": {e}')
+
+        _loaded_addons.append(manifest)
+        print(f'[addon_loader] Laddade addon: {name} ({manifest.get("label", name)})')
+
+    _loaded_addons.sort(key=lambda m: m.get('order', 99))
+
+
+def get_loaded_addons():
+    """Returnerar en kopia av listan med laddade addon-manifest."""
+    return list(_loaded_addons)
+
+
+def get_all_addon_names():
+    """
+    Returnerar alla addon-namn som finns i addons/-mappen
+    (både aktiverade och inaktiverade).
+    """
+    import addons
+    return [name for _, name, is_pkg in pkgutil.iter_modules(addons.__path__) if is_pkg]
diff --git a/kiosk/addons/__init__.py b/kiosk/addons/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/kiosk/addons/bluetooth/__init__.py b/kiosk/addons/bluetooth/__init__.py
new file mode 100644
index 0000000..9e2347e
--- /dev/null
+++ b/kiosk/addons/bluetooth/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+    "name": "bluetooth",
+    "label": "Bluetooth",
+    "version": "1.0",
+    "order": 10,
+    "has_overlay": False,
+    "has_panel": True,
+    "has_socketio": False,
+    "state_prefix": "bt_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
diff --git a/kiosk/addons/bluetooth/routes.py b/kiosk/addons/bluetooth/routes.py
new file mode 100644
index 0000000..2c720ee
--- /dev/null
+++ b/kiosk/addons/bluetooth/routes.py
@@ -0,0 +1,198 @@
+import re
+import subprocess
+import threading
+import time
+from flask import Blueprint, jsonify, request, abort
+from auth import login_required
+
+bp = Blueprint('bluetooth', __name__, url_prefix='/api/bluetooth')
+
+_BT_ADDR_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
+
+_bt_scanning  = False
+_bt_scan_lock = threading.Lock()
+_bt_scan_proc = None
+
+
+def _bt_run(*args, timeout=15):
+    cmd = ['bluetoothctl'] + list(args)
+    try:
+        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+        return r.returncode, r.stdout.strip()
+    except subprocess.TimeoutExpired:
+        return 1, 'timeout'
+    except Exception as e:
+        return 1, str(e)
+
+
+def _bt_status():
+    rc, out = _bt_run('show')
+    powered = False
+    name = ''
+    addr = ''
+    discovering = False
+    for line in out.splitlines():
+        s = line.strip()
+        if s.startswith('Powered:'):       powered     = 'yes' in s
+        elif s.startswith('Name:'):        name        = s.split(':', 1)[1].strip()
+        elif s.startswith('Discovering:'): discovering = 'yes' in s
+    m = re.search(r'([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', out.split('\n')[0] if out else '')
+    if m:
+        addr = m.group(0)
+    return {
+        'powered':     powered,
+        'name':        name,
+        'address':     addr,
+        'discovering': discovering or _bt_scanning,
+        'available':   rc == 0,
+    }
+
+
+def _bt_device_info(addr):
+    rc, info = _bt_run('info', addr)
+    connected = paired = trusted = False
+    dev_name = ''
+    dev_type = ''
+    for line in info.splitlines():
+        s = line.strip()
+        if s.startswith('Name:'):        dev_name  = s.split(':', 1)[1].strip()
+        elif s.startswith('Connected:'): connected = 'yes' in s
+        elif s.startswith('Paired:'):    paired    = 'yes' in s
+        elif s.startswith('Trusted:'):   trusted   = 'yes' in s
+        elif s.startswith('Icon:'):
+            icon = s.split(':', 1)[1].strip()
+            if any(k in icon for k in ('audio', 'headphone', 'headset', 'speaker')):
+                dev_type = 'audio'
+            elif 'phone' in icon:
+                dev_type = 'phone'
+            elif 'input' in icon:
+                dev_type = 'input'
+            else:
+                dev_type = icon
+    return {
+        'address': addr, 'name': dev_name,
+        'connected': connected, 'paired': paired, 'trusted': trusted,
+        'type': dev_type,
+    }
+
+
+def _bt_devices():
+    rc, out = _bt_run('devices')
+    result = []
+    for line in out.splitlines():
+        m = re.match(r'Device\s+([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\s+(.*)', line)
+        if m:
+            addr = m.group(1)
+            name = m.group(2).strip()
+            info = _bt_device_info(addr)
+            if not info['name']:
+                info['name'] = name
+            result.append(info)
+    return result
+
+
+def _bt_scan_worker(duration=15):
+    global _bt_scanning, _bt_scan_proc
+    try:
+        _bt_scan_proc = subprocess.Popen(
+            ['bluetoothctl', 'scan', 'on'],
+            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+        )
+        time.sleep(duration)
+    finally:
+        try:
+            if _bt_scan_proc:
+                _bt_scan_proc.terminate()
+                _bt_scan_proc = None
+        except Exception:
+            pass
+        _bt_run('scan', 'off', timeout=5)
+        with _bt_scan_lock:
+            _bt_scanning = False
+
+
+@bp.route('/status')
+@login_required
+def api_bt_status():
+    return jsonify(_bt_status())
+
+
+@bp.route('/devices')
+@login_required
+def api_bt_devices():
+    return jsonify({'devices': _bt_devices(), 'scanning': _bt_scanning})
+
+
+@bp.route('/scan', methods=['POST'])
+@login_required
+def api_bt_scan():
+    global _bt_scanning
+    with _bt_scan_lock:
+        if _bt_scanning:
+            return jsonify({'ok': True, 'scanning': True})
+        _bt_scanning = True
+    t = threading.Thread(target=_bt_scan_worker, args=(15,), daemon=True)
+    t.start()
+    return jsonify({'ok': True, 'scanning': True})
+
+
+@bp.route('/pair', methods=['POST'])
+@login_required
+def api_bt_pair():
+    data = request.get_json()
+    if not data or not data.get('address'):
+        abort(400)
+    addr = str(data['address'])
+    if not _BT_ADDR_RE.match(addr):
+        abort(400)
+    _bt_run('trust', addr, timeout=5)
+    rc, out = _bt_run('pair', addr, timeout=30)
+    ok = rc == 0 or 'already paired' in out.lower() or 'already exists' in out.lower()
+    if ok:
+        _bt_run('connect', addr, timeout=15)
+    return jsonify({'ok': ok, 'message': '' if ok else out.split('\n')[-1]})
+
+
+@bp.route('/connect', methods=['POST'])
+@login_required
+def api_bt_connect():
+    data = request.get_json()
+    if not data or not data.get('address'):
+        abort(400)
+    addr = str(data['address'])
+    if not _BT_ADDR_RE.match(addr):
+        abort(400)
+    rc, out = _bt_run('connect', addr, timeout=15)
+    ok = rc == 0 or 'connected' in out.lower()
+    return jsonify({'ok': ok, 'message': '' if ok else out.split('\n')[-1]})
+
+
+@bp.route('/disconnect', methods=['POST'])
+@login_required
+def api_bt_disconnect():
+    data = request.get_json()
+    if not data or not data.get('address'):
+        abort(400)
+    addr = str(data['address'])
+    if not _BT_ADDR_RE.match(addr):
+        abort(400)
+    _bt_run('disconnect', addr, timeout=10)
+    return '', 204
+
+
+@bp.route('/device/<path:address>', methods=['DELETE'])
+@login_required
+def api_bt_remove(address):
+    if not _BT_ADDR_RE.match(address):
+        abort(400)
+    _bt_run('remove', address, timeout=10)
+    return '', 204
+
+
+@bp.route('/power', methods=['POST'])
+@login_required
+def api_bt_power():
+    data = request.get_json() or {}
+    on = bool(data.get('on', True))
+    _bt_run('power', 'on' if on else 'off', timeout=5)
+    return jsonify(_bt_status())
diff --git a/kiosk/addons/clock/__init__.py b/kiosk/addons/clock/__init__.py
new file mode 100644
index 0000000..b686f5c
--- /dev/null
+++ b/kiosk/addons/clock/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+    "name": "clock",
+    "label": "Klocka",
+    "version": "1.0",
+    "order": 3,
+    "has_overlay": False,
+    "has_panel": True,
+    "has_socketio": False,
+    "state_prefix": "clock_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
diff --git a/kiosk/addons/clock/routes.py b/kiosk/addons/clock/routes.py
new file mode 100644
index 0000000..e4fcd0d
--- /dev/null
+++ b/kiosk/addons/clock/routes.py
@@ -0,0 +1,61 @@
+import re
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+from auth import login_required
+
+bp = Blueprint('clock', __name__, url_prefix='/api')
+
+_HEX_COLOR = re.compile(r'^#[0-9a-fA-F]{6}$')
+
+
+@bp.route('/clock', methods=['GET'])
+def get_clock():
+    state = db.get_state()
+    return jsonify({
+        'enabled':       state.get('clock_enabled',       '0') == '1',
+        'style':         state.get('clock_style',         'digital'),
+        'show_date':     state.get('clock_show_date',     '0') == '1',
+        'position':      state.get('clock_position',      'top-right'),
+        'size':          float(state.get('clock_size',    '2')),
+        'color':         state.get('clock_color',         '#ffffff'),
+        'opacity':       int(state.get('clock_opacity',   '90')),
+        'schedule_mode': state.get('clock_schedule_mode', 'always'),
+    })
+
+
+@bp.route('/clock', methods=['POST'])
+@login_required
+def set_clock():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    enabled  = bool(data.get('enabled', False))
+    style    = 'analog' if data.get('style') == 'analog' else 'digital'
+    show_date = bool(data.get('show_date', False))
+    position  = data.get('position', 'top-right')
+    if position not in {'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center'}:
+        position = 'top-right'
+    size    = max(1.0, min(5.0, float(data.get('size', 2))))
+    color   = str(data.get('color', '#ffffff'))
+    if not _HEX_COLOR.match(color):
+        color = '#ffffff'
+    opacity = max(0, min(100, int(data.get('opacity', 90))))
+    schedule_mode = 'scheduled_only' if data.get('schedule_mode') == 'scheduled_only' else 'always'
+
+    db.set_state('clock_enabled',       '1' if enabled else '0')
+    db.set_state('clock_style',         style)
+    db.set_state('clock_show_date',     '1' if show_date else '0')
+    db.set_state('clock_position',      position)
+    db.set_state('clock_size',          str(size))
+    db.set_state('clock_color',         color)
+    db.set_state('clock_opacity',       str(opacity))
+    db.set_state('clock_schedule_mode', schedule_mode)
+
+    payload = {
+        'enabled': enabled, 'style': style, 'show_date': show_date,
+        'position': position, 'size': size, 'color': color,
+        'opacity': opacity, 'schedule_mode': schedule_mode,
+    }
+    extensions.socketio.emit('clock_settings', payload)
+    return '', 204
diff --git a/kiosk/addons/filebrowser/__init__.py b/kiosk/addons/filebrowser/__init__.py
new file mode 100644
index 0000000..26296c3
--- /dev/null
+++ b/kiosk/addons/filebrowser/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+    "name": "filebrowser",
+    "label": "Filhanterare",
+    "version": "1.0",
+    "order": 7,
+    "has_overlay": False,
+    "has_panel": False,
+    "has_socketio": False,
+    "state_prefix": "filebrowser_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
diff --git a/kiosk/addons/filebrowser/routes.py b/kiosk/addons/filebrowser/routes.py
new file mode 100644
index 0000000..d07aab7
--- /dev/null
+++ b/kiosk/addons/filebrowser/routes.py
@@ -0,0 +1,88 @@
+import shutil
+import subprocess
+from pathlib import Path
+from werkzeug.utils import secure_filename
+from flask import Blueprint, jsonify, request, abort, send_file
+from auth import login_required
+from config import ALLOWED_ROOTS, MEDIA_EXTENSIONS, UPLOAD_DIR
+
+bp = Blueprint('filebrowser', __name__)
+
+
+@bp.route('/files')
+def serve_file():
+    path = request.args.get('path', '')
+    if not path:
+        abort(400)
+    p = Path(path).resolve()
+    if not any(p.is_relative_to(r) for r in ALLOWED_ROOTS):
+        abort(403)
+    if not p.is_file():
+        abort(404)
+    return send_file(p)
+
+
+@bp.route('/api/upload', methods=['POST'])
+@login_required
+def upload():
+    if 'file' not in request.files:
+        abort(400)
+    f = request.files['file']
+    if not f.filename:
+        abort(400)
+    name = secure_filename(f.filename)
+    ext = Path(name).suffix.lower()
+    if ext not in MEDIA_EXTENSIONS:
+        abort(415)
+    UPLOAD_DIR.mkdir(exist_ok=True)
+    dest = UPLOAD_DIR / name
+    if dest.exists():
+        stem = Path(name).stem
+        i = 1
+        while dest.exists():
+            dest = UPLOAD_DIR / f'{stem}_{i}{ext}'
+            i += 1
+    f.save(dest)
+
+    # Konvertera presentationer till PDF med LibreOffice om tillgängligt
+    if ext in {'.ppt', '.pptx', '.odp'} and shutil.which('libreoffice'):
+        try:
+            subprocess.run(
+                ['libreoffice', '--headless', '--convert-to', 'pdf', str(dest), '--outdir', str(UPLOAD_DIR)],
+                timeout=120, check=True,
+                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+            )
+            pdf_path = UPLOAD_DIR / (dest.stem + '.pdf')
+            if pdf_path.exists():
+                dest.unlink()
+                dest = pdf_path
+        except Exception:
+            pass
+
+    return jsonify({'path': str(dest), 'url': '/files?path=' + str(dest)})
+
+
+@bp.route('/api/browse')
+@login_required
+def browse():
+    req_path = request.args.get('path', str(Path('/home/mrfox')))
+    p = Path(req_path).resolve()
+    if not any(p.is_relative_to(r) for r in ALLOWED_ROOTS):
+        abort(403)
+    if not p.is_dir():
+        abort(404)
+    entries = []
+    try:
+        for entry in sorted(p.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
+            if entry.name.startswith('.'):
+                continue
+            if entry.is_dir():
+                entries.append({'name': entry.name, 'path': str(entry), 'type': 'dir'})
+            elif entry.suffix.lower() in MEDIA_EXTENSIONS:
+                entries.append({'name': entry.name, 'path': str(entry), 'type': 'file', 'ext': entry.suffix.lower()})
+    except PermissionError:
+        pass
+    parent = str(p.parent)
+    if not any(Path(parent).is_relative_to(r) for r in ALLOWED_ROOTS):
+        parent = None
+    return jsonify({'path': str(p), 'parent': parent, 'entries': entries})
diff --git a/kiosk/addons/livechat/__init__.py b/kiosk/addons/livechat/__init__.py
new file mode 100644
index 0000000..080b0be
--- /dev/null
+++ b/kiosk/addons/livechat/__init__.py
@@ -0,0 +1,21 @@
+ADDON = {
+    "name": "livechat",
+    "label": "Livechat",
+    "version": "1.0",
+    "order": 5,
+    "has_overlay": False,
+    "has_panel": True,
+    "has_socketio": True,
+    "state_prefix": "chat_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
+
+
+def register_socketio(socketio):
+    from .events import ChatNamespace
+    socketio.on_namespace(ChatNamespace('/chat'))
diff --git a/kiosk/addons/livechat/events.py b/kiosk/addons/livechat/events.py
new file mode 100644
index 0000000..6860e66
--- /dev/null
+++ b/kiosk/addons/livechat/events.py
@@ -0,0 +1,84 @@
+from flask import request
+from flask_socketio import Namespace
+import extensions
+from .routes import chat_tokens
+
+
+class ChatNamespace(Namespace):
+    _users    = {}  # sid -> {name, color, emoji, avatar_url}
+    _messages = []  # recent message history (last 50)
+
+    def on_connect(self):
+        sid = request.sid
+        if self._messages:
+            extensions.socketio.emit('chat_history', self._messages, to=sid, namespace='/chat')
+        extensions.socketio.emit('viewer_count', {'count': len(self._users)}, to=sid, namespace='/chat')
+
+    def on_join(self, data):
+        token = str(data.get('token', '')).strip()
+        if not token:
+            return
+        user_info = chat_tokens.get(token)
+        if not user_info:
+            self.emit('auth_error', {'error': 'Ogiltig token, logga in igen'})
+            return
+        if request.sid in self._users:
+            return
+        username   = user_info['username']
+        emoji      = user_info['emoji']
+        color      = user_info['color']
+        avatar_url = user_info.get('avatar_url')
+        self._users[request.sid] = {'name': username, 'color': color, 'emoji': emoji, 'avatar_url': avatar_url}
+        # Re-send history to authenticated client so chat-mobile gets it after login
+        if self._messages:
+            extensions.socketio.emit('chat_history', self._messages, to=request.sid, namespace='/chat')
+        extensions.socketio.emit('viewer_count', {'count': len(self._users)}, namespace='/chat')
+
+    def on_chat_message(self, data):
+        user = self._users.get(request.sid)
+        if not user:
+            return
+        text = str(data.get('text', '')).strip()[:300]
+        if not text:
+            return
+        msg = {
+            'name':       user['name'],
+            'color':      user['color'],
+            'emoji':      user.get('emoji'),
+            'avatar_url': user.get('avatar_url'),
+            'text':       text,
+        }
+        self._messages.append(msg)
+        if len(self._messages) > 50:
+            self._messages.pop(0)
+        extensions.socketio.emit('chat_message', msg, namespace='/chat')
+
+    def on_play_suggestion(self, data):
+        user = self._users.get(request.sid)
+        if not user:
+            return
+        url = str(data.get('url', '')).strip()[:500]
+        if not url or not url.startswith(('http://', 'https://')):
+            return
+        msg = {
+            'name':       user['name'],
+            'color':      user['color'],
+            'emoji':      user.get('emoji'),
+            'avatar_url': user.get('avatar_url'),
+            'text':       url,
+            'kind':       'play_suggestion',
+        }
+        self._messages.append(msg)
+        if len(self._messages) > 50:
+            self._messages.pop(0)
+        # Skicka till alla i /chat-namespacet (display + mobil)
+        extensions.socketio.emit('play_suggestion', msg, namespace='/chat')
+        # Notifiera admin-panelen via huvud-namespacet
+        extensions.socketio.emit('play_suggestion', {
+            'name': user['name'], 'color': user['color'], 'url': url,
+        })
+
+    def on_disconnect(self):
+        user = self._users.pop(request.sid, None)
+        if user:
+            extensions.socketio.emit('viewer_count', {'count': len(self._users)}, namespace='/chat')
diff --git a/kiosk/addons/livechat/routes.py b/kiosk/addons/livechat/routes.py
new file mode 100644
index 0000000..63a2b5a
--- /dev/null
+++ b/kiosk/addons/livechat/routes.py
@@ -0,0 +1,283 @@
+import re
+import json
+import uuid
+from pathlib import Path
+from werkzeug.utils import secure_filename
+from werkzeug.security import generate_password_hash, check_password_hash
+from flask import Blueprint, jsonify, request, abort, send_file, render_template
+import db
+import extensions
+from auth import login_required
+from utils.system import local_ip
+
+bp = Blueprint('livechat', __name__)
+
+_HEX_COLOR = re.compile(r'^#[0-9a-fA-F]{6}$')
+
+CHAT_USERS_FILE = Path(__file__).parent.parent.parent / 'chat_users.json'
+CHAT_AVATARS_DIR = Path(__file__).parent.parent.parent / 'static' / 'chat_avatars'
+CHAT_IMAGES_DIR  = Path(__file__).parent.parent.parent / 'static' / 'chat_images'
+
+CHAT_AVATARS_DIR.mkdir(exist_ok=True)
+CHAT_IMAGES_DIR.mkdir(exist_ok=True)
+
+_AVATAR_EXTS   = ['.jpg', '.jpeg', '.png', '.webp']
+_CHAT_IMG_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
+
+_CHAT_COLORS = [
+    '#f87171', '#fb923c', '#fbbf24', '#a3e635',
+    '#34d399', '#38bdf8', '#818cf8', '#e879f9',
+    '#f472b6', '#94a3b8',
+]
+
+# Shared in-memory token store: token -> {username, emoji, color, avatar_url}
+# Imported by events.py so both modules share the same dict.
+chat_tokens = {}
+
+
+def _load_chat_users():
+    try:
+        if CHAT_USERS_FILE.exists():
+            return json.loads(CHAT_USERS_FILE.read_text())
+    except Exception:
+        pass
+    return {}
+
+
+def _save_chat_users(users):
+    CHAT_USERS_FILE.write_text(json.dumps(users, indent=2))
+
+
+def get_avatar_url(username):
+    safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
+    for ext in _AVATAR_EXTS:
+        if (CHAT_AVATARS_DIR / (safe + ext)).exists():
+            return f'/api/chat/avatar/{username}'
+    return None
+
+
+def _restore_chat_tokens():
+    users = _load_chat_users()
+    for username, u in users.items():
+        token = u.get('token')
+        if token:
+            chat_tokens[token] = {
+                'username':   username,
+                'emoji':      u.get('emoji', '😀'),
+                'color':      u.get('color', '#94a3b8'),
+                'avatar_url': get_avatar_url(username),
+            }
+
+
+_restore_chat_tokens()
+
+
+# --- Page routes ---
+
+@bp.route('/chat-display')
+def chat_display():
+    return render_template('chat_display.html', local_ip=local_ip())
+
+
+@bp.route('/chat-mobile')
+def chat_mobile():
+    return render_template('chat_mobile.html')
+
+
+@bp.route('/api/chat-qr')
+def chat_qr():
+    try:
+        import qrcode as _qr
+        import io as _io
+        url = f'http://{local_ip()}:5000/chat-mobile'
+        qr = _qr.QRCode(box_size=20, border=2)
+        qr.add_data(url)
+        qr.make(fit=True)
+        img = qr.make_image()
+        buf = _io.BytesIO()
+        img.save(buf, format='PNG')
+        buf.seek(0)
+        return send_file(buf, mimetype='image/png', max_age=30)
+    except ImportError:
+        abort(501)
+
+
+# --- Auth routes ---
+
+@bp.route('/api/chat/register', methods=['POST'])
+def chat_register():
+    data     = request.get_json(silent=True) or {}
+    username = str(data.get('username', '')).strip()[:32]
+    password = str(data.get('password', '')).strip()
+    emoji    = str(data.get('emoji', '😀')).strip()[:4]
+    if not username or not password:
+        return jsonify({'error': 'Användarnamn och lösenord krävs'}), 400
+    users = _load_chat_users()
+    if username.lower() in {k.lower() for k in users}:
+        return jsonify({'error': 'Användarnamnet är redan taget'}), 409
+    color = _CHAT_COLORS[len(users) % len(_CHAT_COLORS)]
+    token = str(uuid.uuid4())
+    users[username] = {
+        'password_hash': generate_password_hash(password),
+        'emoji':         emoji,
+        'color':         color,
+        'token':         token,
+    }
+    _save_chat_users(users)
+    chat_tokens[token] = {'username': username, 'emoji': emoji, 'color': color, 'avatar_url': None}
+    return jsonify({'token': token, 'username': username, 'emoji': emoji, 'color': color, 'avatar_url': None})
+
+
+@bp.route('/api/chat/login', methods=['POST'])
+def chat_login():
+    data     = request.get_json(silent=True) or {}
+    username = str(data.get('username', '')).strip()
+    password = str(data.get('password', '')).strip()
+    if not username or not password:
+        return jsonify({'error': 'Fyll i användarnamn och lösenord'}), 400
+    users = _load_chat_users()
+    key   = next((k for k in users if k.lower() == username.lower()), None)
+    if not key or not check_password_hash(users[key]['password_hash'], password):
+        return jsonify({'error': 'Fel användarnamn eller lösenord'}), 401
+    u          = users[key]
+    avatar_url = get_avatar_url(key)
+    token      = str(uuid.uuid4())
+    chat_tokens[token] = {'username': key, 'emoji': u['emoji'], 'color': u['color'], 'avatar_url': avatar_url}
+    users[key]['token'] = token
+    _save_chat_users(users)
+    return jsonify({'token': token, 'username': key, 'emoji': u['emoji'], 'color': u['color'], 'avatar_url': avatar_url})
+
+
+@bp.route('/api/chat/validate', methods=['POST'])
+def chat_validate():
+    data  = request.get_json(silent=True) or {}
+    token = str(data.get('token', ''))
+    user  = chat_tokens.get(token)
+    if not user:
+        return jsonify({'error': 'Ogiltig token'}), 401
+    if not user.get('avatar_url'):
+        user['avatar_url'] = get_avatar_url(user['username'])
+    return jsonify({'username': user['username'], 'emoji': user['emoji'], 'color': user['color'], 'avatar_url': user.get('avatar_url')})
+
+
+# --- Avatar & image routes ---
+
+@bp.route('/api/chat/avatar/<username>')
+def serve_chat_avatar(username):
+    safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
+    for ext in _AVATAR_EXTS:
+        p = CHAT_AVATARS_DIR / (safe + ext)
+        if p.exists():
+            return send_file(p)
+    abort(404)
+
+
+@bp.route('/api/chat/avatar', methods=['POST'])
+def upload_chat_avatar():
+    token     = request.form.get('token', '')
+    user_info = chat_tokens.get(token)
+    if not user_info:
+        return jsonify({'error': 'Ogiltig token'}), 401
+    f = request.files.get('avatar')
+    if not f or not f.filename:
+        return jsonify({'error': 'Ingen fil'}), 400
+    ext = Path(secure_filename(f.filename)).suffix.lower()
+    if ext not in _AVATAR_EXTS:
+        return jsonify({'error': 'Använd jpg, png eller webp'}), 400
+    username = user_info['username']
+    safe     = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
+    for old_ext in _AVATAR_EXTS:
+        old = CHAT_AVATARS_DIR / (safe + old_ext)
+        if old.exists():
+            old.unlink()
+    dest = CHAT_AVATARS_DIR / (safe + ext)
+    f.save(str(dest))
+    avatar_url = f'/api/chat/avatar/{username}'
+    user_info['avatar_url'] = avatar_url
+    users = _load_chat_users()
+    if username in users:
+        users[username]['avatar_url'] = avatar_url
+        _save_chat_users(users)
+    return jsonify({'avatar_url': avatar_url})
+
+
+@bp.route('/api/chat/image', methods=['POST'])
+def upload_chat_image():
+    token     = request.form.get('token', '')
+    user_info = chat_tokens.get(token)
+    if not user_info:
+        return jsonify({'error': 'Ogiltig token'}), 401
+    f = request.files.get('image')
+    if not f or not f.filename:
+        return jsonify({'error': 'Ingen fil'}), 400
+    ext = Path(secure_filename(f.filename)).suffix.lower()
+    if ext not in _CHAT_IMG_EXTS:
+        return jsonify({'error': 'Använd jpg, png, gif eller webp'}), 400
+    filename = uuid.uuid4().hex + ext
+    f.save(str(CHAT_IMAGES_DIR / filename))
+    return jsonify({'url': f'/static/chat_images/{filename}'})
+
+
+# --- Chat overlay settings (YouTube chat + kiosk chat display) ---
+
+@bp.route('/api/chat', methods=['GET'])
+def get_chat():
+    state = db.get_state()
+    return jsonify({
+        'enabled':         state.get('chat_enabled',         '0') == '1',
+        'position':        state.get('chat_position',        'right'),
+        'width_pct':       int(state.get('chat_width_pct',   '25')),
+        'height':          int(state.get('chat_height',      '100')),
+        'bg_color':        state.get('chat_bg_color',        '#000000'),
+        'bg_opacity':      int(state.get('chat_bg_opacity',  '70')),
+        'text_color':      state.get('chat_text_color',      '#ffffff'),
+        'font_size':       int(state.get('chat_font_size',   '14')),
+        'kiosk_enabled':   state.get('kiosk_chat_enabled',   '0') == '1',
+        'kiosk_position':  state.get('kiosk_chat_position',  'right'),
+        'kiosk_width_pct': int(state.get('kiosk_chat_width', '30')),
+        'kiosk_height':    int(state.get('kiosk_chat_height','80')),
+    })
+
+
+@bp.route('/api/chat', methods=['POST'])
+@login_required
+def set_chat():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    enabled    = bool(data.get('enabled', False))
+    position   = 'left' if data.get('position') == 'left' else 'right'
+    width_pct  = max(5,  min(60,  int(data.get('width_pct',  25))))
+    height     = max(30, min(100, int(data.get('height',    100))))
+    bg_color   = str(data.get('bg_color',   '#000000'))
+    bg_opacity = max(0,  min(100, int(data.get('bg_opacity',  70))))
+    text_color = str(data.get('text_color', '#ffffff'))
+    font_size  = max(8,  min(48,  int(data.get('font_size',   14))))
+    if not _HEX_COLOR.match(bg_color):   bg_color   = '#000000'
+    if not _HEX_COLOR.match(text_color): text_color = '#ffffff'
+    kiosk_enabled   = bool(data.get('kiosk_enabled', False))
+    kiosk_position  = 'left' if data.get('kiosk_position') == 'left' else 'right'
+    kiosk_width_pct = max(10, min(60,  int(data.get('kiosk_width_pct', 30))))
+    kiosk_height    = max(30, min(100, int(data.get('kiosk_height',    80))))
+    db.set_state('chat_enabled',          '1' if enabled else '0')
+    db.set_state('chat_position',         position)
+    db.set_state('chat_width_pct',        str(width_pct))
+    db.set_state('chat_height',           str(height))
+    db.set_state('chat_bg_color',         bg_color)
+    db.set_state('chat_bg_opacity',       str(bg_opacity))
+    db.set_state('chat_text_color',       text_color)
+    db.set_state('chat_font_size',        str(font_size))
+    db.set_state('kiosk_chat_enabled',    '1' if kiosk_enabled else '0')
+    db.set_state('kiosk_chat_position',   kiosk_position)
+    db.set_state('kiosk_chat_width',      str(kiosk_width_pct))
+    db.set_state('kiosk_chat_height',     str(kiosk_height))
+    payload = {
+        'enabled': enabled, 'position': position,
+        'width_pct': width_pct, 'height': height,
+        'bg_color': bg_color, 'bg_opacity': bg_opacity,
+        'text_color': text_color, 'font_size': font_size,
+        'kiosk_enabled': kiosk_enabled, 'kiosk_position': kiosk_position,
+        'kiosk_width_pct': kiosk_width_pct, 'kiosk_height': kiosk_height,
+    }
+    extensions.socketio.emit('chat_settings', payload)
+    return '', 204
diff --git a/kiosk/addons/music/__init__.py b/kiosk/addons/music/__init__.py
new file mode 100644
index 0000000..03b6a81
--- /dev/null
+++ b/kiosk/addons/music/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+    "name": "music",
+    "label": "Musik",
+    "version": "1.0",
+    "order": 4,
+    "has_overlay": False,
+    "has_panel": True,
+    "has_socketio": False,
+    "state_prefix": "music_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
diff --git a/kiosk/addons/music/routes.py b/kiosk/addons/music/routes.py
new file mode 100644
index 0000000..4bb8173
--- /dev/null
+++ b/kiosk/addons/music/routes.py
@@ -0,0 +1,159 @@
+import subprocess
+from pathlib import Path
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+from auth import login_required
+from config import ALLOWED_ROOTS
+from utils.youtube import yt_extract, playlist_cache as _yt_playlist_cache
+
+bp = Blueprint('music', __name__, url_prefix='/api')
+
+_MUSIC_EXTS = {'.mp3', '.flac', '.ogg', '.wav', '.m4a', '.aac'}
+_music_playlist_cache = []
+
+
+@bp.route('/music', methods=['GET'])
+def get_music():
+    state = db.get_state()
+    return jsonify({
+        'enabled':     state.get('music_enabled', '0') == '1',
+        'source_type': state.get('music_source_type', 'local'),
+        'source':      state.get('music_source', ''),
+        'volume':      int(state.get('music_volume', '50')),
+        'shuffle':     state.get('music_shuffle', '0') == '1',
+        'show_overlay': state.get('music_show_overlay', '1') == '1',
+        'overlay_position': state.get('music_overlay_position', 'bottom-left'),
+        'now_playing': {
+            'title':  state.get('music_now_title', ''),
+            'artist': state.get('music_now_artist', ''),
+        },
+    })
+
+
+@bp.route('/music', methods=['POST'])
+@login_required
+def set_music():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    enabled     = bool(data.get('enabled', False))
+    source_type = 'youtube' if data.get('source_type') == 'youtube' else 'local'
+    source      = str(data.get('source', ''))
+    volume      = max(0, min(100, int(data.get('volume', 50))))
+    shuffle     = bool(data.get('shuffle', False))
+    show_overlay     = bool(data.get('show_overlay', True))
+    overlay_position = str(data.get('overlay_position', 'bottom-left'))
+    if overlay_position not in {'top-left', 'top-right', 'bottom-left', 'bottom-right'}:
+        overlay_position = 'bottom-left'
+
+    db.set_state('music_enabled',          '1' if enabled else '0')
+    db.set_state('music_source_type',      source_type)
+    db.set_state('music_source',           source)
+    db.set_state('music_volume',           str(volume))
+    db.set_state('music_shuffle',          '1' if shuffle else '0')
+    db.set_state('music_show_overlay',     '1' if show_overlay else '0')
+    db.set_state('music_overlay_position', overlay_position)
+
+    payload = {
+        'enabled': enabled, 'source_type': source_type,
+        'source': source, 'volume': volume, 'shuffle': shuffle,
+        'show_overlay': show_overlay, 'overlay_position': overlay_position,
+    }
+    extensions.socketio.emit('music_settings', payload)
+    return '', 204
+
+
+@bp.route('/music/tracks', methods=['GET'])
+@login_required
+def get_music_tracks():
+    folder = request.args.get('path', '')
+    if not folder:
+        return jsonify([])
+    p = Path(folder)
+    try:
+        p = p.resolve()
+        if not any(str(p).startswith(str(r)) for r in ALLOWED_ROOTS):
+            abort(403)
+    except Exception:
+        abort(400)
+    if not p.is_dir():
+        return jsonify([])
+    tracks = sorted([
+        {'name': f.name, 'path': str(f)}
+        for f in p.iterdir()
+        if f.is_file() and f.suffix.lower() in _MUSIC_EXTS
+    ], key=lambda x: x['name'])
+    return jsonify(tracks)
+
+
+@bp.route('/music/extract', methods=['POST'])
+@login_required
+def extract_music_playlist():
+    """Extract video IDs for the music playlist and persist to DB."""
+    global _music_playlist_cache
+    data = request.get_json() or {}
+    url  = str(data.get('url', '')).strip()
+    if not url:
+        return jsonify({'error': 'URL saknas'}), 400
+    try:
+        ids, _total_seconds = yt_extract(url)
+    except subprocess.TimeoutExpired:
+        return jsonify({'error': 'Timeout — spellistans hämtning tog för lång tid'}), 504
+    except Exception as e:
+        return jsonify({'error': str(e)}), 500
+
+    _music_playlist_cache = ids
+    _yt_playlist_cache[url] = ids
+    db.set_state('music_playlist_ids', ','.join(ids))
+    return jsonify({'count': len(ids), 'ids': ids})
+
+
+@bp.route('/music/playlist', methods=['GET'])
+def get_music_playlist():
+    """Return the extracted playlist video IDs."""
+    global _music_playlist_cache
+    if not _music_playlist_cache:
+        stored = db.get_state().get('music_playlist_ids', '')
+        _music_playlist_cache = [v for v in stored.split(',') if v] if stored else []
+    return jsonify({'ids': _music_playlist_cache, 'count': len(_music_playlist_cache)})
+
+
+@bp.route('/music/nowplaying', methods=['POST'])
+def update_now_playing():
+    """Called by display client to broadcast now-playing state to admin."""
+    data   = request.get_json() or {}
+    title  = str(data.get('title',  ''))[:200]
+    artist = str(data.get('artist', ''))[:200]
+    db.set_state('music_now_title',  title)
+    db.set_state('music_now_artist', artist)
+    extensions.socketio.emit('now_playing', {'title': title, 'artist': artist})
+    return '', 204
+
+
+# --- YouTube playlist extraction (video & musik) ---
+
+@bp.route('/playlist/extract', methods=['POST'])
+@login_required
+def extract_playlist():
+    """Extract video IDs from any public YouTube playlist URL and cache by URL."""
+    data = request.get_json() or {}
+    url  = str(data.get('url', '')).strip()
+    if not url:
+        return jsonify({'error': 'URL saknas'}), 400
+    try:
+        ids, total_seconds = yt_extract(url)
+    except subprocess.TimeoutExpired:
+        return jsonify({'error': 'Timeout'}), 504
+    except Exception as e:
+        return jsonify({'error': str(e)}), 500
+    _yt_playlist_cache[url] = ids
+    return jsonify({'count': len(ids), 'ids': ids, 'total_seconds': total_seconds})
+
+
+@bp.route('/playlist/ids', methods=['GET'])
+def get_playlist_ids():
+    """Return cached video IDs for a playlist URL."""
+    url = request.args.get('url', '').strip()
+    ids = _yt_playlist_cache.get(url, [])
+    return jsonify({'ids': ids, 'count': len(ids)})
diff --git a/kiosk/addons/settings/__init__.py b/kiosk/addons/settings/__init__.py
new file mode 100644
index 0000000..1abaa41
--- /dev/null
+++ b/kiosk/addons/settings/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+    "name": "settings",
+    "label": "Inställningar",
+    "version": "1.0",
+    "order": 8,
+    "has_overlay": False,
+    "has_panel": True,
+    "has_socketio": False,
+    "state_prefix": "settings_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
diff --git a/kiosk/addons/settings/routes.py b/kiosk/addons/settings/routes.py
new file mode 100644
index 0000000..2728c1d
--- /dev/null
+++ b/kiosk/addons/settings/routes.py
@@ -0,0 +1,273 @@
+import os
+import re
+import shutil
+import subprocess
+from pathlib import Path
+import eventlet
+from flask import Blueprint, jsonify, request, abort
+import db
+from auth import login_required
+from utils.display import (
+    sway_env, audio_device_id, parse_profiles,
+    apply_saved_audio, apply_display_mode,
+)
+from utils.system import local_ip
+
+bp = Blueprint('settings', __name__, url_prefix='/api')
+
+
+def _apply_internal_display(enabled, env):
+    """Aktivera eller stäng av eDP-1 (intern display) via sway."""
+    try:
+        if enabled:
+            subprocess.run(['swaymsg', 'output', 'eDP-1', 'enable'], env=env, timeout=5)
+            eventlet.sleep(0.5)
+            ip = local_ip()
+            flags = (
+                '--kiosk --ozone-platform=wayland --noerr --no-first-run '
+                '--disable-infobars --disable-session-crashed-bubble --disable-translate '
+                '--disable-web-security --autoplay-policy=no-user-gesture-required '
+                '--no-sandbox --user-data-dir=/tmp/kiosk-admin'
+            )
+            subprocess.run(['swaymsg', 'workspace 2'], env=env, timeout=5)
+            eventlet.sleep(0.3)
+            subprocess.run(['swaymsg', 'exec', f'chromium {flags} http://{ip}:5000/admin'],
+                           env=env, timeout=5)
+            eventlet.sleep(1)
+            subprocess.run(['swaymsg', 'workspace 1'], env=env, timeout=5)
+        else:
+            subprocess.run(['pkill', '-f', 'user-data-dir=/tmp/kiosk-admin'], timeout=5)
+            eventlet.sleep(0.3)
+            subprocess.run(['swaymsg', 'output', 'eDP-1', 'disable'], env=env, timeout=5)
+    except Exception:
+        pass
+
+
+@bp.route('/settings/apply_display', methods=['POST'])
+@login_required
+def apply_display_now():
+    data = request.get_json(silent=True) or {}
+    mode = data.get('display_mode')
+    target = data.get('display_output') or None
+    if not mode:
+        state = db.get_state()
+        mode = state.get('display_mode', '')
+    if not mode:
+        return jsonify({'ok': False, 'error': 'no_mode'}), 400
+    try:
+        ok = apply_display_mode(str(mode), target_output=target)
+        apply_saved_audio()
+        return jsonify({'ok': bool(ok)})
+    except Exception as e:
+        return jsonify({'ok': False, 'error': str(e)}), 500
+
+
+@bp.route('/audio/profiles')
+@login_required
+def audio_profiles():
+    dev_id = audio_device_id()
+    if dev_id is None:
+        return jsonify([])
+    try:
+        pw_env = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+        out = subprocess.check_output(
+            ['pw-cli', 'enum-params', str(dev_id), 'EnumProfile'],
+            timeout=5, env=pw_env
+        ).decode()
+        return jsonify(parse_profiles(out))
+    except Exception:
+        return jsonify([])
+
+
+@bp.route('/displays')
+@login_required
+def list_displays():
+    outputs = []
+    try:
+        for conn in sorted(Path('/sys/class/drm').iterdir()):
+            status_file = conn / 'status'
+            if not status_file.exists():
+                continue
+            parts = conn.name.split('-', 1)
+            if len(parts) != 2:
+                continue
+            connector = parts[1]
+            connected = status_file.read_text().strip() == 'connected'
+            outputs.append({'name': connector, 'connected': connected})
+    except Exception:
+        pass
+
+    # Best-effort: xrandr-läge (X11)
+    try:
+        if shutil.which('xrandr'):
+            out = ''
+            for disp in (None, ':0', ':0.0', ':1'):
+                try:
+                    env = os.environ.copy()
+                    if disp:
+                        env['DISPLAY'] = disp
+                    out = subprocess.check_output(['xrandr', '--query'], env=env, timeout=5).decode(errors='ignore')
+                    if out and ' connected' in out:
+                        break
+                except Exception:
+                    out = ''
+            lines = out.splitlines() if out else []
+            cur_out = None
+            modes_map = {}
+            for line in lines:
+                m = re.match(r"^([A-Za-z0-9-]+) connected", line)
+                if m:
+                    cur_out = m.group(1)
+                    modes_map[cur_out] = []
+                    continue
+                if cur_out:
+                    m2 = re.match(r"^\s+(\d+)x(\d+)\s+([0-9\.]+)\s*Hz?(.*)$", line)
+                    if m2:
+                        token = f"{m2.group(1)}x{m2.group(2)}@{m2.group(3)}"
+                        if token not in modes_map[cur_out]:
+                            modes_map[cur_out].append(token)
+                        if '*' in (m2.group(4) or '') or 'current' in (m2.group(4) or ''):
+                            modes_map.setdefault('__current__', {})[cur_out] = token
+                    elif line.strip() == '':
+                        cur_out = None
+            if modes_map:
+                for o in outputs:
+                    if o['name'] in modes_map and modes_map[o['name']]:
+                        o['modes'] = modes_map[o['name']]
+                curmap = modes_map.get('__current__', {})
+                for o in outputs:
+                    if o['name'] in curmap:
+                        o['current_mode'] = curmap[o['name']]
+    except Exception:
+        pass
+
+    # Best-effort: wlr-randr-läge (Wayland)
+    try:
+        if not any(o.get('modes') for o in outputs) and shutil.which('wlr-randr'):
+            env = os.environ.copy()
+            if not env.get('WAYLAND_DISPLAY'):
+                xdg_runtime = env.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
+                for sock in sorted(Path(xdg_runtime).glob('wayland-[0-9]*')):
+                    if sock.suffix != '.lock':
+                        env['WAYLAND_DISPLAY'] = sock.name
+                        break
+            out = subprocess.check_output(['wlr-randr'], timeout=5, env=env).decode(errors='ignore')
+            cur_out = None
+            modes_map = {}
+            for line in out.splitlines():
+                if not line.strip():
+                    continue
+                if line[0] not in (' ', '\t'):
+                    cur_out = line.strip().split()[0]
+                    modes_map[cur_out] = []
+                    continue
+                m = re.search(r"(\d+)x(\d+)\s+px,\s+([0-9\.]+)\s+Hz(?:.*(current))?", line)
+                if cur_out and m:
+                    token = f"{m.group(1)}x{m.group(2)}@{m.group(3)}"
+                    if token not in modes_map[cur_out]:
+                        modes_map[cur_out].append(token)
+                    if 'current' in line:
+                        modes_map.setdefault('__current__', {})[cur_out] = token
+            current_map = modes_map.pop('__current__', {})
+            for o in outputs:
+                if o['name'] in modes_map and modes_map[o['name']]:
+                    o['modes'] = modes_map[o['name']]
+                if o['name'] in current_map:
+                    o['current_mode'] = current_map[o['name']]
+    except Exception:
+        pass
+
+    return jsonify(outputs)
+
+
+@bp.route('/volume', methods=['GET'])
+@login_required
+def get_volume():
+    try:
+        out = subprocess.check_output(['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@'], timeout=5).decode()
+        vol = round(float(out.split()[1]) * 100)
+        return jsonify({'volume': vol})
+    except Exception:
+        return jsonify({'volume': 100})
+
+
+@bp.route('/volume', methods=['POST'])
+@login_required
+def set_volume():
+    data = request.get_json()
+    vol = max(0, min(100, int(data.get('volume', 100))))
+    subprocess.run(['wpctl', 'set-volume', '@DEFAULT_AUDIO_SINK@', f'{vol/100:.2f}'], timeout=5)
+    return '', 204
+
+
+@bp.route('/settings', methods=['GET'])
+@login_required
+def get_settings():
+    state = db.get_state()
+    return jsonify({
+        'audio_profile_index': int(state.get('audio_profile_index', 1)),
+        'display_output':      state.get('display_output', 'last'),
+        'display_mode':        state.get('display_mode', ''),
+        'internal_display':    state.get('internal_display', 'on'),
+    })
+
+
+@bp.route('/settings', methods=['POST'])
+@login_required
+def save_settings():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    profile_index    = int(data.get('audio_profile_index', 1))
+    display          = data.get('display_output', 'last')
+    display_mode     = str(data.get('display_mode', '')).strip()
+    internal_display = 'on' if data.get('internal_display', True) else 'off'
+    db.set_state('audio_profile_index', str(profile_index))
+    db.set_state('display_output', display)
+    db.set_state('internal_display', internal_display)
+    if display_mode:
+        db.set_state('display_mode', display_mode)
+    conf = Path(os.path.dirname(__file__)).parent.parent / 'kiosk.conf'
+    conf.write_text(
+        '# Genererad av kiosk-admin — ändra ej manuellt\n'
+        f'KIOSK_AUDIO_PROFILE_INDEX={profile_index}\n'
+        f'KIOSK_DISPLAY_OUTPUT={display}\n'
+        f'KIOSK_INTERNAL_DISPLAY={internal_display}\n'
+        + (f'KIOSK_DISPLAY_MODE={display_mode}\n' if display_mode else '')
+    )
+    dev_id = audio_device_id()
+    if dev_id is not None:
+        try:
+            pw_env = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+            subprocess.run(
+                ['pw-cli', 's', str(dev_id), 'Profile', f'{{ index: {profile_index} }}'],
+                timeout=5, check=True, env=pw_env
+            )
+        except Exception:
+            pass
+    return '', 204
+
+
+@bp.route('/internal_display', methods=['POST'])
+@login_required
+def toggle_internal_display():
+    data = request.get_json()
+    if data is None:
+        abort(400)
+    enabled = bool(data.get('enabled', True))
+    val = 'on' if enabled else 'off'
+    db.set_state('internal_display', val)
+    state = db.get_state()
+    conf = Path(os.path.dirname(__file__)).parent.parent / 'kiosk.conf'
+    conf.write_text(
+        '# Genererad av kiosk-admin — ändra ej manuellt\n'
+        f'KIOSK_AUDIO_PROFILE_INDEX={state.get("audio_profile_index", 1)}\n'
+        f'KIOSK_DISPLAY_OUTPUT={state.get("display_output", "HDMI-A-1")}\n'
+        f'KIOSK_INTERNAL_DISPLAY={val}\n'
+        + (f'KIOSK_DISPLAY_MODE={state["display_mode"]}\n' if state.get("display_mode") else '')
+    )
+    env = sway_env()
+    if not env:
+        return '', 204
+    eventlet.spawn(_apply_internal_display, enabled, env)
+    return '', 204
diff --git a/kiosk/addons/sleep/__init__.py b/kiosk/addons/sleep/__init__.py
new file mode 100644
index 0000000..61f7d91
--- /dev/null
+++ b/kiosk/addons/sleep/__init__.py
@@ -0,0 +1,31 @@
+ADDON = {
+    "name": "sleep",
+    "label": "Sovläge",
+    "version": "1.0",
+    "order": 6,
+    "has_overlay": False,
+    "has_panel": True,
+    "has_socketio": False,
+    "state_prefix": "sleep_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
+
+
+def is_sleep_active(start, end):
+    """Returnerar True om nuvarande tid faller inom sovfönstret."""
+    try:
+        import datetime
+        now = datetime.datetime.now()
+        cur = now.hour * 60 + now.minute
+        sh, sm = map(int, start.split(':'))
+        eh, em = map(int, end.split(':'))
+        s, e = sh * 60 + sm, eh * 60 + em
+        # Hanterar fönster som sträcker sig över midnatt (t.ex. 22:00–07:00)
+        return (cur >= s or cur < e) if s > e else (s <= cur < e)
+    except Exception:
+        return False
diff --git a/kiosk/addons/sleep/panel.html b/kiosk/addons/sleep/panel.html
new file mode 100644
index 0000000..7b7aac1
--- /dev/null
+++ b/kiosk/addons/sleep/panel.html
@@ -0,0 +1,20 @@
+<section>
+  <h2>Sovläge</h2>
+  <div class="prop-grid">
+    <label>Aktivera</label>
+    <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+      <input type="checkbox" id="sleep-enabled">
+      Täck displayen med svart skärm under angiven tid
+    </label>
+
+    <label>Sov från</label>
+    <input type="time" id="sleep-start" value="22:00" style="max-width:9rem">
+
+    <label>Vakna kl</label>
+    <input type="time" id="sleep-end" value="07:00" style="max-width:9rem">
+  </div>
+  <div class="row" style="margin-top:.75rem">
+    <button onclick="sparaSovlage()">Spara</button>
+    <span id="sleep-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+  </div>
+</section>
diff --git a/kiosk/addons/sleep/routes.py b/kiosk/addons/sleep/routes.py
new file mode 100644
index 0000000..9eae5f1
--- /dev/null
+++ b/kiosk/addons/sleep/routes.py
@@ -0,0 +1,40 @@
+import re
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+import scheduler as sched
+from auth import login_required
+from . import is_sleep_active
+
+bp = Blueprint('sleep', __name__, url_prefix='/api/sleep')
+
+
+@bp.route('', methods=['GET'])
+@login_required
+def get_sleep():
+    state = db.get_state()
+    return jsonify({
+        'enabled': state.get('sleep_enabled', '0') == '1',
+        'start':   state.get('sleep_start', '22:00'),
+        'end':     state.get('sleep_end',   '07:00'),
+    })
+
+
+@bp.route('', methods=['POST'])
+@login_required
+def set_sleep():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    enabled = bool(data.get('enabled', False))
+    start   = str(data.get('start', '22:00'))
+    end     = str(data.get('end',   '07:00'))
+    if not re.match(r'^\d{2}:\d{2}$', start): start = '22:00'
+    if not re.match(r'^\d{2}:\d{2}$', end):   end   = '07:00'
+    db.set_state('sleep_enabled', '1' if enabled else '0')
+    db.set_state('sleep_start',   start)
+    db.set_state('sleep_end',     end)
+    sched.reload_sleep_jobs()
+    active = enabled and is_sleep_active(start, end)
+    extensions.socketio.emit('sleep_toggle', {'active': active})
+    return '', 204
diff --git a/kiosk/addons/system/__init__.py b/kiosk/addons/system/__init__.py
new file mode 100644
index 0000000..d740e18
--- /dev/null
+++ b/kiosk/addons/system/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+    "name": "system",
+    "label": "System",
+    "version": "1.0",
+    "order": 11,
+    "has_overlay": False,
+    "has_panel": True,
+    "has_socketio": False,
+    "state_prefix": "system_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
diff --git a/kiosk/addons/system/routes.py b/kiosk/addons/system/routes.py
new file mode 100644
index 0000000..0cd0e74
--- /dev/null
+++ b/kiosk/addons/system/routes.py
@@ -0,0 +1,33 @@
+import subprocess
+from flask import Blueprint, jsonify
+from auth import login_required
+from utils.display import sway_env
+
+bp = Blueprint('system', __name__, url_prefix='/api')
+
+
+@bp.route('/reboot', methods=['POST'])
+@login_required
+def reboot():
+    subprocess.Popen(['sudo', 'reboot'])
+    return '', 204
+
+
+@bp.route('/shutdown', methods=['POST'])
+@login_required
+def shutdown():
+    subprocess.Popen(['sudo', 'poweroff'])
+    return '', 204
+
+
+@bp.route('/restart', methods=['POST'])
+@login_required
+def restart():
+    # Avsluta sway — getty loopar och startar om via .bash_profile → kiosk-launcher.sh
+    try:
+        env = sway_env()
+        if env:
+            subprocess.Popen(['swaymsg', 'exit'], env=env)
+    except Exception:
+        pass
+    return '', 204
diff --git a/kiosk/addons/ticker/__init__.py b/kiosk/addons/ticker/__init__.py
new file mode 100644
index 0000000..d6782c9
--- /dev/null
+++ b/kiosk/addons/ticker/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+    "name": "ticker",
+    "label": "Löptext",
+    "version": "1.0",
+    "order": 2,
+    "has_overlay": False,
+    "has_panel": True,
+    "has_socketio": False,
+    "state_prefix": "ticker_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
diff --git a/kiosk/addons/ticker/routes.py b/kiosk/addons/ticker/routes.py
new file mode 100644
index 0000000..8b222f9
--- /dev/null
+++ b/kiosk/addons/ticker/routes.py
@@ -0,0 +1,127 @@
+import re
+import urllib.request as _urlreq
+import xml.etree.ElementTree as _ET
+from datetime import datetime as _dt, timedelta as _td
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+from auth import login_required
+
+bp = Blueprint('ticker', __name__, url_prefix='/api')
+
+_HEX_COLOR = re.compile(r'^#[0-9a-fA-F]{6}$')
+_rss_cache = {'url': None, 'items': [], 'fetched_at': None}
+
+
+def _fetch_rss(url, max_items=30):
+    global _rss_cache
+    now = _dt.now()
+    if (_rss_cache['url'] == url and _rss_cache['fetched_at']
+            and now - _rss_cache['fetched_at'] < _td(minutes=2)):
+        return _rss_cache['items']
+    try:
+        req = _urlreq.Request(url, headers={'User-Agent': 'KioskRSS/1.0'})
+        with _urlreq.urlopen(req, timeout=10) as r:
+            raw = r.read()
+        root = _ET.fromstring(raw)
+        items = []
+        # RSS 2.0
+        for item in root.findall('.//item'):
+            t = (item.findtext('title') or '').strip()
+            if t:
+                items.append(t)
+        # Atom
+        if not items:
+            ns = {'a': 'http://www.w3.org/2005/Atom'}
+            for entry in root.findall('.//a:entry', ns):
+                t = (entry.findtext('a:title', namespaces=ns) or '').strip()
+                if t:
+                    items.append(t)
+        _rss_cache = {'url': url, 'items': items[:max_items], 'fetched_at': now}
+    except Exception:
+        pass  # keep stale cache on error
+    return _rss_cache['items']
+
+
+@bp.route('/ticker/rss')
+def get_ticker_rss():
+    state = db.get_state()
+    url   = state.get('ticker_rss_url', '').strip()
+    if not url:
+        return jsonify({'items': [], 'error': 'Ingen RSS-URL konfigurerad'})
+    items = _fetch_rss(url)
+    return jsonify({'items': items})
+
+
+@bp.route('/ticker', methods=['GET'])
+def get_ticker():
+    state = db.get_state()
+    return jsonify({
+        'enabled':      state.get('ticker_enabled', '0') == '1',
+        'source':       state.get('ticker_source', 'text'),
+        'text':         state.get('ticker_text', ''),
+        'rss_url':      state.get('ticker_rss_url', ''),
+        'rss_interval': int(state.get('ticker_rss_interval', '10')),
+        'pps':          int(state.get('ticker_pps', '150')),
+        'mode':         state.get('ticker_mode', 'sequential'),
+        'separator':    state.get('ticker_separator', '◆'),
+        'position':     state.get('ticker_position', 'bottom'),
+        'text_color':   state.get('ticker_text_color', '#ffffff'),
+        'font_size':    float(state.get('ticker_font_size', '2.0')),
+        'font_family':  state.get('ticker_font_family', 'sans-serif'),
+        'bar_color':    state.get('ticker_bar_color', '#000000'),
+        'bar_opacity':  int(state.get('ticker_bar_opacity', '72')),
+        'bar_padding':  float(state.get('ticker_bar_padding', '0.45')),
+    })
+
+
+@bp.route('/ticker', methods=['POST'])
+@login_required
+def set_ticker():
+    data = request.get_json()
+    if not data:
+        abort(400)
+    enabled      = bool(data.get('enabled', False))
+    source       = 'rss' if data.get('source') == 'rss' else 'text'
+    text         = str(data.get('text', ''))
+    rss_url      = str(data.get('rss_url', ''))
+    rss_interval = max(1, min(1440, int(data.get('rss_interval', 10))))
+    pps          = max(30, min(800, int(data.get('pps', 150))))
+    mode         = 'continuous' if data.get('mode') == 'continuous' else 'sequential'
+    separator    = str(data.get('separator', '◆'))[:20]
+    position     = 'top' if data.get('position') == 'top' else 'bottom'
+    text_color   = str(data.get('text_color', '#ffffff'))
+    font_size    = max(0.5, min(6.0, float(data.get('font_size', 2.0))))
+    font_family  = str(data.get('font_family', 'sans-serif'))
+    bar_color    = str(data.get('bar_color', '#000000'))
+    bar_opacity  = max(0, min(100, int(data.get('bar_opacity', 72))))
+    bar_padding  = max(0.1, min(2.0, float(data.get('bar_padding', 0.45))))
+    if not _HEX_COLOR.match(text_color): text_color = '#ffffff'
+    if not _HEX_COLOR.match(bar_color):  bar_color  = '#000000'
+    db.set_state('ticker_enabled',      '1' if enabled else '0')
+    db.set_state('ticker_source',       source)
+    db.set_state('ticker_text',         text)
+    db.set_state('ticker_rss_url',      rss_url)
+    db.set_state('ticker_rss_interval', str(rss_interval))
+    db.set_state('ticker_pps',          str(pps))
+    db.set_state('ticker_mode',         mode)
+    db.set_state('ticker_separator',    separator)
+    db.set_state('ticker_position',     position)
+    db.set_state('ticker_text_color',   text_color)
+    db.set_state('ticker_font_size',    str(font_size))
+    db.set_state('ticker_font_family',  font_family)
+    db.set_state('ticker_bar_color',    bar_color)
+    db.set_state('ticker_bar_opacity',  str(bar_opacity))
+    db.set_state('ticker_bar_padding',  str(bar_padding))
+    # Invalidate RSS cache when URL changes
+    if source == 'rss':
+        _rss_cache['fetched_at'] = None
+    payload = {
+        'enabled': enabled, 'source': source,
+        'text': text, 'rss_url': rss_url, 'rss_interval': rss_interval,
+        'pps': pps, 'mode': mode, 'separator': separator, 'position': position,
+        'text_color': text_color, 'font_size': font_size, 'font_family': font_family,
+        'bar_color': bar_color, 'bar_opacity': bar_opacity, 'bar_padding': bar_padding,
+    }
+    extensions.socketio.emit('ticker', payload)
+    return '', 204
diff --git a/kiosk/addons/wifi/__init__.py b/kiosk/addons/wifi/__init__.py
new file mode 100644
index 0000000..be5decd
--- /dev/null
+++ b/kiosk/addons/wifi/__init__.py
@@ -0,0 +1,22 @@
+ADDON = {
+    "name": "wifi",
+    "label": "Wi-Fi",
+    "version": "1.0",
+    "order": 9,
+    "has_overlay": False,
+    "has_panel": True,
+    "has_socketio": False,
+    "state_prefix": "wifi_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
+
+
+def wifi_status():
+    """Publikt API för att hämta WiFi-status (används av app.py startup)."""
+    from .routes import _wifi_status
+    return _wifi_status()
diff --git a/kiosk/addons/wifi/routes.py b/kiosk/addons/wifi/routes.py
new file mode 100644
index 0000000..4339cba
--- /dev/null
+++ b/kiosk/addons/wifi/routes.py
@@ -0,0 +1,195 @@
+import re
+import subprocess
+import time
+from flask import Blueprint, jsonify, request, abort
+from auth import login_required
+
+bp = Blueprint('wifi', __name__, url_prefix='/api/wifi')
+
+
+def _wifi_iface():
+    try:
+        with open('/proc/net/wireless') as f:
+            for line in f:
+                if ':' in line:
+                    iface = line.split(':')[0].strip()
+                    if iface:
+                        return iface
+    except Exception:
+        pass
+    return 'wlo1'
+
+
+_WIFI_IFACE = _wifi_iface()
+
+
+def _wpa(*args):
+    cmd = ['sudo', '-n', '/usr/sbin/wpa_cli', '-i', _WIFI_IFACE] + list(args)
+    try:
+        r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
+        return r.returncode, r.stdout.strip()
+    except Exception as e:
+        return 1, str(e)
+
+
+def _wifi_status():
+    rc, out = _wpa('status')
+    d = {}
+    if rc == 0:
+        for line in out.splitlines():
+            if '=' in line:
+                k, v = line.split('=', 1)
+                d[k.strip()] = v.strip()
+    connected = d.get('wpa_state') == 'COMPLETED'
+    ip = ''
+    try:
+        r = subprocess.run(['ip', '-4', 'addr', 'show', _WIFI_IFACE],
+                           capture_output=True, text=True, timeout=5)
+        m = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', r.stdout)
+        if m:
+            ip = m.group(1)
+    except Exception:
+        pass
+    return {
+        'connected': connected,
+        'ssid': d.get('ssid', ''),
+        'ip': ip,
+        'signal': int(d.get('signal_level', 0) or 0),
+        'iface': _WIFI_IFACE,
+        'sudo_ok': rc != 1 or 'sudo' not in out,
+    }
+
+
+def _wifi_scan():
+    _wpa('scan')
+    time.sleep(2.5)
+    rc, out = _wpa('scan_results')
+    networks = []
+    seen = set()
+    for line in out.splitlines():
+        if not line or line.startswith('bssid'):
+            continue
+        parts = line.split('\t')
+        if len(parts) < 5:
+            continue
+        ssid = parts[4].strip()
+        if not ssid or ssid in seen:
+            continue
+        seen.add(ssid)
+        try:
+            dbm = int(parts[2])
+        except (ValueError, IndexError):
+            dbm = -90
+        flags = parts[3] if len(parts) > 3 else ''
+        if 'WPA2' in flags:
+            sec = 'WPA2'
+        elif 'WPA' in flags:
+            sec = 'WPA'
+        elif 'WEP' in flags:
+            sec = 'WEP'
+        else:
+            sec = 'open'
+        networks.append({
+            'ssid': ssid,
+            'signal': max(0, min(100, 2 * (dbm + 100))),
+            'security': sec,
+        })
+    networks.sort(key=lambda x: -x['signal'])
+    return networks
+
+
+def _wifi_saved():
+    rc, out = _wpa('list_networks')
+    saved = []
+    for line in out.splitlines():
+        parts = line.split('\t')
+        if len(parts) >= 2 and parts[0].strip().isdigit():
+            ssid = parts[1].strip()
+            if ssid:
+                flags = parts[3].strip() if len(parts) > 3 else ''
+                saved.append({
+                    'id': parts[0].strip(),
+                    'ssid': ssid,
+                    'current': '[CURRENT]' in flags,
+                })
+    return saved
+
+
+def _wifi_connect(ssid, password):
+    for net in _wifi_saved():
+        if net['ssid'] == ssid:
+            _wpa('remove_network', net['id'])
+    rc, net_id = _wpa('add_network')
+    net_id = net_id.strip()
+    if not net_id.isdigit():
+        return False, 'Kunde inte lägga till nätverk'
+    _wpa('set_network', net_id, 'ssid', f'"{ssid}"')
+    if password:
+        _wpa('set_network', net_id, 'psk', f'"{password}"')
+    else:
+        _wpa('set_network', net_id, 'key_mgmt', 'NONE')
+    _wpa('select_network', net_id)
+    for _ in range(20):
+        time.sleep(1)
+        s = _wifi_status()
+        if s['connected'] and s['ssid'] == ssid:
+            _wpa('save_config')
+            return True, s.get('ip', '')
+    _wpa('remove_network', net_id)
+    return False, 'Anslutning misslyckades (fel lösenord?)'
+
+
+@bp.route('/status')
+@login_required
+def api_wifi_status():
+    return jsonify(_wifi_status())
+
+
+@bp.route('/networks')
+@login_required
+def api_wifi_networks():
+    try:
+        nets = _wifi_scan()
+        known = {n['ssid'] for n in _wifi_saved()}
+        for n in nets:
+            n['known'] = n['ssid'] in known
+        return jsonify({'networks': nets, 'iface': _WIFI_IFACE})
+    except Exception as e:
+        return jsonify({'networks': [], 'error': str(e)})
+
+
+@bp.route('/connect', methods=['POST'])
+@login_required
+def api_wifi_connect():
+    data = request.get_json()
+    if not data or not data.get('ssid'):
+        abort(400)
+    ssid     = str(data['ssid'])[:64]
+    password = str(data.get('password', ''))[:64]
+    if '"' in ssid or '"' in password:
+        return jsonify({'ok': False, 'message': 'Ogiltiga tecken i SSID/lösenord'}), 400
+    ok, msg = _wifi_connect(ssid, password)
+    return jsonify({'ok': ok, 'message': msg if not ok else '', 'ip': msg if ok else ''})
+
+
+@bp.route('/disconnect', methods=['POST'])
+@login_required
+def api_wifi_disconnect():
+    _wpa('disconnect')
+    return '', 204
+
+
+@bp.route('/saved')
+@login_required
+def api_wifi_saved():
+    return jsonify({'saved': _wifi_saved()})
+
+
+@bp.route('/saved/<net_id>', methods=['DELETE'])
+@login_required
+def api_wifi_remove(net_id):
+    if not net_id.isdigit():
+        abort(400)
+    _wpa('remove_network', net_id)
+    _wpa('save_config')
+    return '', 204
diff --git a/kiosk/app.py b/kiosk/app.py
index 66e894b..ef547a8 100644
--- a/kiosk/app.py
+++ b/kiosk/app.py
@@ -2,71 +2,24 @@ import eventlet
 eventlet.monkey_patch()

 import os
-import json as _json
-import uuid as _uuid
-import signal
-import socket as _socket
 import psutil
 import subprocess
-import re
-import shutil
 from pathlib import Path
-from werkzeug.utils import secure_filename
-from werkzeug.security import generate_password_hash, check_password_hash
-from flask import Flask, render_template, jsonify, request, abort, session, redirect, url_for, send_file
-from flask_socketio import SocketIO, emit, Namespace
+from flask import Flask, render_template, jsonify, request, abort, session, redirect, url_for
+from flask_socketio import SocketIO, emit
 import db
 import scheduler as sched
-
-# --- Chat user store ---
-CHAT_USERS_FILE = Path(__file__).parent / 'chat_users.json'
-
-def _load_chat_users():
-    try:
-        if CHAT_USERS_FILE.exists():
-            return _json.loads(CHAT_USERS_FILE.read_text())
-    except Exception:
-        pass
-    return {}
-
-def _save_chat_users(users):
-    CHAT_USERS_FILE.write_text(_json.dumps(users, indent=2))
-
-_chat_tokens = {}  # token -> {username, emoji, color, avatar_url}
-
-def _restore_chat_tokens():
-    """Ladda sparade tokens från chat_users.json vid uppstart."""
-    users = _load_chat_users()
-    for username, u in users.items():
-        token = u.get('token')
-        if token:
-            _chat_tokens[token] = {
-                'username':   username,
-                'emoji':      u.get('emoji', '😀'),
-                'color':      u.get('color', '#94a3b8'),
-                'avatar_url': None,  # fylls i av validate-endpoint efter att _get_avatar_url är definierad
-            }
-
-_CHAT_COLORS = [
-    '#f87171','#fb923c','#fbbf24','#a3e635',
-    '#34d399','#38bdf8','#818cf8','#e879f9',
-    '#f472b6','#94a3b8',
-]
-
-CHAT_AVATARS_DIR    = Path(__file__).parent / 'static' / 'chat_avatars'
-CHAT_AVATARS_DIR.mkdir(exist_ok=True)
-_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.webp']
-
-def _get_avatar_url(username):
-    safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
-    for ext in _AVATAR_EXTS:
-        if (CHAT_AVATARS_DIR / (safe + ext)).exists():
-            return f'/api/chat/avatar/{username}'
-    return None
-
-
-# Återställ tokens nu när _get_avatar_url är definierad
-_restore_chat_tokens()
+from auth import login_required
+from addons.sleep import is_sleep_active as _is_sleep_active
+from addons.wifi import wifi_status as _wifi_status
+from addons.settings.routes import _apply_internal_display
+from addon_loader import discover_and_load, get_loaded_addons
+from utils.system import local_ip as _local_ip
+from utils.display import (
+    sway_env as _sway_env,
+    apply_saved_audio as _apply_saved_audio,
+    apply_display_mode as _apply_display_mode,
+)


 # Load kiosk.conf (if present) into DB state so settings survive app restarts
@@ -101,33 +54,16 @@ app = Flask(__name__)
 app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24))
 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
+db.init_db()
+discover_and_load(app, socketio)
+
 KIOSK_USER = os.environ.get('KIOSK_USER', 'admin')
 KIOSK_PASS = os.environ.get('KIOSK_PASS', 'changeme')

-ALLOWED_ROOTS = [Path('/home/mrfox'), Path('/media'), Path('/mnt')]
-MEDIA_EXTENSIONS = {
-    '.mp4', '.webm', '.avi', '.mkv', '.mov',
-    '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp',
-    '.html', '.htm',
-    '.pdf', '.ppt', '.pptx', '.odp',
-}
-UPLOAD_DIR = Path('/home/mrfox/codes/display/kiosk/uploads')
-
-
-def login_required(f):
-    from functools import wraps
-    @wraps(f)
-    def decorated(*args, **kwargs):
-        if not session.get('logged_in'):
-            # For API/XHR requests, return 401 JSON instead of redirecting to login
-            is_xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
-            wants_json = request.is_json or 'application/json' in request.headers.get('Accept', '')
-            if is_xhr or wants_json or request.path.startswith('/api/'):
-                return jsonify({'error': 'login_required'}), 401
-            return redirect(url_for('login', next=request.path))
-        return f(*args, **kwargs)
-    return decorated
-

 # --- Login ---

@@ -150,20 +86,10 @@ def logout():

 # --- Display page (no auth) ---

-def _local_ip():
-    try:
-        s = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
-        s.connect(('8.8.8.8', 80))
-        ip = s.getsockname()[0]
-        s.close()
-        return ip
-    except Exception:
-        return '127.0.0.1'
-
-
 @app.route('/display')
 def display():
-    return render_template('display.html', local_ip=_local_ip())
+    overlays = [a['name'] for a in get_loaded_addons() if a.get('has_overlay')]
+    return render_template('display.html', local_ip=_local_ip(), active_overlays=overlays)


 # --- Admin page ---
@@ -171,7 +97,8 @@ def display():
 @app.route('/admin')
 @login_required
 def admin():
-    return render_template('admin.html')
+    panels = [a for a in get_loaded_addons() if a.get('has_panel')]
+    return render_template('admin.html', addon_panels=panels)


 # --- API: sysinfo ---
@@ -256,250 +183,12 @@ def reorder_jobs():
     return '', 204


-# --- Kiosk Livechat routes ---
-
-@app.route('/chat-display')
-def chat_display():
-    return render_template('chat_display.html', local_ip=_local_ip())
-
-
-@app.route('/chat-mobile')
-def chat_mobile():
-    return render_template('chat_mobile.html')
-
-
-@app.route('/api/chat-qr')
-def chat_qr():
-    try:
-        import qrcode as _qr
-        import io as _io
-        url = f'http://{_local_ip()}:5000/chat-mobile'
-        qr = _qr.QRCode(box_size=20, border=2)
-        qr.add_data(url)
-        qr.make(fit=True)
-        img = qr.make_image()
-        buf = _io.BytesIO()
-        img.save(buf, format='PNG')
-        buf.seek(0)
-        return send_file(buf, mimetype='image/png', max_age=30)
-    except ImportError:
-        abort(501)
-
-
-@app.route('/api/chat/register', methods=['POST'])
-def chat_register():
-    data     = request.get_json(silent=True) or {}
-    username = str(data.get('username', '')).strip()[:32]
-    password = str(data.get('password', '')).strip()
-    emoji    = str(data.get('emoji', '😀')).strip()[:4]
-    if not username or not password:
-        return jsonify({'error': 'Användarnamn och lösenord krävs'}), 400
-    users = _load_chat_users()
-    if username.lower() in {k.lower() for k in users}:
-        return jsonify({'error': 'Användarnamnet är redan taget'}), 409
-    color = _CHAT_COLORS[len(users) % len(_CHAT_COLORS)]
-    token = str(_uuid.uuid4())
-    users[username] = {
-        'password_hash': generate_password_hash(password),
-        'emoji': emoji,
-        'color': color,
-        'token': token,
-    }
-    _save_chat_users(users)
-    _chat_tokens[token] = {'username': username, 'emoji': emoji, 'color': color, 'avatar_url': None}
-    return jsonify({'token': token, 'username': username, 'emoji': emoji, 'color': color, 'avatar_url': None})
-
-
-@app.route('/api/chat/login', methods=['POST'])
-def chat_login():
-    data     = request.get_json(silent=True) or {}
-    username = str(data.get('username', '')).strip()
-    password = str(data.get('password', '')).strip()
-    if not username or not password:
-        return jsonify({'error': 'Fyll i användarnamn och lösenord'}), 400
-    users = _load_chat_users()
-    key   = next((k for k in users if k.lower() == username.lower()), None)
-    if not key or not check_password_hash(users[key]['password_hash'], password):
-        return jsonify({'error': 'Fel användarnamn eller lösenord'}), 401
-    u          = users[key]
-    avatar_url = _get_avatar_url(key)
-    token = str(_uuid.uuid4())
-    _chat_tokens[token] = {'username': key, 'emoji': u['emoji'], 'color': u['color'], 'avatar_url': avatar_url}
-    users[key]['token'] = token
-    _save_chat_users(users)
-    return jsonify({'token': token, 'username': key, 'emoji': u['emoji'], 'color': u['color'], 'avatar_url': avatar_url})
-
-
-@app.route('/api/chat/validate', methods=['POST'])
-def chat_validate():
-    data  = request.get_json(silent=True) or {}
-    token = str(data.get('token', ''))
-    user  = _chat_tokens.get(token)
-    if not user:
-        return jsonify({'error': 'Ogiltig token'}), 401
-    if not user.get('avatar_url'):
-        user['avatar_url'] = _get_avatar_url(user['username'])
-    return jsonify({'username': user['username'], 'emoji': user['emoji'], 'color': user['color'], 'avatar_url': user.get('avatar_url')})
-
-
-@app.route('/api/chat/avatar/<username>')
-def serve_chat_avatar(username):
-    safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
-    for ext in _AVATAR_EXTS:
-        p = CHAT_AVATARS_DIR / (safe + ext)
-        if p.exists():
-            return send_file(p)
-    abort(404)
-
-
-CHAT_IMAGES_DIR = Path(__file__).parent / 'static' / 'chat_images'
-CHAT_IMAGES_DIR.mkdir(exist_ok=True)
-_CHAT_IMG_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
-
-
-@app.route('/api/chat/image', methods=['POST'])
-def upload_chat_image():
-    token     = request.form.get('token', '')
-    user_info = _chat_tokens.get(token)
-    if not user_info:
-        return jsonify({'error': 'Ogiltig token'}), 401
-    f = request.files.get('image')
-    if not f or not f.filename:
-        return jsonify({'error': 'Ingen fil'}), 400
-    ext = Path(secure_filename(f.filename)).suffix.lower()
-    if ext not in _CHAT_IMG_EXTS:
-        return jsonify({'error': 'Använd jpg, png, gif eller webp'}), 400
-    filename = _uuid.uuid4().hex + ext
-    f.save(str(CHAT_IMAGES_DIR / filename))
-    return jsonify({'url': f'/static/chat_images/{filename}'})
-
-
-@app.route('/api/chat/avatar', methods=['POST'])
-def upload_chat_avatar():
-    token     = request.form.get('token', '')
-    user_info = _chat_tokens.get(token)
-    if not user_info:
-        return jsonify({'error': 'Ogiltig token'}), 401
-    f = request.files.get('avatar')
-    if not f or not f.filename:
-        return jsonify({'error': 'Ingen fil'}), 400
-    ext = Path(secure_filename(f.filename)).suffix.lower()
-    if ext not in _AVATAR_EXTS:
-        return jsonify({'error': 'Använd jpg, png eller webp'}), 400
-    username = user_info['username']
-    safe     = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
-    for old_ext in _AVATAR_EXTS:
-        old = CHAT_AVATARS_DIR / (safe + old_ext)
-        if old.exists():
-            old.unlink()
-    dest = CHAT_AVATARS_DIR / (safe + ext)
-    f.save(str(dest))
-    avatar_url = f'/api/chat/avatar/{username}'
-    user_info['avatar_url'] = avatar_url
-    users = _load_chat_users()
-    if username in users:
-        users[username]['avatar_url'] = avatar_url
-        _save_chat_users(users)
-    return jsonify({'avatar_url': avatar_url})
-
-
-# --- Kiosk Livechat SocketIO namespace ---
-
-class ChatNamespace(Namespace):
-    _users    = {}   # sid -> {name, color, emoji}
-    _messages = []   # recent message history (last 50)
-
-    def on_connect(self):
-        sid = request.sid
-        if self._messages:
-            socketio.emit('chat_history', self._messages, to=sid, namespace='/chat')
-        socketio.emit('viewer_count', {'count': len(self._users)}, to=sid, namespace='/chat')
-
-    def on_join(self, data):
-        token = str(data.get('token', '')).strip()
-        if not token:
-            return
-        user_info = _chat_tokens.get(token)
-        if not user_info:
-            self.emit('auth_error', {'error': 'Ogiltig token, logga in igen'})
-            return
-        if request.sid in self._users:
-            return
-        username   = user_info['username']
-        emoji      = user_info['emoji']
-        color      = user_info['color']
-        avatar_url = user_info.get('avatar_url')
-        self._users[request.sid] = {'name': username, 'color': color, 'emoji': emoji, 'avatar_url': avatar_url}
-        # Re-send history to authenticated client so chat-mobile gets it after login
-        if self._messages:
-            socketio.emit('chat_history', self._messages, to=request.sid, namespace='/chat')
-        socketio.emit('viewer_count', {'count': len(self._users)}, namespace='/chat')
-
-    def on_chat_message(self, data):
-        user = self._users.get(request.sid)
-        if not user:
-            return
-        text = str(data.get('text', '')).strip()[:300]
-        if not text:
-            return
-        msg = {
-            'name':       user['name'],
-            'color':      user['color'],
-            'emoji':      user.get('emoji'),
-            'avatar_url': user.get('avatar_url'),
-            'text':       text,
-        }
-        self._messages.append(msg)
-        if len(self._messages) > 50:
-            self._messages.pop(0)
-        socketio.emit('chat_message', msg, namespace='/chat')
-
-    def on_play_suggestion(self, data):
-        user = self._users.get(request.sid)
-        if not user:
-            return
-        url = str(data.get('url', '')).strip()[:500]
-        if not url or not url.startswith(('http://', 'https://')):
-            return
-        msg = {
-            'name':       user['name'],
-            'color':      user['color'],
-            'emoji':      user.get('emoji'),
-            'avatar_url': user.get('avatar_url'),
-            'text':       url,
-            'kind':       'play_suggestion',
-        }
-        self._messages.append(msg)
-        if len(self._messages) > 50:
-            self._messages.pop(0)
-        # Skicka till alla i /chat-namespacet (display + mobil)
-        socketio.emit('play_suggestion', msg, namespace='/chat')
-        # Notifiera admin-panelen via huvud-namespacet
-        socketio.emit('play_suggestion', {
-            'name': user['name'], 'color': user['color'], 'url': url,
-        })
-
-    def on_disconnect(self):
-        user = self._users.pop(request.sid, None)
-        if user:
-            socketio.emit('viewer_count', {'count': len(self._users)}, namespace='/chat')
-
-
-socketio.on_namespace(ChatNamespace('/chat'))
+# --- Kiosk Livechat --- (migrerat till addons/livechat/)


 # --- WebSocket ---

-def _is_sleep_active(start, end):
-    try:
-        now = __import__('datetime').datetime.now()
-        cur = now.hour * 60 + now.minute
-        sh, sm = map(int, start.split(':'))
-        eh, em = map(int, end.split(':'))
-        s, e = sh * 60 + sm, eh * 60 + em
-        return (cur >= s or cur < e) if s > e else (s <= cur < e)
-    except Exception:
-        return False
+# _is_sleep_active importeras från addons.sleep


 @socketio.on('connect')
@@ -537,1400 +226,26 @@ def handle_switch(data):
     socketio.emit('switch', {'type': type_, 'source': source, 'scheduled': False})


-# --- File browser ---
-
-@app.route('/files')
-def serve_file():
-    path = request.args.get('path', '')
-    if not path:
-        abort(400)
-    p = Path(path).resolve()
-    if not any(p.is_relative_to(r) for r in ALLOWED_ROOTS):
-        abort(403)
-    if not p.is_file():
-        abort(404)
-    return send_file(p)
-
-
-@app.route('/api/upload', methods=['POST'])
-@login_required
-def upload():
-    if 'file' not in request.files:
-        abort(400)
-    f = request.files['file']
-    if not f.filename:
-        abort(400)
-    name = secure_filename(f.filename)
-    ext = Path(name).suffix.lower()
-    if ext not in MEDIA_EXTENSIONS:
-        abort(415)
-    UPLOAD_DIR.mkdir(exist_ok=True)
-    dest = UPLOAD_DIR / name
-    if dest.exists():
-        stem = Path(name).stem
-        i = 1
-        while dest.exists():
-            dest = UPLOAD_DIR / f'{stem}_{i}{ext}'
-            i += 1
-    f.save(dest)
-
-    # Konvertera presentationer till PDF med LibreOffice om tillgängligt
-    if ext in {'.ppt', '.pptx', '.odp'} and shutil.which('libreoffice'):
-        try:
-            subprocess.run(
-                ['libreoffice', '--headless', '--convert-to', 'pdf', str(dest), '--outdir', str(UPLOAD_DIR)],
-                timeout=120, check=True,
-                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
-            )
-            pdf_path = UPLOAD_DIR / (dest.stem + '.pdf')
-            if pdf_path.exists():
-                dest.unlink()
-                dest = pdf_path
-        except Exception:
-            pass
-
-    file_url = '/files?path=' + str(dest)
-    return jsonify({'path': str(dest), 'url': file_url})
-
-
-@app.route('/api/browse')
-@login_required
-def browse():
-    req_path = request.args.get('path', str(Path('/home/mrfox')))
-    p = Path(req_path).resolve()
-    if not any(p.is_relative_to(r) for r in ALLOWED_ROOTS):
-        abort(403)
-    if not p.is_dir():
-        abort(404)
-    entries = []
-    try:
-        for entry in sorted(p.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
-            if entry.name.startswith('.'):
-                continue
-            if entry.is_dir():
-                entries.append({'name': entry.name, 'path': str(entry), 'type': 'dir'})
-            elif entry.suffix.lower() in MEDIA_EXTENSIONS:
-                entries.append({'name': entry.name, 'path': str(entry), 'type': 'file', 'ext': entry.suffix.lower()})
-    except PermissionError:
-        pass
-    parent = str(p.parent)
-    if not any(Path(parent).is_relative_to(r) for r in ALLOWED_ROOTS):
-        parent = None
-    return jsonify({'path': str(p), 'parent': parent, 'entries': entries})
-
-
-# --- Settings ---
-
-def _audio_device_id():
-    """Return the PipeWire interface-id of the first Audio/Device."""
-    import json as _json
-    try:
-        out = subprocess.check_output(
-            ['pw-dump'], timeout=5,
-            env={**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
-        ).decode()
-        for node in _json.loads(out):
-            if node.get('type') != 'PipeWire:Interface:Device':
-                continue
-            if node.get('info', {}).get('props', {}).get('media.class') == 'Audio/Device':
-                return node.get('id')
-    except Exception:
-        pass
-    return None
-
-
-def _parse_profiles(text):
-    """Parse pw-cli enum-params EnumProfile output into a list of dicts."""
-    profiles, cur, last_key = [], {}, None
-    for line in text.splitlines():
-        s = line.strip()
-        if s.startswith('Object:') and 'Profile' in s:
-            if 'index' in cur:
-                profiles.append(cur)
-            cur, last_key = {}, None
-        elif 'Profile:index'       in s: last_key = 'index'
-        elif 'Profile:description' in s: last_key = 'description'
-        elif 'Profile:name'        in s and 'description' not in s: last_key = 'name'
-        elif 'Profile:available'   in s: last_key = 'available'
-        elif last_key == 'index' and s.startswith('Int '):
-            cur['index'] = int(s.split()[1]); last_key = None
-        elif last_key in ('name', 'description') and s.startswith('String '):
-            m = re.match(r'String "(.+)"', s)
-            if m: cur[last_key] = m.group(1)
-            last_key = None
-        elif last_key == 'available' and 'Availability' in s:
-            cur['available'] = 'yes' in s; last_key = None
-    if 'index' in cur:
-        profiles.append(cur)
-    skip = {'off', 'pro-audio'}
-    # Include all non-trivial profiles regardless of availability so user can always switch
-    return [p for p in profiles
-            if p.get('name') not in skip
-            and not p.get('name', '').startswith('input:')]
-
-
-def _apply_saved_audio():
-    """Apply the saved audio profile from DB state. Best-effort, non-fatal."""
-    try:
-        state     = db.get_state()
-        idx       = int(state.get('audio_profile_index', 1))
-        dev_id    = _audio_device_id()
-        pw_env    = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
-        if dev_id is not None:
-            subprocess.run(
-                ['pw-cli', 's', str(dev_id), 'Profile', f'{{ index: {idx} }}'],
-                timeout=5, env=pw_env
-            )
-    except Exception:
-        pass
-
-
-def _apply_display_mode(mode_str, target_output=None):
-    """Best-effort apply display resolution given a string like '1920x1080'.
-    Tries xrandr, swaymsg, or wlr-randr when available. Non-fatal on failure."""
-    m = re.search(r"(\d+)x(\d+)", mode_str)
-    if not m:
-        return False
-    width, height = m.group(1), m.group(2)
-
-    # find first connected output from /sys/class/drm unless a target_output is given
-    out_name = None
-    if target_output:
-        out_name = target_output
-    else:
-        try:
-            for conn in sorted(Path('/sys/class/drm').iterdir()):
-                status_file = conn / 'status'
-                if not status_file.exists():
-                    continue
-                if status_file.read_text().strip() == 'connected':
-                    parts = conn.name.split('-', 1)
-                    if len(parts) == 2:
-                        out_name = parts[1]
-                        break
-        except Exception:
-            out_name = None
-
-    # Auto-detect WAYLAND_DISPLAY if not set (e.g. when running as a systemd service)
-    env = os.environ.copy()
-    if not env.get('WAYLAND_DISPLAY'):
-        xdg_runtime = env.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
-        try:
-            for sock in sorted(Path(xdg_runtime).glob('wayland-[0-9]*')):
-                if sock.suffix != '.lock':
-                    env['WAYLAND_DISPLAY'] = sock.name
-                    break
-        except Exception:
-            pass
-
-    # Prefer Wayland wlroots tool first (wlr-randr)
-    if shutil.which('wlr-randr') and out_name:
-        try:
-            # if mode_str contains refresh token like '1920x1080@60.000000', strip @Hz for wlr-randr
-            token = mode_str.split('@', 1)[0]
-            subprocess.run(['wlr-randr', '--output', out_name, '--mode', token], timeout=5, check=True, env=env)
-            return True
-        except Exception:
-            pass
-
-    # Try swaymsg next (Wayland sway)
-    if shutil.which('swaymsg') and out_name:
-        try:
-            # swaymsg expects: output <name> mode <width> <height>
-            subprocess.run(['swaymsg', 'output', out_name, 'mode', width, height], timeout=5, check=True, env=env)
-            return True
-        except Exception:
-            pass
-
-    # Finally try xrandr (X11). Skip xrandr when running in Wayland session.
-    if os.environ.get('XDG_SESSION_TYPE', '').lower() == 'wayland' or os.environ.get('WAYLAND_DISPLAY'):
-        # running under Wayland — avoid xrandr
-        return False
-    if shutil.which('xrandr') and out_name:
-        # try common DISPLAY fallbacks so this works when daemonized
-        for disp in (None, ':0', ':0.0', ':1'):
-            try:
-                env = os.environ.copy()
-                if disp:
-                    env['DISPLAY'] = disp
-                subprocess.run(['xrandr', '--output', out_name, '--mode', f'{width}x{height}'], env=env, timeout=5, check=True)
-                return True
-            except Exception:
-                continue
-
-    return False
-
-
-@app.route('/api/settings/apply_display', methods=['POST'])
-@login_required
-def apply_display_now():
-    # Apply display_mode from posted JSON or from saved state
-    data = request.get_json(silent=True) or {}
-    mode = data.get('display_mode')
-    target = data.get('display_output') or None
-    if not mode:
-        state = db.get_state()
-        mode = state.get('display_mode', '')
-    if not mode:
-        return jsonify({'ok': False, 'error': 'no_mode'}), 400
-    try:
-        ok = _apply_display_mode(str(mode), target_output=target)
-        # Re-apply saved audio profile — display changes can reset PipeWire routing
-        _apply_saved_audio()
-        return jsonify({'ok': bool(ok)})
-    except Exception as e:
-        return jsonify({'ok': False, 'error': str(e)}), 500
-
-
-@app.route('/api/audio/profiles')
-@login_required
-def audio_profiles():
-    dev_id = _audio_device_id()
-    if dev_id is None:
-        return jsonify([])
-    try:
-        pw_env = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
-        out = subprocess.check_output(
-            ['pw-cli', 'enum-params', str(dev_id), 'EnumProfile'],
-            timeout=5, env=pw_env
-        ).decode()
-        return jsonify(_parse_profiles(out))
-    except Exception:
-        return jsonify([])
-
-
-@app.route('/api/displays')
-@login_required
-def list_displays():
-    outputs = []
-    try:
-        for conn in sorted(Path('/sys/class/drm').iterdir()):
-            status_file = conn / 'status'
-            if not status_file.exists():
-                continue
-            parts = conn.name.split('-', 1)
-            if len(parts) != 2:
-                continue
-            connector = parts[1]
-            connected = status_file.read_text().strip() == 'connected'
-            outputs.append({'name': connector, 'connected': connected})
-    except Exception:
-        pass
-
-    # Best-effort: if xrandr is available, append available modes per output
-    try:
-        if shutil.which('xrandr'):
-            out = ''
-            # try without DISPLAY and common DISPLAY fallbacks
-            for disp in (None, ':0', ':0.0', ':1'):
-                try:
-                    env = os.environ.copy()
-                    if disp:
-                        env['DISPLAY'] = disp
-                    out = subprocess.check_output(['xrandr', '--query'], env=env, timeout=5).decode(errors='ignore')
-                    if out and ' connected' in out:
-                        break
-                except Exception:
-                    out = ''
-            lines = out.splitlines() if out else []
-            cur_out = None
-            modes_map = {}
-            for line in lines:
-                m = re.match(r"^([A-Za-z0-9-]+) connected", line)
-                if m:
-                    cur_out = m.group(1)
-                    modes_map[cur_out] = []
-                    modes_current = None
-                    continue
-                if cur_out:
-                    m2 = re.match(r"^\s+(\d+)x(\d+)\s+([0-9\.]+)\s*Hz?(.*)$", line)
-                    if m2:
-                        w = m2.group(1)
-                        h = m2.group(2)
-                        hz = m2.group(3)
-                        token = f"{w}x{h}@{hz}"
-                        if token not in modes_map[cur_out]:
-                            modes_map[cur_out].append(token)
-                        rest = m2.group(4) or ''
-                        if '*' in rest or 'current' in rest:
-                            modes_map.setdefault('__current__', {})
-                            modes_map['__current__'][cur_out] = token
-                    elif line.strip() == '':
-                        cur_out = None
-            if modes_map:
-                for o in outputs:
-                    if o['name'] in modes_map and modes_map[o['name']]:
-                        o['modes'] = modes_map[o['name']]
-                # extract current modes if any
-                curmap = modes_map.get('__current__', {})
-                if curmap:
-                    for o in outputs:
-                        if o['name'] in curmap:
-                            o['current_mode'] = curmap[o['name']]
-    except Exception:
-        pass
-
-    # If no modes found yet and wlr-randr is available (Wayland wlroots), try parsing it
-    try:
-        if not any(o.get('modes') for o in outputs) and shutil.which('wlr-randr'):
-            env = os.environ.copy()
-            if not env.get('WAYLAND_DISPLAY'):
-                xdg_runtime = env.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
-                try:
-                    for sock in sorted(Path(xdg_runtime).glob('wayland-[0-9]*')):
-                        if sock.suffix != '.lock':
-                            env['WAYLAND_DISPLAY'] = sock.name
-                            break
-                except Exception:
-                    pass
-            out = subprocess.check_output(['wlr-randr'], timeout=5, env=env).decode(errors='ignore')
-            lines = out.splitlines()
-            cur_out = None
-            modes_map = {}
-            for line in lines:
-                if line.strip() == '':
-                    continue
-                # output lines usually start without indent
-                if line[0] != ' ' and line[0] != '\t':
-                    cur_out = line.strip().split()[0]
-                    modes_map[cur_out] = []
-                    continue
-                # mode lines are indented and often contain WxH and optional (current)
-                m = re.search(r"(\d+)x(\d+)\s+px,\s+([0-9\.]+)\s+Hz(?:.*(current))?", line)
-                if cur_out and m:
-                    w = m.group(1)
-                    h = m.group(2)
-                    hz = m.group(3)
-                    token = f"{w}x{h}@{hz}"
-                    if token not in modes_map[cur_out]:
-                        modes_map[cur_out].append(token)
-                    if 'current' in line:
-                        modes_map.setdefault('__current__', {})
-                        modes_map['__current__'][cur_out] = token
-            current_map = modes_map.pop('__current__', {})
-            if modes_map:
-                for o in outputs:
-                    if o['name'] in modes_map and modes_map[o['name']]:
-                        o['modes'] = modes_map[o['name']]
-                    if o['name'] in current_map:
-                        o['current_mode'] = current_map[o['name']]
-    except Exception:
-        pass
-
-    return jsonify(outputs)
-
-
-@app.route('/api/volume', methods=['GET'])
-@login_required
-def get_volume():
-    try:
-        out = subprocess.check_output(['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@'], timeout=5).decode()
-        # Format: "Volume: 1.00"
-        vol = round(float(out.split()[1]) * 100)
-        return jsonify({'volume': vol})
-    except Exception:
-        return jsonify({'volume': 100})
-
-
-@app.route('/api/volume', methods=['POST'])
-@login_required
-def set_volume():
-    data = request.get_json()
-    vol = max(0, min(100, int(data.get('volume', 100))))
-    subprocess.run(['wpctl', 'set-volume', '@DEFAULT_AUDIO_SINK@', f'{vol/100:.2f}'], timeout=5)
-    return '', 204
-
-
-@app.route('/api/settings', methods=['GET'])
-@login_required
-def get_settings():
-    state = db.get_state()
-    return jsonify({
-        'audio_profile_index': int(state.get('audio_profile_index', 1)),
-        'display_output':      state.get('display_output', 'last'),
-        'display_mode':        state.get('display_mode', ''),
-        'internal_display':    state.get('internal_display', 'on'),
-    })
-
-
-@app.route('/api/settings', methods=['POST'])
-@login_required
-def save_settings():
-    data = request.get_json()
-    if not data:
-        abort(400)
-    profile_index    = int(data.get('audio_profile_index', 1))
-    display          = data.get('display_output', 'last')
-    display_mode     = str(data.get('display_mode', '')).strip()
-    internal_display = 'on' if data.get('internal_display', True) else 'off'
-    db.set_state('audio_profile_index', str(profile_index))
-    db.set_state('display_output', display)
-    db.set_state('internal_display', internal_display)
-    if display_mode:
-        db.set_state('display_mode', display_mode)
-    conf = Path(os.path.dirname(__file__)) / 'kiosk.conf'
-    conf.write_text(
-        '# Genererad av kiosk-admin — ändra ej manuellt\n'
-        f'KIOSK_AUDIO_PROFILE_INDEX={profile_index}\n'
-        f'KIOSK_DISPLAY_OUTPUT={display}\n'
-        f'KIOSK_INTERNAL_DISPLAY={internal_display}\n'
-        + (f'KIOSK_DISPLAY_MODE={display_mode}\n' if display_mode else '')
-    )
-    # Tillämpa profilen direkt utan omstart
-    dev_id = _audio_device_id()
-    if dev_id is not None:
-        try:
-            pw_env = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
-            subprocess.run(
-                ['pw-cli', 's', str(dev_id), 'Profile', f'{{ index: {profile_index} }}'],
-                timeout=5, check=True, env=pw_env
-            )
-        except Exception:
-            pass
-    return '', 204
-
-
-# --- Söv-läge ---
-
-@app.route('/api/sleep', methods=['GET'])
-@login_required
-def get_sleep():
-    state = db.get_state()
-    return jsonify({
-        'enabled': state.get('sleep_enabled', '0') == '1',
-        'start':   state.get('sleep_start', '22:00'),
-        'end':     state.get('sleep_end',   '07:00'),
-    })
-
-
-@app.route('/api/sleep', methods=['POST'])
-@login_required
-def set_sleep():
-    data = request.get_json()
-    if not data:
-        abort(400)
-    enabled = bool(data.get('enabled', False))
-    start   = str(data.get('start', '22:00'))
-    end     = str(data.get('end',   '07:00'))
-    # Validera HH:MM
-    import re as _re2
-    if not _re2.match(r'^\d{2}:\d{2}$', start): start = '22:00'
-    if not _re2.match(r'^\d{2}:\d{2}$', end):   end   = '07:00'
-    db.set_state('sleep_enabled', '1' if enabled else '0')
-    db.set_state('sleep_start',   start)
-    db.set_state('sleep_end',     end)
-    sched.reload_sleep_jobs()
-    # Skicka omedelbart om aktiv
-    active = enabled and _is_sleep_active(start, end)
-    socketio.emit('sleep_toggle', {'active': active})
-    return '', 204
-
-
-# --- Ticker ---
-
-import re as _re
-import urllib.request as _urlreq
-import xml.etree.ElementTree as _ET
-from datetime import datetime as _dt, timedelta as _td
-
-_HEX_COLOR  = _re.compile(r'^#[0-9a-fA-F]{6}$')
-_rss_cache  = {'url': None, 'items': [], 'fetched_at': None}
-
-
-def _fetch_rss(url, max_items=30):
-    global _rss_cache
-    now = _dt.now()
-    if (_rss_cache['url'] == url and _rss_cache['fetched_at']
-            and now - _rss_cache['fetched_at'] < _td(minutes=2)):
-        return _rss_cache['items']
-    try:
-        req = _urlreq.Request(url, headers={'User-Agent': 'KioskRSS/1.0'})
-        with _urlreq.urlopen(req, timeout=10) as r:
-            raw = r.read()
-        root = _ET.fromstring(raw)
-        items = []
-        # RSS 2.0
-        for item in root.findall('.//item'):
-            t = (item.findtext('title') or '').strip()
-            if t:
-                items.append(t)
-        # Atom
-        if not items:
-            ns = {'a': 'http://www.w3.org/2005/Atom'}
-            for entry in root.findall('.//a:entry', ns):
-                t = (entry.findtext('a:title', namespaces=ns) or '').strip()
-                if t:
-                    items.append(t)
-        _rss_cache = {'url': url, 'items': items[:max_items], 'fetched_at': now}
-    except Exception:
-        pass  # keep stale cache on error
-    return _rss_cache['items']
-
-
-@app.route('/api/ticker/rss')
-def get_ticker_rss():
-    state = db.get_state()
-    url   = state.get('ticker_rss_url', '').strip()
-    if not url:
-        return jsonify({'items': [], 'error': 'Ingen RSS-URL konfigurerad'})
-    items = _fetch_rss(url)
-    return jsonify({'items': items})
-
-
-@app.route('/api/ticker', methods=['GET'])
-def get_ticker():
-    state = db.get_state()
-    return jsonify({
-        'enabled':      state.get('ticker_enabled', '0') == '1',
-        'source':       state.get('ticker_source', 'text'),
-        'text':         state.get('ticker_text', ''),
-        'rss_url':      state.get('ticker_rss_url', ''),
-        'rss_interval': int(state.get('ticker_rss_interval', '10')),
-        'pps':          int(state.get('ticker_pps', '150')),
-        'mode':         state.get('ticker_mode', 'sequential'),
-        'separator':    state.get('ticker_separator', '◆'),
-        'position':     state.get('ticker_position', 'bottom'),
-        'text_color':   state.get('ticker_text_color', '#ffffff'),
-        'font_size':    float(state.get('ticker_font_size', '2.0')),
-        'font_family':  state.get('ticker_font_family', 'sans-serif'),
-        'bar_color':    state.get('ticker_bar_color', '#000000'),
-        'bar_opacity':  int(state.get('ticker_bar_opacity', '72')),
-        'bar_padding':  float(state.get('ticker_bar_padding', '0.45')),
-    })
-
-
-@app.route('/api/ticker', methods=['POST'])
-@login_required
-def set_ticker():
-    data = request.get_json()
-    if not data:
-        abort(400)
-    enabled      = bool(data.get('enabled', False))
-    source       = 'rss' if data.get('source') == 'rss' else 'text'
-    text         = str(data.get('text', ''))
-    rss_url      = str(data.get('rss_url', ''))
-    rss_interval = max(1, min(1440, int(data.get('rss_interval', 10))))
-    pps          = max(30, min(800, int(data.get('pps', 150))))
-    mode         = 'continuous' if data.get('mode') == 'continuous' else 'sequential'
-    separator    = str(data.get('separator', '◆'))[:20]
-    position     = 'top' if data.get('position') == 'top' else 'bottom'
-    text_color   = str(data.get('text_color', '#ffffff'))
-    font_size    = max(0.5, min(6.0, float(data.get('font_size', 2.0))))
-    font_family  = str(data.get('font_family', 'sans-serif'))
-    bar_color    = str(data.get('bar_color', '#000000'))
-    bar_opacity  = max(0, min(100, int(data.get('bar_opacity', 72))))
-    bar_padding  = max(0.1, min(2.0, float(data.get('bar_padding', 0.45))))
-    if not _HEX_COLOR.match(text_color): text_color = '#ffffff'
-    if not _HEX_COLOR.match(bar_color):  bar_color  = '#000000'
-    db.set_state('ticker_enabled',      '1' if enabled else '0')
-    db.set_state('ticker_source',       source)
-    db.set_state('ticker_text',         text)
-    db.set_state('ticker_rss_url',      rss_url)
-    db.set_state('ticker_rss_interval', str(rss_interval))
-    db.set_state('ticker_pps',          str(pps))
-    db.set_state('ticker_mode',         mode)
-    db.set_state('ticker_separator',    separator)
-    db.set_state('ticker_position',     position)
-    db.set_state('ticker_text_color',   text_color)
-    db.set_state('ticker_font_size',    str(font_size))
-    db.set_state('ticker_font_family',  font_family)
-    db.set_state('ticker_bar_color',    bar_color)
-    db.set_state('ticker_bar_opacity',  str(bar_opacity))
-    db.set_state('ticker_bar_padding',  str(bar_padding))
-    # Invalidate RSS cache when URL changes
-    if source == 'rss':
-        _rss_cache['fetched_at'] = None
-    payload = {
-        'enabled': enabled, 'source': source,
-        'text': text, 'rss_url': rss_url, 'rss_interval': rss_interval,
-        'pps': pps, 'mode': mode, 'separator': separator, 'position': position,
-        'text_color': text_color, 'font_size': font_size, 'font_family': font_family,
-        'bar_color': bar_color, 'bar_opacity': bar_opacity, 'bar_padding': bar_padding,
-    }
-    socketio.emit('ticker', payload)
-    return '', 204
-
-
-# --- Chat overlay ---
-
-@app.route('/api/chat', methods=['GET'])
-def get_chat():
-    state = db.get_state()
-    return jsonify({
-        'enabled':          state.get('chat_enabled',          '0') == '1',
-        'position':         state.get('chat_position',         'right'),
-        'width_pct':        int(state.get('chat_width_pct',    '25')),
-        'height':           int(state.get('chat_height',       '100')),
-        'bg_color':         state.get('chat_bg_color',         '#000000'),
-        'bg_opacity':       int(state.get('chat_bg_opacity',   '70')),
-        'text_color':       state.get('chat_text_color',       '#ffffff'),
-        'font_size':        int(state.get('chat_font_size',    '14')),
-        'kiosk_enabled':    state.get('kiosk_chat_enabled',    '0') == '1',
-        'kiosk_position':   state.get('kiosk_chat_position',   'right'),
-        'kiosk_width_pct':  int(state.get('kiosk_chat_width',  '30')),
-        'kiosk_height':     int(state.get('kiosk_chat_height', '80')),
-    })
-
-
-@app.route('/api/chat', methods=['POST'])
-@login_required
-def set_chat():
-    data = request.get_json()
-    if not data:
-        abort(400)
-    enabled    = bool(data.get('enabled', False))
-    position   = 'left' if data.get('position') == 'left' else 'right'
-    width_pct  = max(5,   min(60,  int(data.get('width_pct',  25))))
-    height     = max(30,  min(100, int(data.get('height',    100))))
-    bg_color   = str(data.get('bg_color',   '#000000'))
-    bg_opacity = max(0,   min(100, int(data.get('bg_opacity',  70))))
-    text_color = str(data.get('text_color', '#ffffff'))
-    font_size  = max(8,   min(48,  int(data.get('font_size',   14))))
-    if not _HEX_COLOR.match(bg_color):   bg_color   = '#000000'
-    if not _HEX_COLOR.match(text_color): text_color = '#ffffff'
-    kiosk_enabled   = bool(data.get('kiosk_enabled', False))
-    kiosk_position  = 'left' if data.get('kiosk_position') == 'left' else 'right'
-    kiosk_width_pct = max(10, min(60, int(data.get('kiosk_width_pct', 30))))
-    kiosk_height    = max(30, min(100, int(data.get('kiosk_height',   80))))
-    db.set_state('chat_enabled',          '1' if enabled else '0')
-    db.set_state('chat_position',         position)
-    db.set_state('chat_width_pct',        str(width_pct))
-    db.set_state('chat_height',           str(height))
-    db.set_state('chat_bg_color',         bg_color)
-    db.set_state('chat_bg_opacity',       str(bg_opacity))
-    db.set_state('chat_text_color',       text_color)
-    db.set_state('chat_font_size',        str(font_size))
-    db.set_state('kiosk_chat_enabled',    '1' if kiosk_enabled else '0')
-    db.set_state('kiosk_chat_position',   kiosk_position)
-    db.set_state('kiosk_chat_width',      str(kiosk_width_pct))
-    db.set_state('kiosk_chat_height',     str(kiosk_height))
-    payload = {
-        'enabled': enabled, 'position': position,
-        'width_pct': width_pct, 'height': height,
-        'bg_color': bg_color, 'bg_opacity': bg_opacity,
-        'text_color': text_color, 'font_size': font_size,
-        'kiosk_enabled': kiosk_enabled, 'kiosk_position': kiosk_position,
-        'kiosk_width_pct': kiosk_width_pct, 'kiosk_height': kiosk_height,
-    }
-    socketio.emit('chat_settings', payload)
-    return '', 204
-
-
-# --- Clock overlay ---
-
-@app.route('/api/clock', methods=['GET'])
-def get_clock():
-    state = db.get_state()
-    return jsonify({
-        'enabled':       state.get('clock_enabled',       '0') == '1',
-        'style':         state.get('clock_style',         'digital'),
-        'show_date':     state.get('clock_show_date',     '0') == '1',
-        'position':      state.get('clock_position',      'top-right'),
-        'size':          float(state.get('clock_size',    '2')),
-        'color':         state.get('clock_color',         '#ffffff'),
-        'opacity':       int(state.get('clock_opacity',   '90')),
-        'schedule_mode': state.get('clock_schedule_mode', 'always'),
-    })
-
-
-@app.route('/api/clock', methods=['POST'])
-@login_required
-def set_clock():
-    data = request.get_json()
-    if not data:
-        abort(400)
-    enabled  = bool(data.get('enabled', False))
-    style    = 'analog' if data.get('style') == 'analog' else 'digital'
-    show_date = bool(data.get('show_date', False))
-    position  = data.get('position', 'top-right')
-    if position not in {'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center'}:
-        position = 'top-right'
-    size    = max(1.0, min(5.0, float(data.get('size', 2))))
-    color   = str(data.get('color', '#ffffff'))
-    if not _HEX_COLOR.match(color):
-        color = '#ffffff'
-    opacity = max(0, min(100, int(data.get('opacity', 90))))
-    schedule_mode = 'scheduled_only' if data.get('schedule_mode') == 'scheduled_only' else 'always'
-
-    db.set_state('clock_enabled',       '1' if enabled else '0')
-    db.set_state('clock_style',         style)
-    db.set_state('clock_show_date',     '1' if show_date else '0')
-    db.set_state('clock_position',      position)
-    db.set_state('clock_size',          str(size))
-    db.set_state('clock_color',         color)
-    db.set_state('clock_opacity',       str(opacity))
-    db.set_state('clock_schedule_mode', schedule_mode)
-
-    payload = {
-        'enabled': enabled, 'style': style, 'show_date': show_date,
-        'position': position, 'size': size, 'color': color,
-        'opacity': opacity, 'schedule_mode': schedule_mode,
-    }
-    socketio.emit('clock_settings', payload)
-    return '', 204
-
-
-# --- Music ---
-
-_MUSIC_EXTS = {'.mp3', '.flac', '.ogg', '.wav', '.m4a', '.aac'}
-
-@app.route('/api/music', methods=['GET'])
-def get_music():
-    state = db.get_state()
-    return jsonify({
-        'enabled':     state.get('music_enabled', '0') == '1',
-        'source_type': state.get('music_source_type', 'local'),
-        'source':      state.get('music_source', ''),
-        'volume':      int(state.get('music_volume', '50')),
-        'shuffle':     state.get('music_shuffle', '0') == '1',
-        'show_overlay': state.get('music_show_overlay', '1') == '1',
-        'overlay_position': state.get('music_overlay_position', 'bottom-left'),
-        'now_playing': {
-            'title':  state.get('music_now_title', ''),
-            'artist': state.get('music_now_artist', ''),
-        },
-    })
-
-
-@app.route('/api/music', methods=['POST'])
-@login_required
-def set_music():
-    data = request.get_json()
-    if not data:
-        abort(400)
-    enabled     = bool(data.get('enabled', False))
-    source_type = 'youtube' if data.get('source_type') == 'youtube' else 'local'
-    source      = str(data.get('source', ''))
-    volume      = max(0, min(100, int(data.get('volume', 50))))
-    shuffle     = bool(data.get('shuffle', False))
-    show_overlay     = bool(data.get('show_overlay', True))
-    overlay_position = str(data.get('overlay_position', 'bottom-left'))
-    if overlay_position not in {'top-left', 'top-right', 'bottom-left', 'bottom-right'}:
-        overlay_position = 'bottom-left'
-
-    db.set_state('music_enabled',          '1' if enabled else '0')
-    db.set_state('music_source_type',      source_type)
-    db.set_state('music_source',           source)
-    db.set_state('music_volume',           str(volume))
-    db.set_state('music_shuffle',          '1' if shuffle else '0')
-    db.set_state('music_show_overlay',     '1' if show_overlay else '0')
-    db.set_state('music_overlay_position', overlay_position)
-
-    payload = {
-        'enabled': enabled, 'source_type': source_type,
-        'source': source, 'volume': volume, 'shuffle': shuffle,
-        'show_overlay': show_overlay, 'overlay_position': overlay_position,
-    }
-    socketio.emit('music_settings', payload)
-    return '', 204
-
-
-@app.route('/api/music/tracks', methods=['GET'])
-@login_required
-def get_music_tracks():
-    folder = request.args.get('path', '')
-    if not folder:
-        return jsonify([])
-    p = Path(folder)
-    try:
-        p = p.resolve()
-        if not any(str(p).startswith(str(r)) for r in ALLOWED_ROOTS):
-            abort(403)
-    except Exception:
-        abort(400)
-    if not p.is_dir():
-        return jsonify([])
-    tracks = sorted([
-        {'name': f.name, 'path': str(f)}
-        for f in p.iterdir()
-        if f.is_file() and f.suffix.lower() in _MUSIC_EXTS
-    ], key=lambda x: x['name'])
-    return jsonify(tracks)
-
-
-_music_playlist_cache = []  # in-memory list of video IDs from last extraction
-
-
-@app.route('/api/music/extract', methods=['POST'])
-@login_required
-def extract_music_playlist():
-    """Extract video IDs for the music playlist and persist to DB."""
-    data = request.get_json() or {}
-    url  = str(data.get('url', '')).strip()
-    if not url:
-        return jsonify({'error': 'URL saknas'}), 400
-    try:
-        ids, _total_seconds = _yt_extract(url)
-    except subprocess.TimeoutExpired:
-        return jsonify({'error': 'Timeout — spellistans hämtning tog för lång tid'}), 504
-    except Exception as e:
-        return jsonify({'error': str(e)}), 500
-
-    global _music_playlist_cache
-    _music_playlist_cache = ids
-    _yt_playlist_cache[url] = ids
-    db.set_state('music_playlist_ids', ','.join(ids))
-    return jsonify({'count': len(ids), 'ids': ids})
-
-
-@app.route('/api/music/playlist', methods=['GET'])
-def get_music_playlist():
-    """Return the extracted playlist video IDs."""
-    global _music_playlist_cache
-    if not _music_playlist_cache:
-        stored = db.get_state().get('music_playlist_ids', '')
-        _music_playlist_cache = [v for v in stored.split(',') if v] if stored else []
-    return jsonify({'ids': _music_playlist_cache, 'count': len(_music_playlist_cache)})
-
-
-@app.route('/api/music/nowplaying', methods=['POST'])
-def update_now_playing():
-    """Called by display client to broadcast now-playing state to admin."""
-    data = request.get_json() or {}
-    title  = str(data.get('title',  ''))[:200]
-    artist = str(data.get('artist', ''))[:200]
-    db.set_state('music_now_title',  title)
-    db.set_state('music_now_artist', artist)
-    socketio.emit('now_playing', {'title': title, 'artist': artist})
-    return '', 204
-
-
-# --- YouTube playlist extraction (video & musik) ---
-
-_yt_playlist_cache = {}   # url -> [video_id, ...]
-
-
-def _yt_extract(url):
-    """Run yt-dlp and return (ids, total_seconds). Raises on error."""
-    import shutil
-    ytdlp = shutil.which('yt-dlp') or '/home/mrfox/.local/bin/yt-dlp'
-    if not ytdlp or not Path(ytdlp).exists():
-        raise RuntimeError('yt-dlp är inte installerat')
-    result = subprocess.run(
-        [ytdlp, '--flat-playlist', '--print', 'id', '--print', '%(duration)s',
-         '--no-warnings', url],
-        capture_output=True, text=True, timeout=60
-    )
-    lines = [l.strip() for l in result.stdout.splitlines() if l.strip()]
-    # Lines alternate: id, duration, id, duration, ...
-    ids, total_seconds = [], 0
-    for i in range(0, len(lines) - 1, 2):
-        ids.append(lines[i])
-        try:
-            total_seconds += int(lines[i + 1])
-        except ValueError:
-            pass  # duration okänd för denna video
-    if not ids:
-        raise RuntimeError(result.stderr[:300] or 'Inga videos hittades')
-    return ids, total_seconds
-
-
-@app.route('/api/playlist/extract', methods=['POST'])
-@login_required
-def extract_playlist():
-    """Extract video IDs from any public YouTube playlist URL and cache by URL."""
-    data = request.get_json() or {}
-    url  = str(data.get('url', '')).strip()
-    if not url:
-        return jsonify({'error': 'URL saknas'}), 400
-    try:
-        ids, total_seconds = _yt_extract(url)
-    except subprocess.TimeoutExpired:
-        return jsonify({'error': 'Timeout'}), 504
-    except Exception as e:
-        return jsonify({'error': str(e)}), 500
-    _yt_playlist_cache[url] = ids
-    return jsonify({'count': len(ids), 'ids': ids, 'total_seconds': total_seconds})
-
-
-@app.route('/api/playlist/ids', methods=['GET'])
-def get_playlist_ids():
-    """Return cached video IDs for a playlist URL."""
-    url = request.args.get('url', '').strip()
-    ids = _yt_playlist_cache.get(url, [])
-    return jsonify({'ids': ids, 'count': len(ids)})
-
-
-# --- Wi-Fi ---
-
-def _wifi_iface():
-    try:
-        with open('/proc/net/wireless') as _f:
-            for _line in _f:
-                if ':' in _line:
-                    _i = _line.split(':')[0].strip()
-                    if _i:
-                        return _i
-    except Exception:
-        pass
-    return 'wlo1'
-
-_WIFI_IFACE = _wifi_iface()
-
-
-def _wpa(*args):
-    cmd = ['sudo', '-n', '/usr/sbin/wpa_cli', '-i', _WIFI_IFACE] + list(args)
-    try:
-        r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
-        return r.returncode, r.stdout.strip()
-    except Exception as e:
-        return 1, str(e)
-
-
-def _wifi_status():
-    rc, out = _wpa('status')
-    d = {}
-    if rc == 0:
-        for line in out.splitlines():
-            if '=' in line:
-                k, v = line.split('=', 1)
-                d[k.strip()] = v.strip()
-    connected = d.get('wpa_state') == 'COMPLETED'
-    ip = ''
-    try:
-        r = subprocess.run(['ip', '-4', 'addr', 'show', _WIFI_IFACE],
-                           capture_output=True, text=True, timeout=5)
-        m = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', r.stdout)
-        if m:
-            ip = m.group(1)
-    except Exception:
-        pass
-    return {
-        'connected': connected,
-        'ssid': d.get('ssid', ''),
-        'ip': ip,
-        'signal': int(d.get('signal_level', 0) or 0),
-        'iface': _WIFI_IFACE,
-        'sudo_ok': rc != 1 or 'sudo' not in out,
-    }
-
-
-def _wifi_scan():
-    _wpa('scan')
-    import time; time.sleep(2.5)
-    rc, out = _wpa('scan_results')
-    networks = []
-    seen = set()
-    for line in out.splitlines():
-        if not line or line.startswith('bssid'):
-            continue
-        parts = line.split('\t')
-        if len(parts) < 5:
-            continue
-        ssid = parts[4].strip()
-        if not ssid or ssid in seen:
-            continue
-        seen.add(ssid)
-        try:
-            dbm = int(parts[2])
-        except (ValueError, IndexError):
-            dbm = -90
-        flags = parts[3] if len(parts) > 3 else ''
-        if 'WPA2' in flags:
-            sec = 'WPA2'
-        elif 'WPA' in flags:
-            sec = 'WPA'
-        elif 'WEP' in flags:
-            sec = 'WEP'
-        else:
-            sec = 'open'
-        networks.append({
-            'ssid': ssid,
-            'signal': max(0, min(100, 2 * (dbm + 100))),
-            'security': sec,
-        })
-    networks.sort(key=lambda x: -x['signal'])
-    return networks
-
-
-def _wifi_saved():
-    rc, out = _wpa('list_networks')
-    saved = []
-    for line in out.splitlines():
-        parts = line.split('\t')
-        if len(parts) >= 2 and parts[0].strip().isdigit():
-            ssid = parts[1].strip()
-            if ssid:
-                flags = parts[3].strip() if len(parts) > 3 else ''
-                saved.append({
-                    'id': parts[0].strip(),
-                    'ssid': ssid,
-                    'current': '[CURRENT]' in flags,
-                })
-    return saved
-
-
-def _wifi_connect(ssid, password):
-    for net in _wifi_saved():
-        if net['ssid'] == ssid:
-            _wpa('remove_network', net['id'])
-    rc, net_id = _wpa('add_network')
-    net_id = net_id.strip()
-    if not net_id.isdigit():
-        return False, 'Kunde inte lägga till nätverk'
-    _wpa('set_network', net_id, 'ssid', f'"{ssid}"')
-    if password:
-        _wpa('set_network', net_id, 'psk', f'"{password}"')
-    else:
-        _wpa('set_network', net_id, 'key_mgmt', 'NONE')
-    _wpa('select_network', net_id)
-    import time
-    for _ in range(20):
-        time.sleep(1)
-        s = _wifi_status()
-        if s['connected'] and s['ssid'] == ssid:
-            _wpa('save_config')
-            return True, s.get('ip', '')
-    _wpa('remove_network', net_id)
-    return False, 'Anslutning misslyckades (fel lösenord?)'
-
-
-@app.route('/api/wifi/status')
-@login_required
-def api_wifi_status():
-    return jsonify(_wifi_status())
-
-
-@app.route('/api/wifi/networks')
-@login_required
-def api_wifi_networks():
-    try:
-        nets = _wifi_scan()
-        known = {n['ssid'] for n in _wifi_saved()}
-        for n in nets:
-            n['known'] = n['ssid'] in known
-        return jsonify({'networks': nets, 'iface': _WIFI_IFACE})
-    except Exception as e:
-        return jsonify({'networks': [], 'error': str(e)})
-
-
-@app.route('/api/wifi/connect', methods=['POST'])
-@login_required
-def api_wifi_connect():
-    data = request.get_json()
-    if not data or not data.get('ssid'):
-        abort(400)
-    ssid     = str(data['ssid'])[:64]
-    password = str(data.get('password', ''))[:64]
-    if '"' in ssid or '"' in password:
-        return jsonify({'ok': False, 'message': 'Ogiltiga tecken i SSID/lösenord'}), 400
-    ok, msg = _wifi_connect(ssid, password)
-    return jsonify({'ok': ok, 'message': msg if not ok else '', 'ip': msg if ok else ''})
-
-
-@app.route('/api/wifi/disconnect', methods=['POST'])
-@login_required
-def api_wifi_disconnect():
-    _wpa('disconnect')
-    return '', 204
-
-
-@app.route('/api/wifi/saved')
-@login_required
-def api_wifi_saved():
-    return jsonify({'saved': _wifi_saved()})
-
-
-@app.route('/api/wifi/saved/<net_id>', methods=['DELETE'])
-@login_required
-def api_wifi_remove(net_id):
-    if not net_id.isdigit():
-        abort(400)
-    _wpa('remove_network', net_id)
-    _wpa('save_config')
-    return '', 204
-
-
-# --- Bluetooth ---
-
-import threading as _threading
-import time as _time
-
-_BT_ADDR_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
-
-_bt_scanning      = False
-_bt_scan_lock     = _threading.Lock()
-_bt_scan_proc     = None
-
-
-def _bt_run(*args, timeout=15):
-    cmd = ['bluetoothctl'] + list(args)
-    try:
-        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
-        return r.returncode, r.stdout.strip()
-    except subprocess.TimeoutExpired:
-        return 1, 'timeout'
-    except Exception as e:
-        return 1, str(e)
-
-
-def _bt_status():
-    rc, out = _bt_run('show')
-    powered     = False
-    name        = ''
-    addr        = ''
-    discovering = False
-    for line in out.splitlines():
-        s = line.strip()
-        if s.startswith('Powered:'):    powered     = 'yes' in s
-        elif s.startswith('Name:'):     name        = s.split(':', 1)[1].strip()
-        elif s.startswith('Discovering:'): discovering = 'yes' in s
-    m = re.search(r'([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', out.split('\n')[0] if out else '')
-    if m:
-        addr = m.group(0)
-    return {
-        'powered':     powered,
-        'name':        name,
-        'address':     addr,
-        'discovering': discovering or _bt_scanning,
-        'available':   rc == 0,
-    }
-
-
-def _bt_device_info(addr):
-    rc, info = _bt_run('info', addr)
-    connected = paired = trusted = False
-    dev_name  = ''
-    dev_type  = ''
-    for line in info.splitlines():
-        s = line.strip()
-        if s.startswith('Name:'):       dev_name   = s.split(':', 1)[1].strip()
-        elif s.startswith('Connected:'): connected  = 'yes' in s
-        elif s.startswith('Paired:'):    paired     = 'yes' in s
-        elif s.startswith('Trusted:'):   trusted    = 'yes' in s
-        elif s.startswith('Icon:'):
-            icon = s.split(':', 1)[1].strip()
-            if any(k in icon for k in ('audio', 'headphone', 'headset', 'speaker')):
-                dev_type = 'audio'
-            elif 'phone' in icon:
-                dev_type = 'phone'
-            elif 'input' in icon:
-                dev_type = 'input'
-            else:
-                dev_type = icon
-    return {
-        'address': addr, 'name': dev_name,
-        'connected': connected, 'paired': paired, 'trusted': trusted,
-        'type': dev_type,
-    }
-
-
-def _bt_devices():
-    rc, out = _bt_run('devices')
-    result = []
-    for line in out.splitlines():
-        m = re.match(r'Device\s+([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\s+(.*)', line)
-        if m:
-            addr = m.group(1)
-            name = m.group(2).strip()
-            info = _bt_device_info(addr)
-            if not info['name']:
-                info['name'] = name
-            result.append(info)
-    return result
-
-
-def _bt_scan_worker(duration=15):
-    global _bt_scanning, _bt_scan_proc
-    try:
-        _bt_scan_proc = subprocess.Popen(
-            ['bluetoothctl', 'scan', 'on'],
-            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
-        )
-        _time.sleep(duration)
-    finally:
-        try:
-            if _bt_scan_proc:
-                _bt_scan_proc.terminate()
-                _bt_scan_proc = None
-        except Exception:
-            pass
-        _bt_run('scan', 'off', timeout=5)
-        with _bt_scan_lock:
-            _bt_scanning = False
-
-
-@app.route('/api/bluetooth/status')
-@login_required
-def api_bt_status():
-    return jsonify(_bt_status())
-
-
-@app.route('/api/bluetooth/devices')
-@login_required
-def api_bt_devices():
-    return jsonify({'devices': _bt_devices(), 'scanning': _bt_scanning})
-
-
-@app.route('/api/bluetooth/scan', methods=['POST'])
-@login_required
-def api_bt_scan():
-    global _bt_scanning
-    with _bt_scan_lock:
-        if _bt_scanning:
-            return jsonify({'ok': True, 'scanning': True})
-        _bt_scanning = True
-    t = _threading.Thread(target=_bt_scan_worker, args=(15,), daemon=True)
-    t.start()
-    return jsonify({'ok': True, 'scanning': True})
-
-
-@app.route('/api/bluetooth/pair', methods=['POST'])
-@login_required
-def api_bt_pair():
-    data = request.get_json()
-    if not data or not data.get('address'):
-        abort(400)
-    addr = str(data['address'])
-    if not _BT_ADDR_RE.match(addr):
-        abort(400)
-    _bt_run('trust', addr, timeout=5)
-    rc, out = _bt_run('pair', addr, timeout=30)
-    ok = rc == 0 or 'already paired' in out.lower() or 'already exists' in out.lower()
-    if ok:
-        _bt_run('connect', addr, timeout=15)
-    return jsonify({'ok': ok, 'message': '' if ok else out.split('\n')[-1]})
-
-
-@app.route('/api/bluetooth/connect', methods=['POST'])
-@login_required
-def api_bt_connect():
-    data = request.get_json()
-    if not data or not data.get('address'):
-        abort(400)
-    addr = str(data['address'])
-    if not _BT_ADDR_RE.match(addr):
-        abort(400)
-    rc, out = _bt_run('connect', addr, timeout=15)
-    ok = rc == 0 or 'connected' in out.lower()
-    return jsonify({'ok': ok, 'message': '' if ok else out.split('\n')[-1]})
-
-
-@app.route('/api/bluetooth/disconnect', methods=['POST'])
-@login_required
-def api_bt_disconnect():
-    data = request.get_json()
-    if not data or not data.get('address'):
-        abort(400)
-    addr = str(data['address'])
-    if not _BT_ADDR_RE.match(addr):
-        abort(400)
-    _bt_run('disconnect', addr, timeout=10)
-    return '', 204
-
-
-@app.route('/api/bluetooth/device/<path:address>', methods=['DELETE'])
-@login_required
-def api_bt_remove(address):
-    if not _BT_ADDR_RE.match(address):
-        abort(400)
-    _bt_run('remove', address, timeout=10)
-    return '', 204
+# --- File browser --- (migrerat till addons/filebrowser/)


-@app.route('/api/bluetooth/power', methods=['POST'])
-@login_required
-def api_bt_power():
-    data = request.get_json() or {}
-    on = bool(data.get('on', True))
-    _bt_run('power', 'on' if on else 'off', timeout=5)
-    return jsonify(_bt_status())
+# --- Settings --- (migrerat till addons/settings/)


-# --- System ---
+# --- Söv-läge --- (migrerat till addons/sleep/)

-@app.route('/api/reboot', methods=['POST'])
-@login_required
-def reboot():
-    subprocess.Popen(['sudo', 'reboot'])
-    return '', 204

+# --- Ticker --- (migrerat till addons/ticker/)
+# --- Chat overlay --- (migrerat till addons/livechat/)
+# --- Clock overlay --- (migrerat till addons/clock/)

-@app.route('/api/shutdown', methods=['POST'])
-@login_required
-def shutdown():
-    subprocess.Popen(['sudo', 'poweroff'])
-    return '', 204

+# --- Music --- (migrerat till addons/music/)

-def _sway_env():
-    """Returnerar env-dict med SWAYSOCK satt, eller None om sway inte körs."""
-    uid = os.getuid()
-    xdg_runtime = os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{uid}')
-    sockets = sorted(Path(xdg_runtime).glob(f'sway-ipc.{uid}.*.sock'))
-    if not sockets:
-        return None
-    env = os.environ.copy()
-    env['SWAYSOCK'] = str(sockets[0])
-    return env

-
-@app.route('/api/restart', methods=['POST'])
-@login_required
-def restart():
-    # Avsluta sway — getty loopar och startar om via .bash_profile → kiosk-launcher.sh
-    try:
-        env = _sway_env()
-        if env:
-            subprocess.Popen(['swaymsg', 'exit'], env=env)
-    except Exception:
-        pass
-    return '', 204
-
-
-def _apply_internal_display(enabled, env):
-    """Aktivera eller stäng av eDP-1 (intern display) via sway."""
-    try:
-        if enabled:
-            subprocess.run(['swaymsg', 'output', 'eDP-1', 'enable'], env=env, timeout=5)
-            eventlet.sleep(0.5)
-            local_ip = _local_ip()
-            flags = (
-                '--kiosk --ozone-platform=wayland --noerr --no-first-run '
-                '--disable-infobars --disable-session-crashed-bubble --disable-translate '
-                '--disable-web-security --autoplay-policy=no-user-gesture-required '
-                '--no-sandbox --user-data-dir=/tmp/kiosk-admin'
-            )
-            subprocess.run(['swaymsg', 'workspace 2'], env=env, timeout=5)
-            eventlet.sleep(0.3)
-            subprocess.run(['swaymsg', 'exec', f'chromium {flags} http://{local_ip}:5000/admin'],
-                           env=env, timeout=5)
-            eventlet.sleep(1)
-            subprocess.run(['swaymsg', 'workspace 1'], env=env, timeout=5)
-        else:
-            subprocess.run(['pkill', '-f', 'user-data-dir=/tmp/kiosk-admin'], timeout=5)
-            eventlet.sleep(0.3)
-            subprocess.run(['swaymsg', 'output', 'eDP-1', 'disable'], env=env, timeout=5)
-    except Exception:
-        pass
-
-
-@app.route('/api/internal_display', methods=['POST'])
-@login_required
-def toggle_internal_display():
-    data = request.get_json()
-    if data is None:
-        abort(400)
-    enabled = bool(data.get('enabled', True))
-    val = 'on' if enabled else 'off'
-
-    # Spara till DB och kiosk.conf
-    db.set_state('internal_display', val)
-    state = db.get_state()
-    conf = Path(os.path.dirname(__file__)) / 'kiosk.conf'
-    conf.write_text(
-        '# Genererad av kiosk-admin — ändra ej manuellt\n'
-        f'KIOSK_AUDIO_PROFILE_INDEX={state.get("audio_profile_index", 1)}\n'
-        f'KIOSK_DISPLAY_OUTPUT={state.get("display_output", "HDMI-A-1")}\n'
-        f'KIOSK_INTERNAL_DISPLAY={val}\n'
-        + (f'KIOSK_DISPLAY_MODE={state["display_mode"]}\n' if state.get("display_mode") else '')
-    )
-
-    env = _sway_env()
-    if not env:
-        return '', 204
-    eventlet.spawn(_apply_internal_display, enabled, env)
-    return '', 204
+# --- Wi-Fi --- (migrerat till addons/wifi/)
+# --- Bluetooth --- (migrerat till addons/bluetooth/)
+# --- System + internal_display --- (migrerat till addons/system/ och addons/settings/)


 # --- Startup ---
@@ -1961,8 +276,40 @@ def _apply_settings_after_cage():
             _apply_internal_display(True, env)


+@app.route('/api/addons', methods=['GET'])
+@login_required
+def list_addons():
+    """Lista alla kända addons med aktiverad/inaktiverad status."""
+    from addon_loader import get_all_addon_names
+    state = db.get_state()
+    result = []
+    for name in get_all_addon_names():
+        db_val = state.get(f'addon_{name}_enabled')
+        loaded = any(a['name'] == name for a in get_loaded_addons())
+        result.append({
+            'name': name,
+            'enabled': loaded,
+            'db_value': db_val,
+        })
+    return jsonify(result)
+
+
+@app.route('/api/addons/<name>/toggle', methods=['POST'])
+@login_required
+def toggle_addon(name):
+    """Aktivera/inaktivera ett addon. Kräver omstart för att träda i kraft."""
+    from addon_loader import get_all_addon_names
+    if name not in get_all_addon_names():
+        return jsonify({'error': 'Okänt addon'}), 404
+    state = db.get_state()
+    current_val = state.get(f'addon_{name}_enabled', '1')
+    new_val = '0' if current_val == '1' else '1'
+    db.set_state(f'addon_{name}_enabled', new_val)
+    socketio.emit('addons_changed', {'name': name, 'enabled': new_val == '1'})
+    return jsonify({'name': name, 'enabled': new_val == '1'})
+
+
 if __name__ == '__main__':
-    db.init_db()
     # Apply audio immediately (PipeWire is independent of the Wayland compositor)
     _apply_saved_audio()
     # Apply display settings in background after sway/Wayland is up
diff --git a/kiosk/auth.py b/kiosk/auth.py
new file mode 100644
index 0000000..afb4209
--- /dev/null
+++ b/kiosk/auth.py
@@ -0,0 +1,15 @@
+from functools import wraps
+from flask import session, request, jsonify, redirect, url_for
+
+
+def login_required(f):
+    @wraps(f)
+    def decorated(*args, **kwargs):
+        if not session.get('logged_in'):
+            is_xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
+            wants_json = request.is_json or 'application/json' in request.headers.get('Accept', '')
+            if is_xhr or wants_json or request.path.startswith('/api/'):
+                return jsonify({'error': 'login_required'}), 401
+            return redirect(url_for('login', next=request.path))
+        return f(*args, **kwargs)
+    return decorated
diff --git a/kiosk/config.py b/kiosk/config.py
new file mode 100644
index 0000000..444c0c1
--- /dev/null
+++ b/kiosk/config.py
@@ -0,0 +1,16 @@
+"""
+Delade konfigurationskonstanter för kiosk-appen.
+Importeras av app.py och addons som behöver dessa värden.
+"""
+from pathlib import Path
+
+ALLOWED_ROOTS = [Path('/home/mrfox'), Path('/media'), Path('/mnt')]
+
+MEDIA_EXTENSIONS = {
+    '.mp4', '.webm', '.avi', '.mkv', '.mov',
+    '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp',
+    '.html', '.htm',
+    '.pdf', '.ppt', '.pptx', '.odp',
+}
+
+UPLOAD_DIR = Path('/home/mrfox/codes/display/kiosk/uploads')
diff --git a/kiosk/db.py b/kiosk/db.py
index 36e0480..6492fe4 100644
--- a/kiosk/db.py
+++ b/kiosk/db.py
@@ -156,3 +156,31 @@ def get_state():
     with get_conn() as conn:
         rows = conn.execute("SELECT key, value FROM state").fetchall()
         return {r['key']: r['value'] for r in rows}
+
+
+class AddonDB:
+    """
+    Scoped DB-access för addons.
+    Alla nycklar prefixas automatiskt med addon-prefixet
+    så att addons inte kan råka skriva utanför sin namnrymd.
+
+    Exempel:
+        _db = AddonDB('ticker_')
+        _db.set('enabled', '1')   # sparar 'ticker_enabled'
+        _db.get('enabled')        # läser 'ticker_enabled'
+    """
+
+    def __init__(self, prefix: str):
+        self._prefix = prefix
+
+    def get(self, key: str, default=None):
+        return get_state().get(self._prefix + key, default)
+
+    def set(self, key: str, value):
+        set_state(self._prefix + key, str(value))
+
+    def get_all(self) -> dict:
+        """Returnerar alla värden för detta prefix, utan prefixet i nyckelnamnen."""
+        state = get_state()
+        pfx = self._prefix
+        return {k[len(pfx):]: v for k, v in state.items() if k.startswith(pfx)}
diff --git a/kiosk/extensions.py b/kiosk/extensions.py
new file mode 100644
index 0000000..b3c5b78
--- /dev/null
+++ b/kiosk/extensions.py
@@ -0,0 +1,7 @@
+"""
+Delade Flask-extensions.
+app.py sätter `socketio` direkt efter att objektet skapats.
+Addons importerar denna modul och använder t.ex. `extensions.socketio.emit(...)`.
+"""
+
+socketio = None
diff --git a/kiosk/static/addons/.gitkeep b/kiosk/static/addons/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/kiosk/static/addons/sleep/admin.js b/kiosk/static/addons/sleep/admin.js
new file mode 100644
index 0000000..1313daf
--- /dev/null
+++ b/kiosk/static/addons/sleep/admin.js
@@ -0,0 +1,25 @@
+async function laddaSovlage() {
+  const res = await apiFetch('/api/sleep');
+  if (!res) return;
+  const d = await res.json();
+  document.getElementById('sleep-enabled').checked = !!d.enabled;
+  document.getElementById('sleep-start').value     = d.start || '22:00';
+  document.getElementById('sleep-end').value       = d.end   || '07:00';
+}
+
+async function sparaSovlage() {
+  const status = document.getElementById('sleep-status');
+  status.textContent = 'Sparar...';
+  const res = await apiFetch('/api/sleep', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      enabled: document.getElementById('sleep-enabled').checked,
+      start:   document.getElementById('sleep-start').value,
+      end:     document.getElementById('sleep-end').value,
+    }),
+  });
+  if (!res) return;
+  status.textContent = '✓ Sparat';
+  setTimeout(() => { status.textContent = ''; }, 3000);
+}
diff --git a/kiosk/templates/addons/bluetooth/panel.html b/kiosk/templates/addons/bluetooth/panel.html
new file mode 100644
index 0000000..cb6793b
--- /dev/null
+++ b/kiosk/templates/addons/bluetooth/panel.html
@@ -0,0 +1,34 @@
+    <!-- Sektion: Bluetooth -->
+    <section>
+      <h2>Bluetooth</h2>
+
+      <!-- Adapterstatus -->
+      <div id="bt-status-box" class="prop-grid" style="margin-bottom:1rem">
+        <label>Adapter</label>
+        <div style="display:flex;align-items:center;gap:.6rem;flex-wrap:wrap">
+          <span id="bt-status-dot" style="display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:#555"></span>
+          <span id="bt-adapter-name" style="font-size:.9rem">Laddar...</span>
+          <span id="bt-adapter-addr" style="font-size:.78rem;color:#555;font-family:monospace"></span>
+        </div>
+      </div>
+
+      <div class="row" style="margin-bottom:1rem">
+        <button onclick="btScan()" id="bt-scan-btn">Skanna enheter</button>
+        <button class="secondary" id="bt-power-btn" onclick="btTogglePower()">Stäng av adapter</button>
+        <span id="bt-scan-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+
+      <!-- Skannrade enheter -->
+      <div id="bt-found-wrap" style="display:none;margin-bottom:1.25rem">
+        <h3>Hittade enheter</h3>
+        <div id="bt-found-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem"></div>
+      </div>
+
+      <!-- Kopplade / kända enheter -->
+      <div id="bt-known-wrap">
+        <h3>Kopplade enheter</h3>
+        <div id="bt-known-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem;font-size:.9rem">
+          <span style="color:#555;font-style:italic">Inga kopplade enheter</span>
+        </div>
+      </div>
+    </section>
diff --git a/kiosk/templates/addons/clock/panel.html b/kiosk/templates/addons/clock/panel.html
new file mode 100644
index 0000000..751b194
--- /dev/null
+++ b/kiosk/templates/addons/clock/panel.html
@@ -0,0 +1,58 @@
+    <!-- Sektion: Klocka -->
+    <section>
+      <h2>Klocka</h2>
+
+      <div class="prop-grid">
+        <label>Stil</label>
+        <select id="clock-style">
+          <option value="digital">Digital</option>
+          <option value="analog">Analog</option>
+        </select>
+
+        <label>Datum</label>
+        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+          <input type="checkbox" id="clock-show-date">
+          Visa datum under klockan
+        </label>
+
+        <label>Position</label>
+        <div style="display:flex;gap:.4rem;flex-wrap:wrap">
+          <button type="button" id="clkpos-top-left"     class="secondary clock-pos-btn" onclick="setClockPos('top-left')">↖ Övre vänster</button>
+          <button type="button" id="clkpos-top-right"    class="secondary clock-pos-btn" onclick="setClockPos('top-right')">↗ Övre höger</button>
+          <button type="button" id="clkpos-bottom-left"  class="secondary clock-pos-btn" onclick="setClockPos('bottom-left')">↙ Nedre vänster</button>
+          <button type="button" id="clkpos-bottom-right" class="secondary clock-pos-btn" onclick="setClockPos('bottom-right')">↘ Nedre höger</button>
+          <button type="button" id="clkpos-center"       class="secondary clock-pos-btn" onclick="setClockPos('center')">⊙ Centrerad</button>
+        </div>
+
+        <label>Storlek</label>
+        <div class="row">
+          <span style="color:#555;font-size:.8rem;white-space:nowrap">Liten</span>
+          <input type="range" id="clock-size" min="1" max="5" step="0.5" value="2"
+                 oninput="document.getElementById('clock-size-val').textContent=this.value">
+          <span style="color:#555;font-size:.8rem;white-space:nowrap">Stor</span>
+          <span id="clock-size-val" style="color:#aaa;min-width:2rem;text-align:right">2</span>
+        </div>
+
+        <label>Färg</label>
+        <input type="color" id="clock-color" value="#ffffff">
+
+        <label>Opacitet</label>
+        <div class="row">
+          <input type="range" id="clock-opacity" min="10" max="100" step="5" value="90"
+                 oninput="document.getElementById('clock-opacity-val').textContent=this.value+'%'">
+          <span id="clock-opacity-val" style="color:#aaa;min-width:3rem">90%</span>
+        </div>
+
+        <label>Schema</label>
+        <select id="clock-schedule-mode">
+          <option value="always">Visa alltid</option>
+          <option value="scheduled_only">Bara under schemalagd visning</option>
+        </select>
+      </div>
+
+      <div class="row" style="margin-top:.75rem">
+        <button onclick="aktiveraClock()">Aktivera</button>
+        <button class="secondary" onclick="avaktiveraClock()">Stäng av</button>
+        <span id="clock-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+    </section>
diff --git a/kiosk/templates/addons/livechat/panel.html b/kiosk/templates/addons/livechat/panel.html
new file mode 100644
index 0000000..19479e6
--- /dev/null
+++ b/kiosk/templates/addons/livechat/panel.html
@@ -0,0 +1,86 @@
+    <!-- Sektion: Livechat -->
+    <section>
+      <h2>Livechat</h2>
+
+      <h3>Kiosk Livechat</h3>
+      <p style="font-size:.85rem;color:#555;margin-bottom:.75rem">Inbyggd chat — besökare deltar via QR-kod på sin mobil. Kan visas som helskärm (schema-typ "Kiosk Livechat") eller som overlay ovanpå annat innehåll.</p>
+
+      <div class="prop-grid">
+        <label>Overlay</label>
+        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+          <input type="checkbox" id="kiosk-chat-enabled">
+          Visa kiosk-chat som overlay ovanpå annat innehåll
+        </label>
+
+        <label>Position</label>
+        <select id="kiosk-chat-position">
+          <option value="right">Höger sida</option>
+          <option value="left">Vänster sida</option>
+        </select>
+
+        <label>Bredd</label>
+        <div class="row">
+          <input type="range" id="kiosk-chat-width" min="10" max="60" step="1" value="30"
+                 oninput="document.getElementById('kiosk-chat-width-val').textContent=this.value+'%'">
+          <span id="kiosk-chat-width-val" style="color:#aaa;min-width:3rem">30%</span>
+        </div>
+
+        <label>Höjd</label>
+        <div class="row">
+          <input type="range" id="kiosk-chat-height" min="30" max="100" step="5" value="80"
+                 oninput="document.getElementById('kiosk-chat-height-val').textContent=this.value+'%'">
+          <span id="kiosk-chat-height-val" style="color:#aaa;min-width:3rem">80%</span>
+        </div>
+      </div>
+
+      <div class="row" style="margin-bottom:1.25rem">
+        <button onclick="aktiveraChatOverlay('kiosk')">Aktivera overlay</button>
+        <button class="secondary" onclick="avaktiveraChatOverlay('kiosk')">Stäng av overlay</button>
+        <span id="kiosk-chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+
+      <h3>YouTube Livechat</h3>
+      <p style="font-size:.85rem;color:#555;margin-bottom:1rem">Visas automatiskt som en overlay när ett YouTube-liveflöde spelas. Döljs vid annat innehåll.</p>
+
+      <div class="prop-grid">
+        <label>Position</label>
+        <select id="chat-position">
+          <option value="right">Höger sida</option>
+          <option value="left">Vänster sida</option>
+        </select>
+
+        <label>Bredd</label>
+        <div class="row">
+          <input type="range" id="chat-width-pct" min="5" max="60" step="1" value="25"
+                 oninput="document.getElementById('chat-width-pct-val').textContent=this.value+'%'">
+          <span id="chat-width-pct-val" style="color:#aaa;min-width:3rem">25%</span>
+        </div>
+
+        <label>Höjd</label>
+        <div class="row">
+          <input type="range" id="chat-height" min="30" max="100" step="5" value="100"
+                 oninput="document.getElementById('chat-height-val').textContent=this.value+'%'">
+          <span id="chat-height-val" style="color:#aaa;min-width:3rem">100%</span>
+        </div>
+      </div>
+
+      <h3>Bakgrund</h3>
+      <div class="prop-grid">
+        <label>Färg</label>
+        <input type="color" id="chat-bg-color" value="#000000">
+
+        <label>Transparens</label>
+        <div class="row">
+          <input type="range" id="chat-bg-opacity" min="0" max="100" step="5" value="70"
+                 oninput="document.getElementById('chat-bg-opacity-val').textContent=this.value+'%'">
+          <span id="chat-bg-opacity-val" style="color:#aaa;min-width:3rem">70%</span>
+        </div>
+      </div>
+
+      <div class="row">
+        <button onclick="aktiveraChatOverlay('yt')">Aktivera</button>
+        <button class="secondary" onclick="avaktiveraChatOverlay('yt')">Stäng av</button>
+        <button id="chat-theme-btn" class="secondary">Chatt: Auto</button>
+        <span id="chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+    </section>
diff --git a/kiosk/templates/addons/music/panel.html b/kiosk/templates/addons/music/panel.html
new file mode 100644
index 0000000..b3c0131
--- /dev/null
+++ b/kiosk/templates/addons/music/panel.html
@@ -0,0 +1,67 @@
+    <!-- Sektion: Musik -->
+    <section>
+      <h2>Musik</h2>
+      <p style="font-size:.85rem;color:#555;margin-bottom:.75rem">Spelar musik i bakgrunden oberoende av schemat. Fungerar med lokala MP3-filer (mapp) eller en YouTube-spellista. En "Spelar nu"-overlay kan visas på displayen.</p>
+
+      <div class="prop-grid">
+        <label>Källa</label>
+        <select id="music-source-type" onchange="musicSourceTypeChanged()">
+          <option value="local">Lokal mapp med MP3-filer</option>
+          <option value="youtube">YouTube-spellista (ljud)</option>
+        </select>
+
+        <label id="music-local-label">Mapp</label>
+        <div id="music-local-wrap" class="row">
+          <input id="music-source-local" type="text" placeholder="/home/mrfox/musik" style="flex:1">
+          <button type="button" class="secondary" onclick="openMusicFolderBrowser()">Bläddra</button>
+        </div>
+
+        <label id="music-yt-label" style="display:none">YouTube-URL</label>
+        <div id="music-yt-wrap" style="display:none">
+          <div style="display:flex;gap:.5rem;align-items:center">
+            <input id="music-source-youtube" type="url" placeholder="https://www.youtube.com/playlist?list=..." style="flex:1">
+            <button type="button" class="secondary" onclick="extractMusicPlaylist()" id="music-extract-btn">Extrahera</button>
+          </div>
+          <div id="music-extract-status" style="font-size:.75rem;color:#444;margin-top:.3rem">Ange en publik YouTube-spellista och klicka Extrahera för att hämta låtarna.</div>
+        </div>
+
+        <label>Volym</label>
+        <div class="row">
+          <input type="range" id="music-volume" min="0" max="100" step="5" value="50"
+                 oninput="document.getElementById('music-volume-val').textContent=this.value+'%'">
+          <span id="music-volume-val" style="color:#aaa;min-width:3rem">50%</span>
+        </div>
+
+        <label>Blanda</label>
+        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+          <input type="checkbox" id="music-shuffle">
+          Spela i slumpmässig ordning
+        </label>
+
+        <label>Overlay</label>
+        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+          <input type="checkbox" id="music-show-overlay" checked>
+          Visa "Spelar nu"-overlay på displayen
+        </label>
+
+        <label>Overlay-position</label>
+        <select id="music-overlay-position">
+          <option value="bottom-left">Nedre vänster</option>
+          <option value="bottom-right">Nedre höger</option>
+          <option value="top-left">Övre vänster</option>
+          <option value="top-right">Övre höger</option>
+        </select>
+      </div>
+
+      <!-- Spårlista (lokal) -->
+      <div id="music-track-list-wrap" style="display:none; margin:.75rem 0;">
+        <h3 style="margin-bottom:.4rem">Hittade spår</h3>
+        <div id="music-track-list" style="font-size:.8rem;color:#888;max-height:160px;overflow-y:auto;background:#111;border-radius:6px;padding:.5rem .75rem;line-height:1.7;"></div>
+      </div>
+
+      <div class="row" style="margin-top:.75rem">
+        <button onclick="setMusik(true)">Aktivera</button>
+        <button class="secondary" onclick="setMusik(false)">Stäng av</button>
+        <span id="music-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+    </section>
diff --git a/kiosk/templates/addons/settings/panel.html b/kiosk/templates/addons/settings/panel.html
new file mode 100644
index 0000000..a4c70df
--- /dev/null
+++ b/kiosk/templates/addons/settings/panel.html
@@ -0,0 +1,48 @@
+    <!-- Sektion: Inställningar -->
+    <section>
+      <h2>Inställningar</h2>
+
+      <h3>Ljud</h3>
+      <div class="prop-grid">
+        <label>Volym</label>
+        <div class="row">
+          <input id="setting-volume" type="range" min="0" max="100" step="1" style="flex:1"
+                 oninput="document.getElementById('volume-val').textContent=this.value+'%'">
+          <span id="volume-val" style="color:#aaa;min-width:3rem;text-align:right">—</span>
+          <button onclick="setVolym()">Sätt</button>
+        </div>
+
+        <label>Ljudprofil</label>
+        <select id="setting-audio"></select>
+      </div>
+
+      <h3>Display</h3>
+      <div class="prop-grid">
+        <label>Intern display</label>
+        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+          <input type="checkbox" id="setting-internal-display" checked onchange="setInternDisplay(this.checked)">
+          Aktivera eDP-1 med admin-gränssnitt
+        </label>
+
+        <label>HDMI-utgång</label>
+        <select id="setting-display">
+          <option value="last">Sista anslutna (standard)</option>
+          <option value="first">Första anslutna</option>
+        </select>
+
+        <label>Upplösning</label>
+        <div style="display:flex;gap:.5rem;align-items:center">
+          <select id="setting-display-mode" style="flex:1">
+            <option value="">(Ingen / lämna tomt)</option>
+          </select>
+          <input id="setting-display-mode-custom" type="text" placeholder="Ex: 1920x1080" style="display:none;min-width:10rem">
+        </div>
+      </div>
+      <div id="displays-info" style="font-size:.82rem;color:#555;margin:.2rem 0 .75rem"></div>
+
+      <div class="row">
+        <button onclick="sparaInstallningar()">Spara inställningar</button>
+        <button class="secondary" onclick="applyDisplayNow()">Tillämpa upplösning nu</button>
+        <span id="settings-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+    </section>
diff --git a/kiosk/templates/addons/sleep/panel.html b/kiosk/templates/addons/sleep/panel.html
new file mode 100644
index 0000000..372d5c9
--- /dev/null
+++ b/kiosk/templates/addons/sleep/panel.html
@@ -0,0 +1,21 @@
+    <!-- Sektion: Sovläge -->
+    <section>
+      <h2>Sovläge</h2>
+      <div class="prop-grid">
+        <label>Aktivera</label>
+        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+          <input type="checkbox" id="sleep-enabled">
+          Täck displayen med svart skärm under angiven tid
+        </label>
+
+        <label>Sov från</label>
+        <input type="time" id="sleep-start" value="22:00" style="max-width:9rem">
+
+        <label>Vakna kl</label>
+        <input type="time" id="sleep-end" value="07:00" style="max-width:9rem">
+      </div>
+      <div class="row" style="margin-top:.75rem">
+        <button onclick="sparaSovlage()">Spara</button>
+        <span id="sleep-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+    </section>
diff --git a/kiosk/templates/addons/system/panel.html b/kiosk/templates/addons/system/panel.html
new file mode 100644
index 0000000..e9b4ded
--- /dev/null
+++ b/kiosk/templates/addons/system/panel.html
@@ -0,0 +1,9 @@
+    <!-- Sektion: System -->
+    <section>
+      <h2>System</h2>
+      <div class="row">
+        <button class="secondary" onclick="startaOmProgram()">Starta om programmet</button>
+        <button class="danger" onclick="startaOmSystemet()">Starta om datorn</button>
+        <button class="danger" onclick="stangAvDatorn()">Stäng av datorn</button>
+      </div>
+    </section>
diff --git a/kiosk/templates/addons/ticker/panel.html b/kiosk/templates/addons/ticker/panel.html
new file mode 100644
index 0000000..c593dab
--- /dev/null
+++ b/kiosk/templates/addons/ticker/panel.html
@@ -0,0 +1,149 @@
+    <!-- Sektion: Löptext -->
+    <section>
+      <h2>Löptext</h2>
+
+      <div class="prop-grid">
+        <label>Källa</label>
+        <select id="ticker-source" onchange="tickerSourceChanged()">
+          <option value="text">Text</option>
+          <option value="rss">RSS-flöde</option>
+        </select>
+
+        <label id="ticker-text-label">Text</label>
+        <div id="ticker-text-wrap">
+          <textarea id="ticker-text" rows="4" placeholder="Välkommen! Öppet 10–18... 🎉&#10;Rad 2 scrollas efter rad 1&#10;Rad 3..." style="width:100%;resize:vertical;font-family:inherit;line-height:1.5"></textarea>
+          <div class="bbcode-bar">
+            <button type="button" class="secondary bb-btn" onclick="insertBB('b')" title="Fet"><b>B</b></button>
+            <button type="button" class="secondary bb-btn" onclick="insertBB('i')" title="Kursiv"><i>I</i></button>
+            <button type="button" class="secondary bb-btn" onclick="insertBB('u')" title="Understruken"><u>U</u></button>
+            <button type="button" class="secondary bb-btn" onclick="insertBB('s')" title="Genomstruken"><s>S</s></button>
+            <span class="bb-sep"></span>
+            <input type="color" id="bb-color-val" value="#ffff00" title="Välj färg">
+            <button type="button" class="secondary bb-btn" onclick="insertBBColor()" title="Infoga färgtagg">[color]</button>
+            <input type="number" id="bb-size-val" value="48" min="8" max="300">
+            <button type="button" class="secondary bb-btn" onclick="insertBBSize()" title="Infoga storlekstagg">[size]</button>
+            <span class="bb-sep"></span>
+            <button type="button" class="secondary bb-btn" onclick="insertBB('shadow')" title="Textskugga">Skugga</button>
+            <button type="button" class="secondary bb-btn" onclick="insertBBGlow()" title="Glödeffekt (använder vald färg)">Glöd</button>
+          </div>
+          <div style="font-size:.75rem;color:#444;margin-top:.35rem">
+            Varje rad scrollas en i taget. Radbryt med Enter eller [br]. BBCode: [b]fet[/b] &nbsp;[color=#ff0000]röd[/color] &nbsp;[size=64]stor[/size] &nbsp;[shadow]skugga[/shadow]
+          </div>
+        </div>
+
+        <label id="ticker-rss-label" style="display:none">RSS-URL</label>
+        <div id="ticker-rss-wrap" style="display:none">
+          <input id="ticker-rss-url" type="url" placeholder="https://feeds.example.com/rss" style="width:100%">
+          <div style="font-size:.75rem;color:#444;margin-top:.35rem">
+            Stöder RSS 2.0 och Atom. Rubrikerna hämtas och scrollas en i taget.
+          </div>
+        </div>
+
+        <label id="ticker-rss-interval-label" style="display:none">Uppdatera</label>
+        <select id="ticker-rss-interval" style="display:none">
+          <option value="1">Varje minut</option>
+          <option value="5">Var 5:e minut</option>
+          <option value="10" selected>Var 10:e minut</option>
+          <option value="30">Var 30:e minut</option>
+          <option value="60">Varje timme</option>
+        </select>
+
+        <label>Position</label>
+        <select id="ticker-position">
+          <option value="bottom">Nederkant</option>
+          <option value="top">Överkant</option>
+        </select>
+
+        <label>Hastighet</label>
+        <div class="row">
+          <span style="color:#555;font-size:.8rem;white-space:nowrap">Långsam</span>
+          <input type="range" id="ticker-pps" min="30" max="800" step="10" value="150"
+                 oninput="document.getElementById('ticker-pps-val').textContent=this.value+' px/s'">
+          <span style="color:#555;font-size:.8rem;white-space:nowrap">Snabb</span>
+          <span id="ticker-pps-val" style="color:#aaa;min-width:5rem;text-align:right">150 px/s</span>
+        </div>
+
+        <label>Scrolläge</label>
+        <select id="ticker-mode" onchange="tickerModeChanged()">
+          <option value="sequential">En rad i taget</option>
+          <option value="continuous">Löpande (kontinuerligt)</option>
+        </select>
+
+        <label id="ticker-sep-label" style="display:none">Separator</label>
+        <div id="ticker-sep-wrap" style="display:none" class="row">
+          <input id="ticker-separator" type="text" value="◆" style="max-width:8rem">
+          <span style="color:#555;font-size:.8rem">visas mellan rader, t.ex. ◆ • | 🔸</span>
+        </div>
+      </div>
+
+      <h3>Textstil</h3>
+      <div class="prop-grid">
+        <label>Färg</label>
+        <div class="row">
+          <input type="color" id="ticker-text-color" value="#ffffff">
+          <span style="color:#aaa;font-size:.85rem">Standard (överskrivs av [color]-taggar)</span>
+        </div>
+
+        <label>Storlek</label>
+        <div class="row">
+          <input type="range" id="ticker-font-size" min="0.8" max="5" step="0.1" value="2"
+                 oninput="document.getElementById('ticker-font-size-val').textContent=parseFloat(this.value).toFixed(1)+'rem'">
+          <span id="ticker-font-size-val" style="color:#aaa;min-width:4rem">2.0rem</span>
+        </div>
+
+        <label>Typsnitt</label>
+        <select id="ticker-font-family">
+          <optgroup label="System">
+            <option value="sans-serif">System sans-serif</option>
+            <option value="serif">System serif</option>
+            <option value="monospace">Monospace</option>
+          </optgroup>
+          <optgroup label="Webb">
+            <option value="Arial, sans-serif">Arial</option>
+            <option value="Georgia, serif">Georgia</option>
+            <option value="Impact, sans-serif">Impact</option>
+            <option value="'Courier New', monospace">Courier New</option>
+            <option value="'Times New Roman', serif">Times New Roman</option>
+          </optgroup>
+          <optgroup label="Kod (apt: fonts-cascadia-code)">
+            <option value="'Cascadia Code', monospace">Cascadia Code</option>
+          </optgroup>
+          <optgroup label="Nerd Fonts (installeras separat)">
+            <option value="'JetBrainsMono Nerd Font', monospace">JetBrainsMono NF</option>
+            <option value="'FiraCode Nerd Font', monospace">FiraCode NF</option>
+            <option value="'Hack Nerd Font', monospace">Hack NF</option>
+            <option value="'Inconsolata Nerd Font', monospace">Inconsolata NF</option>
+            <option value="'CaskaydiaCove Nerd Font', monospace">CaskaydiaCove NF</option>
+            <option value="'RobotoMono Nerd Font', monospace">RobotoMono NF</option>
+            <option value="'MesloLGS NF', monospace">MesloLGS NF</option>
+            <option value="'DejaVuSansMono Nerd Font', monospace">DejaVuSansMono NF</option>
+          </optgroup>
+        </select>
+      </div>
+
+      <h3>Tickerrad</h3>
+      <div class="prop-grid">
+        <label>Bakgrundsfärg</label>
+        <input type="color" id="ticker-bar-color" value="#000000">
+
+        <label>Opacitet</label>
+        <div class="row">
+          <input type="range" id="ticker-bar-opacity" min="0" max="100" step="5" value="72"
+                 oninput="document.getElementById('ticker-bar-opacity-val').textContent=this.value+'%'">
+          <span id="ticker-bar-opacity-val" style="color:#aaa;min-width:3rem">72%</span>
+        </div>
+
+        <label>Höjd</label>
+        <div class="row">
+          <input type="range" id="ticker-bar-padding" min="0.1" max="1.5" step="0.05" value="0.45"
+                 oninput="document.getElementById('ticker-bar-padding-val').textContent=parseFloat(this.value).toFixed(2)+'rem'">
+          <span id="ticker-bar-padding-val" style="color:#aaa;min-width:4rem">0.45rem</span>
+        </div>
+      </div>
+
+      <div class="row">
+        <button onclick="aktiveraTicker()">Aktivera</button>
+        <button class="secondary" onclick="avaktriveraTicker()">Stäng av</button>
+        <span id="ticker-status" style="color:#aaa;font-size:0.85rem;align-self:center"></span>
+      </div>
+    </section>
diff --git a/kiosk/templates/addons/wifi/panel.html b/kiosk/templates/addons/wifi/panel.html
new file mode 100644
index 0000000..a8bf322
--- /dev/null
+++ b/kiosk/templates/addons/wifi/panel.html
@@ -0,0 +1,63 @@
+    <!-- Sektion: Wi-Fi -->
+    <section>
+      <h2>Wi-Fi</h2>
+
+      <!-- Status -->
+      <div id="wifi-status-box" class="prop-grid" style="margin-bottom:1rem">
+        <label>Status</label>
+        <div>
+          <span id="wifi-status-dot" style="display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:#555;margin-right:.4rem;vertical-align:middle"></span>
+          <span id="wifi-status-text" style="font-size:.9rem">Laddar...</span>
+        </div>
+        <label>IP</label>
+        <span id="wifi-ip" style="font-size:.9rem;font-family:monospace">—</span>
+        <label>Signal</label>
+        <div style="display:flex;align-items:center;gap:.5rem">
+          <div id="wifi-signal-bar" style="display:flex;align-items:flex-end;gap:2px;height:14px">
+            <span style="width:4px;border-radius:1px;background:#333;height:4px"></span>
+            <span style="width:4px;border-radius:1px;background:#333;height:7px"></span>
+            <span style="width:4px;border-radius:1px;background:#333;height:11px"></span>
+            <span style="width:4px;border-radius:1px;background:#333;height:14px"></span>
+          </div>
+          <span id="wifi-signal-val" style="color:#aaa;font-size:.8rem">—</span>
+        </div>
+      </div>
+
+      <div class="row" style="margin-bottom:1rem">
+        <button onclick="wifiScan()" id="wifi-scan-btn">Skanna nätverk</button>
+        <button class="secondary" onclick="wifiDisconnect()">Koppla från</button>
+        <span id="wifi-scan-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+
+      <!-- Nätverkslista -->
+      <div id="wifi-networks" style="display:none;margin-bottom:1.25rem">
+        <h3>Tillgängliga nätverk</h3>
+        <div id="wifi-network-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem"></div>
+      </div>
+
+      <!-- Sparade nätverk -->
+      <div id="wifi-saved-wrap">
+        <h3>Sparade nätverk</h3>
+        <div id="wifi-saved-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem;font-size:.9rem"></div>
+      </div>
+    </section>
+
+    <!-- Wi-Fi lösenordsmodal -->
+    <div id="wifi-pw-modal" class="modal-overlay" style="display:none" onclick="wifiModalClick(event)">
+      <div class="modal">
+        <div class="modal-header">
+          <strong id="wifi-pw-title">Anslut till nätverk</strong>
+          <button class="secondary" onclick="closeWifiModal()">✕</button>
+        </div>
+        <div style="padding:1rem">
+          <div class="prop-grid">
+            <label>Lösenord</label>
+            <input id="wifi-pw-input" type="password" placeholder="Lämna tomt om öppet nätverk" style="width:100%" onkeydown="if(event.key==='Enter')wifiDoConnect()">
+          </div>
+          <div class="row" style="margin-top:.75rem">
+            <button onclick="wifiDoConnect()" id="wifi-connect-btn">Anslut</button>
+            <span id="wifi-connect-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+          </div>
+        </div>
+      </div>
+    </div>
diff --git a/kiosk/templates/admin.html b/kiosk/templates/admin.html
index 41cd947..7eb40c4 100644
--- a/kiosk/templates/admin.html
+++ b/kiosk/templates/admin.html
@@ -13,7 +13,7 @@
     <!-- Föreslagna uppspelningar från chatten -->
     <div id="play-suggestions" style="display:none; margin-bottom:.75rem; flex-direction:column; gap:.4rem;"></div>

-    <!-- Sektion 1: Visa nu -->
+    <!-- Sektion: Visa nu -->
     <section>
       <h2>Visa nu</h2>

@@ -39,7 +39,7 @@
       </div>
     </section>

-    <!-- Sektion 2: Schema -->
+    <!-- Sektion: Schema -->
     <section>
       <h2>Schema</h2>

@@ -94,7 +94,7 @@
           <button type="submit">Lägg till</button>
         </div>
       </form>
-
+
       <!-- Edit modal (re-uses job form fields) -->
       <div id="edit-modal" class="modal-overlay" style="display:none" onclick="editModalClick(event)">
         <div class="modal">
@@ -130,551 +130,12 @@
       </div>
     </section>

-    <!-- Sektion 3: Ticker -->
-    <section>
-      <h2>Löptext</h2>
-
-      <div class="prop-grid">
-        <label>Källa</label>
-        <select id="ticker-source" onchange="tickerSourceChanged()">
-          <option value="text">Text</option>
-          <option value="rss">RSS-flöde</option>
-        </select>
-
-        <label id="ticker-text-label">Text</label>
-        <div id="ticker-text-wrap">
-          <textarea id="ticker-text" rows="4" placeholder="Välkommen! Öppet 10–18... 🎉&#10;Rad 2 scrollas efter rad 1&#10;Rad 3..." style="width:100%;resize:vertical;font-family:inherit;line-height:1.5"></textarea>
-          <div class="bbcode-bar">
-            <button type="button" class="secondary bb-btn" onclick="insertBB('b')" title="Fet"><b>B</b></button>
-            <button type="button" class="secondary bb-btn" onclick="insertBB('i')" title="Kursiv"><i>I</i></button>
-            <button type="button" class="secondary bb-btn" onclick="insertBB('u')" title="Understruken"><u>U</u></button>
-            <button type="button" class="secondary bb-btn" onclick="insertBB('s')" title="Genomstruken"><s>S</s></button>
-            <span class="bb-sep"></span>
-            <input type="color" id="bb-color-val" value="#ffff00" title="Välj färg">
-            <button type="button" class="secondary bb-btn" onclick="insertBBColor()" title="Infoga färgtagg">[color]</button>
-            <input type="number" id="bb-size-val" value="48" min="8" max="300">
-            <button type="button" class="secondary bb-btn" onclick="insertBBSize()" title="Infoga storlekstagg">[size]</button>
-            <span class="bb-sep"></span>
-            <button type="button" class="secondary bb-btn" onclick="insertBB('shadow')" title="Textskugga">Skugga</button>
-            <button type="button" class="secondary bb-btn" onclick="insertBBGlow()" title="Glödeffekt (använder vald färg)">Glöd</button>
-          </div>
-          <div style="font-size:.75rem;color:#444;margin-top:.35rem">
-            Varje rad scrollas en i taget. Radbryt med Enter eller [br]. BBCode: [b]fet[/b] &nbsp;[color=#ff0000]röd[/color] &nbsp;[size=64]stor[/size] &nbsp;[shadow]skugga[/shadow]
-          </div>
-        </div>
-
-        <label id="ticker-rss-label" style="display:none">RSS-URL</label>
-        <div id="ticker-rss-wrap" style="display:none">
-          <input id="ticker-rss-url" type="url" placeholder="https://feeds.example.com/rss" style="width:100%">
-          <div style="font-size:.75rem;color:#444;margin-top:.35rem">
-            Stöder RSS 2.0 och Atom. Rubrikerna hämtas och scrollas en i taget.
-          </div>
-        </div>
-
-        <label id="ticker-rss-interval-label" style="display:none">Uppdatera</label>
-        <select id="ticker-rss-interval" style="display:none">
-          <option value="1">Varje minut</option>
-          <option value="5">Var 5:e minut</option>
-          <option value="10" selected>Var 10:e minut</option>
-          <option value="30">Var 30:e minut</option>
-          <option value="60">Varje timme</option>
-        </select>
-
-        <label>Position</label>
-        <select id="ticker-position">
-          <option value="bottom">Nederkant</option>
-          <option value="top">Överkant</option>
-        </select>
-
-        <label>Hastighet</label>
-        <div class="row">
-          <span style="color:#555;font-size:.8rem;white-space:nowrap">Långsam</span>
-          <input type="range" id="ticker-pps" min="30" max="800" step="10" value="150"
-                 oninput="document.getElementById('ticker-pps-val').textContent=this.value+' px/s'">
-          <span style="color:#555;font-size:.8rem;white-space:nowrap">Snabb</span>
-          <span id="ticker-pps-val" style="color:#aaa;min-width:5rem;text-align:right">150 px/s</span>
-        </div>
-
-        <label>Scrolläge</label>
-        <select id="ticker-mode" onchange="tickerModeChanged()">
-          <option value="sequential">En rad i taget</option>
-          <option value="continuous">Löpande (kontinuerligt)</option>
-        </select>
-
-        <label id="ticker-sep-label" style="display:none">Separator</label>
-        <div id="ticker-sep-wrap" style="display:none" class="row">
-          <input id="ticker-separator" type="text" value="◆" style="max-width:8rem">
-          <span style="color:#555;font-size:.8rem">visas mellan rader, t.ex. ◆ • | 🔸</span>
-        </div>
-      </div>
-
-      <h3>Textstil</h3>
-      <div class="prop-grid">
-        <label>Färg</label>
-        <div class="row">
-          <input type="color" id="ticker-text-color" value="#ffffff">
-          <span style="color:#aaa;font-size:.85rem">Standard (överskrivs av [color]-taggar)</span>
-        </div>
-
-        <label>Storlek</label>
-        <div class="row">
-          <input type="range" id="ticker-font-size" min="0.8" max="5" step="0.1" value="2"
-                 oninput="document.getElementById('ticker-font-size-val').textContent=parseFloat(this.value).toFixed(1)+'rem'">
-          <span id="ticker-font-size-val" style="color:#aaa;min-width:4rem">2.0rem</span>
-        </div>
-
-        <label>Typsnitt</label>
-        <select id="ticker-font-family">
-          <optgroup label="System">
-            <option value="sans-serif">System sans-serif</option>
-            <option value="serif">System serif</option>
-            <option value="monospace">Monospace</option>
-          </optgroup>
-          <optgroup label="Webb">
-            <option value="Arial, sans-serif">Arial</option>
-            <option value="Georgia, serif">Georgia</option>
-            <option value="Impact, sans-serif">Impact</option>
-            <option value="'Courier New', monospace">Courier New</option>
-            <option value="'Times New Roman', serif">Times New Roman</option>
-          </optgroup>
-          <optgroup label="Kod (apt: fonts-cascadia-code)">
-            <option value="'Cascadia Code', monospace">Cascadia Code</option>
-          </optgroup>
-          <optgroup label="Nerd Fonts (installeras separat)">
-            <option value="'JetBrainsMono Nerd Font', monospace">JetBrainsMono NF</option>
-            <option value="'FiraCode Nerd Font', monospace">FiraCode NF</option>
-            <option value="'Hack Nerd Font', monospace">Hack NF</option>
-            <option value="'Inconsolata Nerd Font', monospace">Inconsolata NF</option>
-            <option value="'CaskaydiaCove Nerd Font', monospace">CaskaydiaCove NF</option>
-            <option value="'RobotoMono Nerd Font', monospace">RobotoMono NF</option>
-            <option value="'MesloLGS NF', monospace">MesloLGS NF</option>
-            <option value="'DejaVuSansMono Nerd Font', monospace">DejaVuSansMono NF</option>
-          </optgroup>
-        </select>
-      </div>
-
-      <h3>Tickerrad</h3>
-      <div class="prop-grid">
-        <label>Bakgrundsfärg</label>
-        <input type="color" id="ticker-bar-color" value="#000000">
-
-        <label>Opacitet</label>
-        <div class="row">
-          <input type="range" id="ticker-bar-opacity" min="0" max="100" step="5" value="72"
-                 oninput="document.getElementById('ticker-bar-opacity-val').textContent=this.value+'%'">
-          <span id="ticker-bar-opacity-val" style="color:#aaa;min-width:3rem">72%</span>
-        </div>
-
-        <label>Höjd</label>
-        <div class="row">
-          <input type="range" id="ticker-bar-padding" min="0.1" max="1.5" step="0.05" value="0.45"
-                 oninput="document.getElementById('ticker-bar-padding-val').textContent=parseFloat(this.value).toFixed(2)+'rem'">
-          <span id="ticker-bar-padding-val" style="color:#aaa;min-width:4rem">0.45rem</span>
-        </div>
-      </div>
-
-      <div class="row">
-        <button onclick="aktiveraTicker()">Aktivera</button>
-        <button class="secondary" onclick="avaktriveraTicker()">Stäng av</button>
-        <span id="ticker-status" style="color:#aaa;font-size:0.85rem;align-self:center"></span>
-      </div>
-    </section>
-
-    <!-- Sektion 4: Chat -->
-    <section>
-      <h2>Livechat</h2>
-
-      <h3>Kiosk Livechat</h3>
-      <p style="font-size:.85rem;color:#555;margin-bottom:.75rem">Inbyggd chat — besökare deltar via QR-kod på sin mobil. Kan visas som helskärm (schema-typ "Kiosk Livechat") eller som overlay ovanpå annat innehåll.</p>
-
-      <div class="prop-grid">
-        <label>Overlay</label>
-        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
-          <input type="checkbox" id="kiosk-chat-enabled">
-          Visa kiosk-chat som overlay ovanpå annat innehåll
-        </label>
-
-        <label>Position</label>
-        <select id="kiosk-chat-position">
-          <option value="right">Höger sida</option>
-          <option value="left">Vänster sida</option>
-        </select>
-
-        <label>Bredd</label>
-        <div class="row">
-          <input type="range" id="kiosk-chat-width" min="10" max="60" step="1" value="30"
-                 oninput="document.getElementById('kiosk-chat-width-val').textContent=this.value+'%'">
-          <span id="kiosk-chat-width-val" style="color:#aaa;min-width:3rem">30%</span>
-        </div>
-
-        <label>Höjd</label>
-        <div class="row">
-          <input type="range" id="kiosk-chat-height" min="30" max="100" step="5" value="80"
-                 oninput="document.getElementById('kiosk-chat-height-val').textContent=this.value+'%'">
-          <span id="kiosk-chat-height-val" style="color:#aaa;min-width:3rem">80%</span>
-        </div>
-      </div>
-
-      <div class="row" style="margin-bottom:1.25rem">
-        <button onclick="aktiveraChatOverlay('kiosk')">Aktivera overlay</button>
-        <button class="secondary" onclick="avaktiveraChatOverlay('kiosk')">Stäng av overlay</button>
-        <span id="kiosk-chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
-      </div>
-
-      <h3>YouTube Livechat</h3>
-      <p style="font-size:.85rem;color:#555;margin-bottom:1rem">Visas automatiskt som en overlay när ett YouTube-liveflöde spelas. Döljs vid annat innehåll.</p>
-
-      <div class="prop-grid">
-        <label>Position</label>
-        <select id="chat-position">
-          <option value="right">Höger sida</option>
-          <option value="left">Vänster sida</option>
-        </select>
-
-        <label>Bredd</label>
-        <div class="row">
-          <input type="range" id="chat-width-pct" min="5" max="60" step="1" value="25"
-                 oninput="document.getElementById('chat-width-pct-val').textContent=this.value+'%'">
-          <span id="chat-width-pct-val" style="color:#aaa;min-width:3rem">25%</span>
-        </div>
-
-        <label>Höjd</label>
-        <div class="row">
-          <input type="range" id="chat-height" min="30" max="100" step="5" value="100"
-                 oninput="document.getElementById('chat-height-val').textContent=this.value+'%'">
-          <span id="chat-height-val" style="color:#aaa;min-width:3rem">100%</span>
-        </div>
-      </div>
-
-      <h3>Bakgrund</h3>
-      <div class="prop-grid">
-        <label>Färg</label>
-        <input type="color" id="chat-bg-color" value="#000000">
-
-        <label>Transparens</label>
-        <div class="row">
-          <input type="range" id="chat-bg-opacity" min="0" max="100" step="5" value="70"
-                 oninput="document.getElementById('chat-bg-opacity-val').textContent=this.value+'%'">
-          <span id="chat-bg-opacity-val" style="color:#aaa;min-width:3rem">70%</span>
-        </div>
-      </div>
-
-      <div class="row">
-        <button onclick="aktiveraChatOverlay('yt')">Aktivera</button>
-        <button class="secondary" onclick="avaktiveraChatOverlay('yt')">Stäng av</button>
-        <button id="chat-theme-btn" class="secondary">Chatt: Auto</button>
-        <span id="chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
-      </div>
-    </section>
-
-    <!-- Sektion 5: Klocka -->
-    <section>
-      <h2>Klocka</h2>
-
-      <div class="prop-grid">
-        <label>Stil</label>
-        <select id="clock-style">
-          <option value="digital">Digital</option>
-          <option value="analog">Analog</option>
-        </select>
-
-        <label>Datum</label>
-        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
-          <input type="checkbox" id="clock-show-date">
-          Visa datum under klockan
-        </label>
-
-        <label>Position</label>
-        <div style="display:flex;gap:.4rem;flex-wrap:wrap">
-          <button type="button" id="clkpos-top-left"     class="secondary clock-pos-btn" onclick="setClockPos('top-left')">↖ Övre vänster</button>
-          <button type="button" id="clkpos-top-right"    class="secondary clock-pos-btn" onclick="setClockPos('top-right')">↗ Övre höger</button>
-          <button type="button" id="clkpos-bottom-left"  class="secondary clock-pos-btn" onclick="setClockPos('bottom-left')">↙ Nedre vänster</button>
-          <button type="button" id="clkpos-bottom-right" class="secondary clock-pos-btn" onclick="setClockPos('bottom-right')">↘ Nedre höger</button>
-          <button type="button" id="clkpos-center"       class="secondary clock-pos-btn" onclick="setClockPos('center')">⊙ Centrerad</button>
-        </div>
-
-        <label>Storlek</label>
-        <div class="row">
-          <span style="color:#555;font-size:.8rem;white-space:nowrap">Liten</span>
-          <input type="range" id="clock-size" min="1" max="5" step="0.5" value="2"
-                 oninput="document.getElementById('clock-size-val').textContent=this.value">
-          <span style="color:#555;font-size:.8rem;white-space:nowrap">Stor</span>
-          <span id="clock-size-val" style="color:#aaa;min-width:2rem;text-align:right">2</span>
-        </div>
-
-        <label>Färg</label>
-        <input type="color" id="clock-color" value="#ffffff">
-
-        <label>Opacitet</label>
-        <div class="row">
-          <input type="range" id="clock-opacity" min="10" max="100" step="5" value="90"
-                 oninput="document.getElementById('clock-opacity-val').textContent=this.value+'%'">
-          <span id="clock-opacity-val" style="color:#aaa;min-width:3rem">90%</span>
-        </div>
-
-        <label>Schema</label>
-        <select id="clock-schedule-mode">
-          <option value="always">Visa alltid</option>
-          <option value="scheduled_only">Bara under schemalagd visning</option>
-        </select>
-      </div>
-
-      <div class="row" style="margin-top:.75rem">
-        <button onclick="aktiveraClock()">Aktivera</button>
-        <button class="secondary" onclick="avaktiveraClock()">Stäng av</button>
-        <span id="clock-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
-      </div>
-    </section>
-
-    <!-- Sektion 6: Musik -->
-    <section>
-      <h2>Musik</h2>
-      <p style="font-size:.85rem;color:#555;margin-bottom:.75rem">Spelar musik i bakgrunden oberoende av schemat. Fungerar med lokala MP3-filer (mapp) eller en YouTube-spellista. En "Spelar nu"-overlay kan visas på displayen.</p>
-
-      <div class="prop-grid">
-        <label>Källa</label>
-        <select id="music-source-type" onchange="musicSourceTypeChanged()">
-          <option value="local">Lokal mapp med MP3-filer</option>
-          <option value="youtube">YouTube-spellista (ljud)</option>
-        </select>
-
-        <label id="music-local-label">Mapp</label>
-        <div id="music-local-wrap" class="row">
-          <input id="music-source-local" type="text" placeholder="/home/mrfox/musik" style="flex:1">
-          <button type="button" class="secondary" onclick="openMusicFolderBrowser()">Bläddra</button>
-        </div>
-
-        <label id="music-yt-label" style="display:none">YouTube-URL</label>
-        <div id="music-yt-wrap" style="display:none">
-          <div style="display:flex;gap:.5rem;align-items:center">
-            <input id="music-source-youtube" type="url" placeholder="https://www.youtube.com/playlist?list=..." style="flex:1">
-            <button type="button" class="secondary" onclick="extractMusicPlaylist()" id="music-extract-btn">Extrahera</button>
-          </div>
-          <div id="music-extract-status" style="font-size:.75rem;color:#444;margin-top:.3rem">Ange en publik YouTube-spellista och klicka Extrahera för att hämta låtarna.</div>
-        </div>
-
-        <label>Volym</label>
-        <div class="row">
-          <input type="range" id="music-volume" min="0" max="100" step="5" value="50"
-                 oninput="document.getElementById('music-volume-val').textContent=this.value+'%'">
-          <span id="music-volume-val" style="color:#aaa;min-width:3rem">50%</span>
-        </div>
-
-        <label>Blanda</label>
-        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
-          <input type="checkbox" id="music-shuffle">
-          Spela i slumpmässig ordning
-        </label>
-
-        <label>Overlay</label>
-        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
-          <input type="checkbox" id="music-show-overlay" checked>
-          Visa "Spelar nu"-overlay på displayen
-        </label>
-
-        <label>Overlay-position</label>
-        <select id="music-overlay-position">
-          <option value="bottom-left">Nedre vänster</option>
-          <option value="bottom-right">Nedre höger</option>
-          <option value="top-left">Övre vänster</option>
-          <option value="top-right">Övre höger</option>
-        </select>
-      </div>
-
-      <!-- Spårlista (lokal) -->
-      <div id="music-track-list-wrap" style="display:none; margin:.75rem 0;">
-        <h3 style="margin-bottom:.4rem">Hittade spår</h3>
-        <div id="music-track-list" style="font-size:.8rem;color:#888;max-height:160px;overflow-y:auto;background:#111;border-radius:6px;padding:.5rem .75rem;line-height:1.7;"></div>
-      </div>
-
-      <div class="row" style="margin-top:.75rem">
-        <button onclick="setMusik(true)">Aktivera</button>
-        <button class="secondary" onclick="setMusik(false)">Stäng av</button>
-        <span id="music-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
-      </div>
-    </section>
-
-    <!-- Sektion 7: Sovläge -->
-    <section>
-      <h2>Sovläge</h2>
-      <div class="prop-grid">
-        <label>Aktivera</label>
-        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
-          <input type="checkbox" id="sleep-enabled">
-          Täck displayen med svart skärm under angiven tid
-        </label>
-
-        <label>Sov från</label>
-        <input type="time" id="sleep-start" value="22:00" style="max-width:9rem">
-
-        <label>Vakna kl</label>
-        <input type="time" id="sleep-end" value="07:00" style="max-width:9rem">
-      </div>
-      <div class="row" style="margin-top:.75rem">
-        <button onclick="sparaSovlage()">Spara</button>
-        <span id="sleep-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
-      </div>
-    </section>
-
-    <!-- Sektion: Wi-Fi -->
-    <section>
-      <h2>Wi-Fi</h2>
-
-      <!-- Status -->
-      <div id="wifi-status-box" class="prop-grid" style="margin-bottom:1rem">
-        <label>Status</label>
-        <div>
-          <span id="wifi-status-dot" style="display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:#555;margin-right:.4rem;vertical-align:middle"></span>
-          <span id="wifi-status-text" style="font-size:.9rem">Laddar...</span>
-        </div>
-        <label>IP</label>
-        <span id="wifi-ip" style="font-size:.9rem;font-family:monospace">—</span>
-        <label>Signal</label>
-        <div style="display:flex;align-items:center;gap:.5rem">
-          <div id="wifi-signal-bar" style="display:flex;align-items:flex-end;gap:2px;height:14px">
-            <span style="width:4px;border-radius:1px;background:#333;height:4px"></span>
-            <span style="width:4px;border-radius:1px;background:#333;height:7px"></span>
-            <span style="width:4px;border-radius:1px;background:#333;height:11px"></span>
-            <span style="width:4px;border-radius:1px;background:#333;height:14px"></span>
-          </div>
-          <span id="wifi-signal-val" style="color:#aaa;font-size:.8rem">—</span>
-        </div>
-      </div>
-
-      <div class="row" style="margin-bottom:1rem">
-        <button onclick="wifiScan()" id="wifi-scan-btn">Skanna nätverk</button>
-        <button class="secondary" onclick="wifiDisconnect()">Koppla från</button>
-        <span id="wifi-scan-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
-      </div>
-
-      <!-- Nätverkslista -->
-      <div id="wifi-networks" style="display:none;margin-bottom:1.25rem">
-        <h3>Tillgängliga nätverk</h3>
-        <div id="wifi-network-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem"></div>
-      </div>
-
-      <!-- Sparade nätverk -->
-      <div id="wifi-saved-wrap">
-        <h3>Sparade nätverk</h3>
-        <div id="wifi-saved-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem;font-size:.9rem"></div>
-      </div>
-    </section>
-
-    <!-- Wi-Fi lösenordsmodal -->
-    <div id="wifi-pw-modal" class="modal-overlay" style="display:none" onclick="wifiModalClick(event)">
-      <div class="modal">
-        <div class="modal-header">
-          <strong id="wifi-pw-title">Anslut till nätverk</strong>
-          <button class="secondary" onclick="closeWifiModal()">✕</button>
-        </div>
-        <div style="padding:1rem">
-          <div class="prop-grid">
-            <label>Lösenord</label>
-            <input id="wifi-pw-input" type="password" placeholder="Lämna tomt om öppet nätverk" style="width:100%" onkeydown="if(event.key==='Enter')wifiDoConnect()">
-          </div>
-          <div class="row" style="margin-top:.75rem">
-            <button onclick="wifiDoConnect()" id="wifi-connect-btn">Anslut</button>
-            <span id="wifi-connect-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
-          </div>
-        </div>
-      </div>
-    </div>
-
-    <!-- Sektion: Bluetooth -->
-    <section>
-      <h2>Bluetooth</h2>
-
-      <!-- Adapterstatus -->
-      <div id="bt-status-box" class="prop-grid" style="margin-bottom:1rem">
-        <label>Adapter</label>
-        <div style="display:flex;align-items:center;gap:.6rem;flex-wrap:wrap">
-          <span id="bt-status-dot" style="display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:#555"></span>
-          <span id="bt-adapter-name" style="font-size:.9rem">Laddar...</span>
-          <span id="bt-adapter-addr" style="font-size:.78rem;color:#555;font-family:monospace"></span>
-        </div>
-      </div>
-
-      <div class="row" style="margin-bottom:1rem">
-        <button onclick="btScan()" id="bt-scan-btn">Skanna enheter</button>
-        <button class="secondary" id="bt-power-btn" onclick="btTogglePower()">Stäng av adapter</button>
-        <span id="bt-scan-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
-      </div>
-
-      <!-- Skannrade enheter -->
-      <div id="bt-found-wrap" style="display:none;margin-bottom:1.25rem">
-        <h3>Hittade enheter</h3>
-        <div id="bt-found-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem"></div>
-      </div>
-
-      <!-- Kopplade / kända enheter -->
-      <div id="bt-known-wrap">
-        <h3>Kopplade enheter</h3>
-        <div id="bt-known-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem;font-size:.9rem">
-          <span style="color:#555;font-style:italic">Inga kopplade enheter</span>
-        </div>
-      </div>
-    </section>
-
-    <!-- Sektion 6: Inställningar -->
-    <section>
-      <h2>Inställningar</h2>
-
-      <h3>Ljud</h3>
-      <div class="prop-grid">
-        <label>Volym</label>
-        <div class="row">
-          <input id="setting-volume" type="range" min="0" max="100" step="1" style="flex:1"
-                 oninput="document.getElementById('volume-val').textContent=this.value+'%'">
-          <span id="volume-val" style="color:#aaa;min-width:3rem;text-align:right">—</span>
-          <button onclick="setVolym()">Sätt</button>
-        </div>
-
-        <label>Ljudprofil</label>
-        <select id="setting-audio"></select>
-      </div>
-
-      <h3>Display</h3>
-      <div class="prop-grid">
-        <label>Intern display</label>
-        <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
-          <input type="checkbox" id="setting-internal-display" checked onchange="setInternDisplay(this.checked)">
-          Aktivera eDP-1 med admin-gränssnitt
-        </label>
-
-        <label>HDMI-utgång</label>
-        <select id="setting-display">
-          <option value="last">Sista anslutna (standard)</option>
-          <option value="first">Första anslutna</option>
-        </select>
-
-        <label>Upplösning</label>
-        <div style="display:flex;gap:.5rem;align-items:center">
-          <select id="setting-display-mode" style="flex:1">
-            <option value="">(Ingen / lämna tomt)</option>
-          </select>
-          <input id="setting-display-mode-custom" type="text" placeholder="Ex: 1920x1080" style="display:none;min-width:10rem">
-        </div>
-      </div>
-      <div id="displays-info" style="font-size:.82rem;color:#555;margin:.2rem 0 .75rem"></div>
-
-      <div class="row">
-        <button onclick="sparaInstallningar()">Spara inställningar</button>
-        <button class="secondary" onclick="applyDisplayNow()">Tillämpa upplösning nu</button>
-        <span id="settings-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
-      </div>
-    </section>
-
-    <!-- Sektion 6: System -->
-    <section>
-      <h2>System</h2>
-      <div class="row">
-        <button class="secondary" onclick="startaOmProgram()">Starta om programmet</button>
-        <button class="danger" onclick="startaOmSystemet()">Starta om datorn</button>
-        <button class="danger" onclick="stangAvDatorn()">Stäng av datorn</button>
-      </div>
-    </section>
+    <!-- Addon-paneler (inlästa dynamiskt baserat på aktiverade addons) -->
+    {% for addon in addon_panels %}
+      {% include 'addons/' + addon.name + '/panel.html' ignore missing %}
+    {% endfor %}

-    <!-- Sektion 7: Systeminfo -->
+    <!-- Sektion: Systeminfo -->
     <section>
       <h2>Systeminfo</h2>
       <div id="sysinfo" class="sysinfo-grid">
diff --git a/kiosk/templates/display.html b/kiosk/templates/display.html
index f2a80ee..1130ee6 100644
--- a/kiosk/templates/display.html
+++ b/kiosk/templates/display.html
@@ -227,5 +227,8 @@

   <script src="/static/socket.io.min.js"></script>
   <script src="/static/display.js"></script>
+  {% for name in active_overlays %}
+    <script src="/static/addons/{{ name }}/overlay.js"></script>
+  {% endfor %}
 </body>
 </html>
diff --git a/kiosk/utils/__init__.py b/kiosk/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/kiosk/utils/display.py b/kiosk/utils/display.py
new file mode 100644
index 0000000..8aac0f0
--- /dev/null
+++ b/kiosk/utils/display.py
@@ -0,0 +1,147 @@
+import os
+import re
+import shutil
+import subprocess
+from pathlib import Path
+import db
+
+
+def sway_env():
+    """Returnerar env-dict med SWAYSOCK satt, eller None om sway inte körs."""
+    uid = os.getuid()
+    xdg_runtime = os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{uid}')
+    sockets = sorted(Path(xdg_runtime).glob(f'sway-ipc.{uid}.*.sock'))
+    if not sockets:
+        return None
+    env = os.environ.copy()
+    env['SWAYSOCK'] = str(sockets[0])
+    return env
+
+
+def audio_device_id():
+    """Returnerar PipeWire interface-id för första Audio/Device."""
+    import json as _json
+    try:
+        out = subprocess.check_output(
+            ['pw-dump'], timeout=5,
+            env={**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+        ).decode()
+        for node in _json.loads(out):
+            if node.get('type') != 'PipeWire:Interface:Device':
+                continue
+            if node.get('info', {}).get('props', {}).get('media.class') == 'Audio/Device':
+                return node.get('id')
+    except Exception:
+        pass
+    return None
+
+
+def parse_profiles(text):
+    """Tolkar pw-cli enum-params EnumProfile-utdata till en lista med dicts."""
+    profiles, cur, last_key = [], {}, None
+    for line in text.splitlines():
+        s = line.strip()
+        if s.startswith('Object:') and 'Profile' in s:
+            if 'index' in cur:
+                profiles.append(cur)
+            cur, last_key = {}, None
+        elif 'Profile:index'       in s: last_key = 'index'
+        elif 'Profile:description' in s: last_key = 'description'
+        elif 'Profile:name'        in s and 'description' not in s: last_key = 'name'
+        elif 'Profile:available'   in s: last_key = 'available'
+        elif last_key == 'index' and s.startswith('Int '):
+            cur['index'] = int(s.split()[1]); last_key = None
+        elif last_key in ('name', 'description') and s.startswith('String '):
+            m = re.match(r'String "(.+)"', s)
+            if m: cur[last_key] = m.group(1)
+            last_key = None
+        elif last_key == 'available' and 'Availability' in s:
+            cur['available'] = 'yes' in s; last_key = None
+    if 'index' in cur:
+        profiles.append(cur)
+    skip = {'off', 'pro-audio'}
+    return [p for p in profiles
+            if p.get('name') not in skip
+            and not p.get('name', '').startswith('input:')]
+
+
+def apply_saved_audio():
+    """Tillämpar sparad ljudprofil från DB. Best-effort, icke-fatal."""
+    try:
+        state  = db.get_state()
+        idx    = int(state.get('audio_profile_index', 1))
+        dev_id = audio_device_id()
+        pw_env = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+        if dev_id is not None:
+            subprocess.run(
+                ['pw-cli', 's', str(dev_id), 'Profile', f'{{ index: {idx} }}'],
+                timeout=5, env=pw_env
+            )
+    except Exception:
+        pass
+
+
+def apply_display_mode(mode_str, target_output=None):
+    """Tillämpar skärmupplösning best-effort. Provar wlr-randr, swaymsg, xrandr."""
+    m = re.search(r"(\d+)x(\d+)", mode_str)
+    if not m:
+        return False
+    width, height = m.group(1), m.group(2)
+
+    out_name = None
+    if target_output:
+        out_name = target_output
+    else:
+        try:
+            for conn in sorted(Path('/sys/class/drm').iterdir()):
+                status_file = conn / 'status'
+                if not status_file.exists():
+                    continue
+                if status_file.read_text().strip() == 'connected':
+                    parts = conn.name.split('-', 1)
+                    if len(parts) == 2:
+                        out_name = parts[1]
+                        break
+        except Exception:
+            out_name = None
+
+    env = os.environ.copy()
+    if not env.get('WAYLAND_DISPLAY'):
+        xdg_runtime = env.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
+        try:
+            for sock in sorted(Path(xdg_runtime).glob('wayland-[0-9]*')):
+                if sock.suffix != '.lock':
+                    env['WAYLAND_DISPLAY'] = sock.name
+                    break
+        except Exception:
+            pass
+
+    if shutil.which('wlr-randr') and out_name:
+        try:
+            token = mode_str.split('@', 1)[0]
+            subprocess.run(['wlr-randr', '--output', out_name, '--mode', token], timeout=5, check=True, env=env)
+            return True
+        except Exception:
+            pass
+
+    if shutil.which('swaymsg') and out_name:
+        try:
+            subprocess.run(['swaymsg', 'output', out_name, 'mode', width, height], timeout=5, check=True, env=env)
+            return True
+        except Exception:
+            pass
+
+    if os.environ.get('XDG_SESSION_TYPE', '').lower() == 'wayland' or os.environ.get('WAYLAND_DISPLAY'):
+        return False
+    if shutil.which('xrandr') and out_name:
+        for disp in (None, ':0', ':0.0', ':1'):
+            try:
+                env2 = os.environ.copy()
+                if disp:
+                    env2['DISPLAY'] = disp
+                subprocess.run(['xrandr', '--output', out_name, '--mode', f'{width}x{height}'], env=env2, timeout=5, check=True)
+                return True
+            except Exception:
+                continue
+
+    return False
diff --git a/kiosk/utils/system.py b/kiosk/utils/system.py
new file mode 100644
index 0000000..170c710
--- /dev/null
+++ b/kiosk/utils/system.py
@@ -0,0 +1,13 @@
+import socket as _socket
+
+
+def local_ip():
+    """Returnerar maskinens lokala IP-adress."""
+    try:
+        s = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
+        s.connect(('8.8.8.8', 80))
+        ip = s.getsockname()[0]
+        s.close()
+        return ip
+    except Exception:
+        return '127.0.0.1'
diff --git a/kiosk/utils/youtube.py b/kiosk/utils/youtube.py
new file mode 100644
index 0000000..ca5b879
--- /dev/null
+++ b/kiosk/utils/youtube.py
@@ -0,0 +1,30 @@
+import subprocess
+import shutil
+from pathlib import Path
+
+# In-memory cache: url -> [video_id, ...]
+playlist_cache = {}
+
+
+def yt_extract(url):
+    """Kör yt-dlp och returnera (ids, total_seconds). Kastar undantag vid fel."""
+    ytdlp = shutil.which('yt-dlp') or '/home/mrfox/.local/bin/yt-dlp'
+    if not ytdlp or not Path(ytdlp).exists():
+        raise RuntimeError('yt-dlp är inte installerat')
+    result = subprocess.run(
+        [ytdlp, '--flat-playlist', '--print', 'id', '--print', '%(duration)s',
+         '--no-warnings', url],
+        capture_output=True, text=True, timeout=60
+    )
+    lines = [l.strip() for l in result.stdout.splitlines() if l.strip()]
+    # Rader alternerar: id, duration, id, duration, ...
+    ids, total_seconds = [], 0
+    for i in range(0, len(lines) - 1, 2):
+        ids.append(lines[i])
+        try:
+            total_seconds += int(lines[i + 1])
+        except ValueError:
+            pass
+    if not ids:
+        raise RuntimeError(result.stderr[:300] or 'Inga videos hittades')
+    return ids, total_seconds