foxygit / infodisplay Log in
commit 6a8012e5ec1ddfaa0e403625aab448ef6d627e1a
Author:     Jens Kristoffersson <jens.se@icloud.com>
AuthorDate: Sun Apr 5 22:13:20 2026 +0200
Commit:     Jens Kristoffersson <jens.se@icloud.com>
CommitDate: Sun Apr 5 22:13:20 2026 +0200

    uppdate 2022-06-01
---
 kiosk/addons/emulator/routes.py                    |   3 +-
 kiosk/addons/livechat/__init__.py                  |   4 +-
 kiosk/addons/livechat/routes.py                    |  38 +----
 kiosk/addons/music/routes.py                       |  17 ++-
 kiosk/addons/settings/routes.py                    |  30 ++++
 kiosk/addons/youtube_livechat/__init__.py          |  16 ++
 kiosk/addons/youtube_livechat/routes.py            |  60 ++++++++
 kiosk/app.py                                       |  64 +++++++-
 kiosk/db.py                                        |  18 ++-
 kiosk/scheduler.py                                 |   5 +-
 kiosk/static/admin.js                              | 168 ++++++++++++++-------
 kiosk/static/display.js                            | 145 +++++++++++++-----
 kiosk/sway-apps.sh                                 |   5 +-
 kiosk/templates/addons/livechat/panel.html         |  51 +------
 kiosk/templates/addons/settings/panel.html         |  16 ++
 kiosk/templates/addons/youtube_livechat/panel.html |  47 ++++++
 kiosk/templates/admin.html                         |  15 +-
 kiosk/templates/emulator.html                      |   1 +
 18 files changed, 513 insertions(+), 190 deletions(-)

diff --git a/kiosk/addons/emulator/routes.py b/kiosk/addons/emulator/routes.py
index 30a1d01..0264470 100644
--- a/kiosk/addons/emulator/routes.py
+++ b/kiosk/addons/emulator/routes.py
@@ -142,8 +142,9 @@ def launch():
 def stop():
     db.set_state('emulator_active', '0')
     extensions.socketio.emit('switch', {
-        'type':      'idle',
+        'type':      'local',
         'source':    '',
+        'html':      '',
         'scheduled': False,
     })
     return '', 204
diff --git a/kiosk/addons/livechat/__init__.py b/kiosk/addons/livechat/__init__.py
index 080b0be..b4b6c68 100644
--- a/kiosk/addons/livechat/__init__.py
+++ b/kiosk/addons/livechat/__init__.py
@@ -1,12 +1,12 @@
 ADDON = {
     "name": "livechat",
-    "label": "Livechat",
+    "label": "Kiosk Livechat",
     "version": "1.0",
     "order": 5,
     "has_overlay": False,
     "has_panel": True,
     "has_socketio": True,
-    "state_prefix": "chat_",
+    "state_prefix": "kiosk_chat_",
     "default_enabled": True,
 }

diff --git a/kiosk/addons/livechat/routes.py b/kiosk/addons/livechat/routes.py
index 63a2b5a..b2da9d0 100644
--- a/kiosk/addons/livechat/routes.py
+++ b/kiosk/addons/livechat/routes.py
@@ -224,14 +224,6 @@ def upload_chat_image():
 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')),
@@ -245,37 +237,15 @@ 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))
+    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,
     }
diff --git a/kiosk/addons/music/routes.py b/kiosk/addons/music/routes.py
index 4bb8173..278f118 100644
--- a/kiosk/addons/music/routes.py
+++ b/kiosk/addons/music/routes.py
@@ -133,6 +133,11 @@ def update_now_playing():

 # --- YouTube playlist extraction (video & musik) ---

+def _playlist_db_key(url):
+    import hashlib
+    return 'playlist_ids_' + hashlib.md5(url.encode()).hexdigest()
+
+
 @bp.route('/playlist/extract', methods=['POST'])
 @login_required
 def extract_playlist():
@@ -148,12 +153,18 @@ def extract_playlist():
     except Exception as e:
         return jsonify({'error': str(e)}), 500
     _yt_playlist_cache[url] = ids
+    db.set_state(_playlist_db_key(url), ','.join(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."""
+    """Return cached video IDs for a playlist URL (memory cache, then DB)."""
     url = request.args.get('url', '').strip()
-    ids = _yt_playlist_cache.get(url, [])
-    return jsonify({'ids': ids, 'count': len(ids)})
+    ids = _yt_playlist_cache.get(url)
+    if not ids:
+        stored = db.get_state().get(_playlist_db_key(url), '')
+        ids = [v for v in stored.split(',') if v] if stored else []
+        if ids:
+            _yt_playlist_cache[url] = ids
+    return jsonify({'ids': ids or [], 'count': len(ids or [])})
diff --git a/kiosk/addons/settings/routes.py b/kiosk/addons/settings/routes.py
index 2728c1d..0f38885 100644
--- a/kiosk/addons/settings/routes.py
+++ b/kiosk/addons/settings/routes.py
@@ -5,6 +5,7 @@ import subprocess
 from pathlib import Path
 import eventlet
 from flask import Blueprint, jsonify, request, abort
+from werkzeug.security import generate_password_hash, check_password_hash
 import db
 from auth import login_required
 from utils.display import (
@@ -271,3 +272,32 @@ def toggle_internal_display():
         return '', 204
     eventlet.spawn(_apply_internal_display, enabled, env)
     return '', 204
+
+
+@bp.route('/settings/credentials', methods=['POST'])
+@login_required
+def change_credentials():
+    data = request.get_json(silent=True) or {}
+    current  = str(data.get('current_password', ''))
+    new_user = str(data.get('new_username', '')).strip()[:64]
+    new_pass = str(data.get('new_password', ''))
+
+    if not new_user or not new_pass:
+        return jsonify({'error': 'Användarnamn och lösenord krävs'}), 400
+    if len(new_pass) < 4:
+        return jsonify({'error': 'Lösenordet måste vara minst 4 tecken'}), 400
+
+    # Verify current credentials
+    import app as _app
+    stored_hash = db.get_state().get('admin_password_hash')
+    if stored_hash:
+        if not check_password_hash(stored_hash, current):
+            return jsonify({'error': 'Fel nuvarande lösenord'}), 403
+    else:
+        # Fall back to env/default comparison
+        if current != _app.KIOSK_PASS:
+            return jsonify({'error': 'Fel nuvarande lösenord'}), 403
+
+    db.set_state('admin_username', new_user)
+    db.set_state('admin_password_hash', generate_password_hash(new_pass))
+    return jsonify({'ok': True})
diff --git a/kiosk/addons/youtube_livechat/__init__.py b/kiosk/addons/youtube_livechat/__init__.py
new file mode 100644
index 0000000..d4e5f74
--- /dev/null
+++ b/kiosk/addons/youtube_livechat/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+    "name": "youtube_livechat",
+    "label": "YouTube Live Chat",
+    "version": "1.0",
+    "order": 6,
+    "has_overlay": False,
+    "has_panel": True,
+    "has_socketio": False,
+    "state_prefix": "yt_chat_",
+    "default_enabled": True,
+}
+
+
+def create_blueprint():
+    from .routes import bp
+    return bp
diff --git a/kiosk/addons/youtube_livechat/routes.py b/kiosk/addons/youtube_livechat/routes.py
new file mode 100644
index 0000000..c0879a4
--- /dev/null
+++ b/kiosk/addons/youtube_livechat/routes.py
@@ -0,0 +1,60 @@
+import re
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+from auth import login_required
+
+bp = Blueprint('youtube_livechat', __name__)
+
+_HEX_COLOR = re.compile(r'^#[0-9a-fA-F]{6}$')
+
+
+@bp.route('/api/yt_chat', methods=['GET'])
+def get_yt_chat():
+    state = db.get_state()
+    return jsonify({
+        'enabled':    state.get('yt_chat_enabled',    '0') == '1',
+        'position':   state.get('yt_chat_position',   'right'),
+        'width_pct':  int(state.get('yt_chat_width_pct', '25')),
+        'height':     int(state.get('yt_chat_height',    '100')),
+        'bg_color':   state.get('yt_chat_bg_color',   '#000000'),
+        'bg_opacity': int(state.get('yt_chat_bg_opacity', '70')),
+        'text_color': state.get('yt_chat_text_color', '#ffffff'),
+        'font_size':  int(state.get('yt_chat_font_size', '14')),
+    })
+
+
+@bp.route('/api/yt_chat', methods=['POST'])
+@login_required
+def set_yt_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'
+
+    db.set_state('yt_chat_enabled',    '1' if enabled else '0')
+    db.set_state('yt_chat_position',   position)
+    db.set_state('yt_chat_width_pct',  str(width_pct))
+    db.set_state('yt_chat_height',     str(height))
+    db.set_state('yt_chat_bg_color',   bg_color)
+    db.set_state('yt_chat_bg_opacity', str(bg_opacity))
+    db.set_state('yt_chat_text_color', text_color)
+    db.set_state('yt_chat_font_size',  str(font_size))
+
+    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,
+    }
+    extensions.socketio.emit('yt_chat_settings', payload)
+    return '', 204
diff --git a/kiosk/app.py b/kiosk/app.py
index e7eae5e..75ffc82 100644
--- a/kiosk/app.py
+++ b/kiosk/app.py
@@ -81,13 +81,26 @@ KIOSK_USER = os.environ.get('KIOSK_USER', 'admin')
 KIOSK_PASS = os.environ.get('KIOSK_PASS', 'changeme')


+def _check_credentials(username, password):
+    """Kontrollera inloggning — DB-hash har prioritet över env/default."""
+    from werkzeug.security import check_password_hash as _chk
+    state = db.get_state()
+    stored_hash = state.get('admin_password_hash')
+    stored_user = state.get('admin_username') or KIOSK_USER
+    if username != stored_user:
+        return False
+    if stored_hash:
+        return _chk(stored_hash, password)
+    return password == KIOSK_PASS
+
+
 # --- Login ---

 @app.route('/login', methods=['GET', 'POST'])
 def login():
     error = None
     if request.method == 'POST':
-        if request.form['username'] == KIOSK_USER and request.form['password'] == KIOSK_PASS:
+        if _check_credentials(request.form['username'], request.form['password']):
             session['logged_in'] = True
             return redirect(request.args.get('next') or url_for('admin'))
         error = 'Fel användarnamn eller lösenord'
@@ -151,7 +164,11 @@ def add_job():
     required = ('label', 'duration', 'type', 'source')
     if not all(k in data for k in required):
         abort(400)
-    job_id = db.add_job(data['label'], float(data['duration']), data['type'], data['source'])
+    job_id = db.add_job(
+        data['label'], float(data['duration']), data['type'], data['source'],
+        shuffle=bool(data.get('shuffle', False)),
+        show_chat=bool(data.get('show_chat', False)),
+    )
     sched.reload_jobs()
     return jsonify({'id': job_id}), 201

@@ -171,11 +188,15 @@ def toggle_job(job_id):
     data = request.get_json(silent=True)
     if data:
         allowed = {}
-        for k in ('label', 'duration', 'type', 'source', 'enabled'):
+        for k in ('label', 'duration', 'type', 'source', 'enabled', 'shuffle', 'show_chat'):
             if k in data:
                 allowed[k] = data[k]
         if 'duration' in allowed:
             allowed['duration'] = float(allowed['duration'])
+        if 'shuffle' in allowed:
+            allowed['shuffle'] = 1 if allowed['shuffle'] else 0
+        if 'show_chat' in allowed:
+            allowed['show_chat'] = 1 if allowed['show_chat'] else 0
         db.update_job(job_id, **allowed)
         sched.reload_jobs()
         return '', 204
@@ -215,6 +236,8 @@ def on_connect():
             'type':      state['current_type'],
             'source':    state.get('current_source', ''),
             'scheduled': False,
+            'shuffle':   False,
+            'show_chat': True,
         })
     if state.get('sleep_enabled') == '1':
         active = _is_sleep_active(
@@ -222,6 +245,36 @@ def on_connect():
             state.get('sleep_end',   '07:00'),
         )
         emit('sleep_toggle', {'active': active})
+    # Emittera alla overlay-inställningar direkt via socket
+    # så display.js inte behöver lyckas med HTTP-fetches för att initiera.
+    emit('ticker', {
+        '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')),
+        '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_family':  state.get('ticker_font_family','sans-serif'),
+        'bar_color':    state.get('ticker_bar_color', '#000000'),
+        'pps':          int(state.get('ticker_pps',   '150')),
+    })
+    emit('chat_settings', {
+        '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')),
+    })
+    emit('yt_chat_settings', {
+        'enabled':    state.get('yt_chat_enabled',    '0') == '1',
+        'position':   state.get('yt_chat_position',   'right'),
+        'width_pct':  int(state.get('yt_chat_width_pct', '25')),
+        'height':     int(state.get('yt_chat_height',    '100')),
+        'bg_color':   state.get('yt_chat_bg_color',   '#000000'),
+        'bg_opacity': int(state.get('yt_chat_bg_opacity', '70')),
+    })
     emit('clock_settings', {
         'enabled':       state.get('clock_enabled',       '0') == '1',
         'style':         state.get('clock_style',         'digital'),
@@ -281,8 +334,9 @@ def _apply_settings_after_cage():
         _apply_display_mode(saved_mode, target_output=state.get('display_output') or None)
     _apply_saved_audio()

-    # Om inget WiFi: aktivera intern display automatiskt så admin-gränssnittet är tillgängligt
-    eventlet.sleep(2)  # ge sway tid att sätta upp outputs
+    # Vänta ordentligt innan workspace-byte så att display-Chromium hinner
+    # ladda klart och WebSocket-anslutningen stabiliseras.
+    eventlet.sleep(15)
     env = _sway_env()
     if env:
         internal_pref = state.get('internal_display', 'off')
diff --git a/kiosk/db.py b/kiosk/db.py
index 6492fe4..d358655 100644
--- a/kiosk/db.py
+++ b/kiosk/db.py
@@ -20,7 +20,9 @@ def init_db():
                 type     TEXT NOT NULL,
                 source   TEXT NOT NULL,
                 enabled  INTEGER DEFAULT 1,
-                position INTEGER
+                position INTEGER,
+                shuffle  INTEGER DEFAULT 0,
+                show_chat INTEGER DEFAULT 0
             );

             CREATE TABLE IF NOT EXISTS state (
@@ -35,6 +37,12 @@ def init_db():
                 ALTER TABLE jobs ADD COLUMN duration REAL NOT NULL DEFAULT 5;
                 UPDATE jobs SET duration = 5;
             """)
+        # Migrering: add shuffle + show_chat columns if missing
+        if 'shuffle' not in cols:
+            conn.execute("ALTER TABLE jobs ADD COLUMN shuffle INTEGER DEFAULT 0")
+        if 'show_chat' not in cols:
+            conn.execute("ALTER TABLE jobs ADD COLUMN show_chat INTEGER DEFAULT 0")
+
         # Migrering: add position column if missing
         if 'position' not in cols:
             conn.executescript("""
@@ -84,14 +92,14 @@ def get_enabled_jobs():
         ).fetchall()]


-def add_job(label, duration, type_, source):
+def add_job(label, duration, type_, source, shuffle=False, show_chat=False):
     with get_conn() as conn:
         # determine next position
         curpos = conn.execute("SELECT MAX(position) AS m FROM jobs").fetchone()
         nextpos = (curpos['m'] or 0) + 1
         cur = conn.execute(
-            "INSERT INTO jobs (label, duration, type, source, position) VALUES (?, ?, ?, ?, ?)",
-            (label, duration, type_, source, nextpos)
+            "INSERT INTO jobs (label, duration, type, source, position, shuffle, show_chat) VALUES (?, ?, ?, ?, ?, ?, ?)",
+            (label, duration, type_, source, nextpos, 1 if shuffle else 0, 1 if show_chat else 0)
         )
         return cur.lastrowid

@@ -117,7 +125,7 @@ def update_job(job_id, **fields):
     keys = []
     vals = []
     for k, v in fields.items():
-        if k in ('label', 'duration', 'type', 'source', 'enabled'):
+        if k in ('label', 'duration', 'type', 'source', 'enabled', 'shuffle', 'show_chat'):
             keys.append(f"{k} = ?")
             vals.append(v)
     if not keys:
diff --git a/kiosk/scheduler.py b/kiosk/scheduler.py
index 44731cf..6b58673 100644
--- a/kiosk/scheduler.py
+++ b/kiosk/scheduler.py
@@ -39,7 +39,10 @@ def _advance():

 def _emit_and_schedule(job):
     if _socketio:
-        _socketio.emit('switch', {'type': job['type'], 'source': job['source'], 'scheduled': True})
+        _socketio.emit('switch', {
+            'type': job['type'], 'source': job['source'], 'scheduled': True,
+            'shuffle': bool(job.get('shuffle')), 'show_chat': bool(job.get('show_chat')),
+        })
         db.set_current(job['type'], job['source'])
     scheduler.add_job(
         func=_advance,
diff --git a/kiosk/static/admin.js b/kiosk/static/admin.js
index 5f54a4f..0f22885 100644
--- a/kiosk/static/admin.js
+++ b/kiosk/static/admin.js
@@ -246,6 +246,11 @@ function _jobTypeChanged(formId, wrapId, statusId, emuWrapId) {
   const isPlaylist = type === 'youtube_playlist';
   const isEmulator = type === 'emulator';

+  // Show/hide shuffle row for playlist type
+  const shuffleWrapId = formId === 'job-form' ? 'job-shuffle-wrap' : 'edit-shuffle-wrap';
+  const shuffleWrap = document.getElementById(shuffleWrapId);
+  if (shuffleWrap) shuffleWrap.style.display = isPlaylist ? 'block' : 'none';
+
   if (wrap)    wrap.style.display    = isPlaylist ? 'flex' : 'none';
   if (emuWrap) emuWrap.style.display = isEmulator ? 'flex' : 'none';

@@ -332,7 +337,8 @@ async function laggTillJobb(e) {
     duration:  form.duration.value,
     type,
     source,
-    show_chat: form.show_chat.checked,
+    show_chat: form.show_chat ? form.show_chat.checked : false,
+    shuffle:   form.shuffle   ? form.shuffle.checked   : false,
   };
   const res = await apiFetch('/api/jobs', {
     method: 'POST',
@@ -359,6 +365,7 @@ function openEdit(id) {
     form.duration.value = job.duration;
     form.type.value = job.type;
     form.source.value = job.source;
+    if (form.shuffle) form.shuffle.checked = !!job.shuffle;
     _jobTypeChanged('edit-form', 'edit-extract-wrap', 'edit-extract-status');
     modal.style.display = 'flex';
   });
@@ -377,10 +384,11 @@ async function saveEdit(e) {
   const form = e.target;
   const id = parseInt(form.id.value, 10);
   const payload = {
-    label: form.label.value,
+    label:    form.label.value,
     duration: parseFloat(form.duration.value),
-    type: form.type.value,
-    source: form.source.value
+    type:     form.type.value,
+    source:   form.source.value,
+    shuffle:  form.shuffle ? form.shuffle.checked : false,
   };
   const res = await apiFetch('/api/jobs/' + id, {
     method: 'PATCH',
@@ -562,6 +570,27 @@ async function sparaInstallningar() {
   setTimeout(() => { status.textContent = ''; }, 3000);
 }

+async function bytaLösenord() {
+  const current = document.getElementById('cred-current').value;
+  const user    = document.getElementById('cred-user').value.trim();
+  const pass    = document.getElementById('cred-pass').value;
+  const status  = document.getElementById('cred-status');
+  if (!current || !user || !pass) { status.textContent = 'Fyll i alla fält'; return; }
+  const res = await apiFetch('/api/settings/credentials', {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ current_password: current, new_username: user, new_password: pass }),
+  }).catch(() => null);
+  if (!res) { status.textContent = '✗ Fel'; return; }
+  const d = await res.json();
+  if (d.ok) {
+    status.textContent = '✓ Sparat — logga in igen';
+    document.getElementById('cred-current').value = '';
+    document.getElementById('cred-pass').value = '';
+  } else {
+    status.textContent = '✗ ' + (d.error || 'Fel');
+  }
+}
+
 async function applyDisplayNow() {
   const modeSelEl = document.getElementById('setting-display-mode');
   let displayMode = '';
@@ -580,37 +609,84 @@ async function applyDisplayNow() {
   else alert('Kunde inte tillämpa upplösning: ' + (j.error || 'okänt fel'));
 }

-// --- Chat overlay ---
+// --- Kiosk Livechat overlay ---

 async function laddaChat() {
   const res = await apiFetch('/api/chat');
   if (!res) return;
   const d = await res.json();

-  document.getElementById('chat-position').value = d.position || 'right';
-  document.getElementById('chat-bg-color').value = d.bg_color || '#000000';
+  const kw = d.kiosk_width_pct != null ? d.kiosk_width_pct : 30;
+  const kh = d.kiosk_height    != null ? d.kiosk_height    : 80;
+  if (document.getElementById('kiosk-chat-enabled'))
+    document.getElementById('kiosk-chat-enabled').checked = !!d.kiosk_enabled;
+  if (document.getElementById('kiosk-chat-position'))
+    document.getElementById('kiosk-chat-position').value = d.kiosk_position || 'right';
+  if (document.getElementById('kiosk-chat-width')) {
+    document.getElementById('kiosk-chat-width').value = kw;
+    document.getElementById('kiosk-chat-width-val').textContent = kw + '%';
+  }
+  if (document.getElementById('kiosk-chat-height')) {
+    document.getElementById('kiosk-chat-height').value = kh;
+    document.getElementById('kiosk-chat-height-val').textContent = kh + '%';
+  }
+  if (document.getElementById('kiosk-chat-status'))
+    document.getElementById('kiosk-chat-status').textContent = d.kiosk_enabled ? '● Aktiv overlay' : '';
+}
+
+async function _sendKioskChat(kioskEnabled) {
+  const res = await apiFetch('/api/chat', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      kiosk_enabled:   kioskEnabled,
+      kiosk_position:  document.getElementById('kiosk-chat-position').value,
+      kiosk_width_pct: parseInt(document.getElementById('kiosk-chat-width').value,  10),
+      kiosk_height:    parseInt(document.getElementById('kiosk-chat-height').value, 10),
+    }),
+  });
+  if (!res) return;
+  if (document.getElementById('kiosk-chat-status'))
+    document.getElementById('kiosk-chat-status').textContent = kioskEnabled ? '● Aktiv overlay' : '';
+  if (document.getElementById('kiosk-chat-enabled'))
+    document.getElementById('kiosk-chat-enabled').checked = kioskEnabled;
+}
+
+// --- YouTube Live Chat overlay ---
+
+async function laddaYtChat() {
+  const res = await apiFetch('/api/yt_chat');
+  if (!res) return;
+  const d = await res.json();
+
+  if (document.getElementById('yt-chat-position'))
+    document.getElementById('yt-chat-position').value = d.position || 'right';
+  if (document.getElementById('yt-chat-bg-color'))
+    document.getElementById('yt-chat-bg-color').value = d.bg_color || '#000000';

   const wp = d.width_pct  != null ? d.width_pct  : 25;
   const h  = d.height     != null ? d.height     : 100;
   const bo = d.bg_opacity != null ? d.bg_opacity : 70;

-  document.getElementById('chat-width-pct').value      = wp; document.getElementById('chat-width-pct-val').textContent = wp + '%';
-  document.getElementById('chat-height').value         = h;  document.getElementById('chat-height-val').textContent    = h  + '%';
-  document.getElementById('chat-bg-opacity').value     = bo; document.getElementById('chat-bg-opacity-val').textContent = bo + '%';
-  document.getElementById('chat-status').textContent   = d.enabled ? '● Aktiv' : '';
+  if (document.getElementById('yt-chat-width')) {
+    document.getElementById('yt-chat-width').value = wp;
+    document.getElementById('yt-chat-width-val').textContent = wp + '%';
+  }
+  if (document.getElementById('yt-chat-height')) {
+    document.getElementById('yt-chat-height').value = h;
+    document.getElementById('yt-chat-height-val').textContent = h + '%';
+  }
+  if (document.getElementById('yt-chat-bg-opacity')) {
+    document.getElementById('yt-chat-bg-opacity').value = bo;
+    document.getElementById('yt-chat-bg-opacity-val').textContent = bo + '%';
+  }
+  if (document.getElementById('yt-chat-status'))
+    document.getElementById('yt-chat-status').textContent = d.enabled ? '● Aktiv' : '';

-  // Kiosk livechat
-  const kw = d.kiosk_width_pct != null ? d.kiosk_width_pct : 30;
-  const kh = d.kiosk_height    != null ? d.kiosk_height    : 80;
-  document.getElementById('kiosk-chat-enabled').checked  = !!d.kiosk_enabled;
-  document.getElementById('kiosk-chat-position').value   = d.kiosk_position || 'right';
-  document.getElementById('kiosk-chat-width').value      = kw; document.getElementById('kiosk-chat-width-val').textContent  = kw + '%';
-  document.getElementById('kiosk-chat-height').value     = kh; document.getElementById('kiosk-chat-height-val').textContent = kh + '%';
-  document.getElementById('kiosk-chat-status').textContent = d.kiosk_enabled ? '● Aktiv overlay' : '';
-  // Chat theme button (auto/dark/light)
+  // Chat theme button
   try {
     const KEY = 'kiosk_chat_theme';
-    const btn = document.getElementById('chat-theme-btn');
+    const btn = document.getElementById('yt-chat-theme-btn');
     if (btn) {
       const getTheme = () => localStorage.getItem(KEY) || 'auto';
       const order = ['auto','dark','light'];
@@ -618,58 +694,43 @@ async function laddaChat() {
         const t = getTheme();
         btn.textContent = t === 'auto' ? 'Chatt: Auto' : (t === 'dark' ? 'Chatt: Mörk' : 'Chatt: Ljus');
       };
-      btn.addEventListener('click', () => {
-        const cur = getTheme();
-        const next = order[(order.indexOf(cur) + 1) % order.length];
+      btn.onclick = () => {
+        const next = order[(order.indexOf(getTheme()) + 1) % order.length];
         localStorage.setItem(KEY, next);
         updateBtn();
-        // notify display pages
         window.dispatchEvent(new Event('kiosk-chat-theme-changed'));
-      });
-      // reflect existing value
+      };
       updateBtn();
-      // react to external changes
       window.addEventListener('storage', (e) => { if (e.key === KEY) updateBtn(); });
     }
   } catch (e) {}
 }

-async function _sendChat(ytEnabled, kioskEnabled) {
-  const res = await apiFetch('/api/chat', {
+async function _sendYtChat(enabled) {
+  const res = await apiFetch('/api/yt_chat', {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify({
-      enabled:          ytEnabled,
-      position:         document.getElementById('chat-position').value,
-      width_pct:        parseInt(document.getElementById('chat-width-pct').value,   10),
-      height:           parseInt(document.getElementById('chat-height').value,      10),
-      bg_color:         document.getElementById('chat-bg-color').value,
-      bg_opacity:       parseInt(document.getElementById('chat-bg-opacity').value,  10),
-      kiosk_enabled:    kioskEnabled,
-      kiosk_position:   document.getElementById('kiosk-chat-position').value,
-      kiosk_width_pct:  parseInt(document.getElementById('kiosk-chat-width').value,  10),
-      kiosk_height:     parseInt(document.getElementById('kiosk-chat-height').value, 10),
+      enabled,
+      position:   document.getElementById('yt-chat-position')   ? document.getElementById('yt-chat-position').value  : 'right',
+      width_pct:  document.getElementById('yt-chat-width')      ? parseInt(document.getElementById('yt-chat-width').value, 10)      : 25,
+      height:     document.getElementById('yt-chat-height')     ? parseInt(document.getElementById('yt-chat-height').value, 10)     : 100,
+      bg_color:   document.getElementById('yt-chat-bg-color')   ? document.getElementById('yt-chat-bg-color').value  : '#000000',
+      bg_opacity: document.getElementById('yt-chat-bg-opacity') ? parseInt(document.getElementById('yt-chat-bg-opacity').value, 10) : 70,
     }),
   });
   if (!res) return;
-  document.getElementById('chat-status').textContent       = ytEnabled    ? '● Aktiv' : '';
-  document.getElementById('kiosk-chat-status').textContent = kioskEnabled ? '● Aktiv overlay' : '';
+  if (document.getElementById('yt-chat-status'))
+    document.getElementById('yt-chat-status').textContent = enabled ? '● Aktiv' : '';
 }

-function _currentKioskEnabled() { return document.getElementById('kiosk-chat-enabled').checked; }
-function _currentYtEnabled()    { return document.getElementById('chat-status').textContent.startsWith('●'); }
-
 function aktiveraChatOverlay(which) {
-  const yt    = which === 'yt'    ? true  : _currentYtEnabled();
-  const kiosk = which === 'kiosk' ? true  : _currentKioskEnabled();
-  document.getElementById('kiosk-chat-enabled').checked = kiosk;
-  _sendChat(yt, kiosk);
+  if (which === 'yt')    _sendYtChat(true);
+  if (which === 'kiosk') _sendKioskChat(true);
 }
 function avaktiveraChatOverlay(which) {
-  const yt    = which === 'yt'    ? false : _currentYtEnabled();
-  const kiosk = which === 'kiosk' ? false : _currentKioskEnabled();
-  document.getElementById('kiosk-chat-enabled').checked = kiosk;
-  _sendChat(yt, kiosk);
+  if (which === 'yt')    _sendYtChat(false);
+  if (which === 'kiosk') _sendKioskChat(false);
 }

 // --- Ticker ---
@@ -1541,6 +1602,7 @@ async function init() {
   await laddaVolym();
   await laddaTicker();
   await laddaChat();
+  await laddaYtChat();
   await laddaClock();
   await laddaMusik();
   await laddaWifiStatus();
diff --git a/kiosk/static/display.js b/kiosk/static/display.js
index f282e0b..85ec562 100644
--- a/kiosk/static/display.js
+++ b/kiosk/static/display.js
@@ -206,37 +206,49 @@ function _tickerNextLine() {
   _tickerShowLine();
 }

-// --- Chat overlays (YouTube + Kiosk livechat) ---
+// --- Chat overlays (YouTube Live Chat + Kiosk livechat, separata) ---
+
+let _currentYtId     = null;
+let _currentIsYtLive = false;  // true = bekräftad live-ström via YT.Player
+let _currentType     = null;
+let _ytSinglePlayer  = null;   // YT.Player för youtube-typ
+
+// YouTube Live Chat state
+let _ytChat = { enabled: false, position: 'right', width_pct: 25, height: 100,
+                bg_color: '#000000', bg_opacity: 70, jobEnabled: true };
+
+// Kiosk livechat overlay state
+let _kioskChat = { enabled: false, position: 'right', width_pct: 30, height: 80, jobEnabled: true };
+
+function applyYtChat(data) {
+  _ytChat.enabled   = !!data.enabled;
+  _ytChat.position  = data.position   || 'right';
+  _ytChat.width_pct = data.width_pct  != null ? data.width_pct  : 25;
+  _ytChat.height    = data.height     != null ? data.height     : 100;
+  _ytChat.bg_color  = data.bg_color   || '#000000';
+  _ytChat.bg_opacity = data.bg_opacity != null ? data.bg_opacity : 70;
+  _updateChatOverlay();
+}

-// jobEnabled: true = follow global _chat.enabled, false = force-hide chat for this job
-let _chat        = { enabled: false, position: 'right', width_pct: 25, height: 100,
-                     bg_color: '#000000', bg_opacity: 70, jobEnabled: true,
-                     kiosk_enabled: false, kiosk_width_pct: 30, kiosk_height: 80,
-                     kiosk_position: 'right' };
-let _currentYtId  = null;
-let _currentType  = null;
+function applyKioskChat(data) {
+  _kioskChat.enabled   = !!data.kiosk_enabled;
+  _kioskChat.position  = data.kiosk_position  || 'right';
+  _kioskChat.width_pct = data.kiosk_width_pct != null ? data.kiosk_width_pct : 30;
+  _kioskChat.height    = data.kiosk_height    != null ? data.kiosk_height    : 80;
+  _updateKioskChatOverlay();
+}

+// Kept for backward-compat: old chat_settings events from livechat addon
 function applyChat(data) {
-  _chat.enabled         = !!data.enabled;
-  _chat.position        = data.position        || 'right';
-  _chat.width_pct       = data.width_pct       != null ? data.width_pct       : 25;
-  _chat.height          = data.height          != null ? data.height          : 100;
-  _chat.bg_color        = data.bg_color        || '#000000';
-  _chat.bg_opacity      = data.bg_opacity      != null ? data.bg_opacity      : 70;
-  _chat.kiosk_enabled   = !!data.kiosk_enabled;
-  _chat.kiosk_width_pct = data.kiosk_width_pct != null ? data.kiosk_width_pct : 30;
-  _chat.kiosk_height    = data.kiosk_height    != null ? data.kiosk_height    : 80;
-  _chat.kiosk_position  = data.kiosk_position  || 'right';
-  _updateChatOverlay();
-  _updateKioskChatOverlay();
+  applyKioskChat(data);
 }

-// YouTube chat overlay
+// YouTube Live Chat overlay (bara för live-strömmar)
 function _updateChatOverlay() {
   const overlay = document.getElementById('chat-overlay');
   const frame   = document.getElementById('chat-frame');

-  if (!_chat.enabled || !_chat.jobEnabled || !_currentYtId) {
+  if (!_ytChat.enabled || !_ytChat.jobEnabled || !_currentYtId || !_currentIsYtLive) {
     overlay.style.display = 'none';
     return;
   }
@@ -258,8 +270,8 @@ function _updateChatOverlay() {
     if (frame.src !== src) frame.src = src;
   }

-  _positionOverlay(overlay, _chat.position, _chat.width_pct, _chat.height);
-  overlay.style.background = hexToRgba(_chat.bg_color, _chat.bg_opacity);
+  _positionOverlay(overlay, _ytChat.position, _ytChat.width_pct, _ytChat.height);
+  overlay.style.background = hexToRgba(_ytChat.bg_color, _ytChat.bg_opacity);
   overlay.style.display    = 'block';
 }

@@ -360,9 +372,9 @@ function _kcEsc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;')

 function _updateKioskChatOverlay() {
   const overlay = document.getElementById('kiosk-chat-overlay');
-  // Visa inte om: inaktiverad, job-döljer, är kiosk_chat-vy, eller YT-chat tar samma plats
-  const ytChatActive = _chat.enabled && _chat.jobEnabled && !!_currentYtId;
-  const showAsOverlay = _chat.kiosk_enabled && _chat.jobEnabled
+  // Visa inte om: inaktiverad, job-döljer, är kiosk_chat-vy, eller YT-live-chat tar samma plats
+  const ytChatActive  = _ytChat.enabled && _ytChat.jobEnabled && _currentIsYtLive;
+  const showAsOverlay = _kioskChat.enabled && _kioskChat.jobEnabled
                         && _currentType !== 'kiosk_chat'
                         && !ytChatActive;

@@ -371,7 +383,7 @@ function _updateKioskChatOverlay() {
     return;
   }

-  _positionOverlay(overlay, _chat.kiosk_position, _chat.kiosk_width_pct, _chat.kiosk_height);
+  _positionOverlay(overlay, _kioskChat.position, _kioskChat.width_pct, _kioskChat.height);
   overlay.style.display = 'flex';
 }

@@ -397,7 +409,12 @@ function _positionOverlay(el, position, widthPct, heightPct) {
 }

 socket.on('chat_settings', (data) => {
-  applyChat(data);
+  // Kiosk livechat settings (from livechat addon)
+  applyKioskChat(data);
+});
+
+socket.on('yt_chat_settings', (data) => {
+  applyYtChat(data);
 });

 // --- Clock overlay ---
@@ -562,11 +579,20 @@ function _positionClock(el) {
 socket.on('clock_settings', applyClockSettings);

 socket.on('connect', () => {
+  // Server pushar nu all state via socket-events (ticker, chat_settings, etc.)
+  // Fetch-anrop som fallback om socket-events skulle missa något.
   fetch('/api/ticker').then(r => r.json()).then(applyTicker).catch(() => {});
-  fetch('/api/chat').then(r => r.json()).then(applyChat).catch(() => {});
+  fetch('/api/chat').then(r => r.json()).then(applyKioskChat).catch(() => {});
+  fetch('/api/yt_chat').then(r => r.json()).then(applyYtChat).catch(() => {});
   fetch('/api/clock').then(r => r.json()).then(applyClockSettings).catch(() => {});
 });

+socket.on('disconnect', () => {
+  // Nollställ live-state så att nästa connect börjar rent
+  _currentIsYtLive = false;
+  if (_ytSinglePlayer) { try { _ytSinglePlayer.destroy(); } catch {} _ytSinglePlayer = null; }
+});
+
 function showLayer(name) {
   ['iframe', 'video', 'local', 'image', 'idle'].forEach(l => {
     document.getElementById('layer-' + l).style.display = 'none';
@@ -575,8 +601,9 @@ function showLayer(name) {
 }

 socket.on('switch', (data) => {
-  // Per-job chat override: false = force-hide, anything else = follow global setting
-  _chat.jobEnabled = (data.show_chat !== false);
+  // Per-job chat override: show_chat=false döljer YT-livechat för jobbet
+  _ytChat.jobEnabled   = (data.show_chat !== false);
+  _kioskChat.jobEnabled = true;  // kiosk-chat styrs bara av sin globala toggle

   // Track scheduled flag for clock schedule_mode
   const prevScheduled = _isScheduled;
@@ -590,19 +617,34 @@ socket.on('switch', (data) => {
     if (_vpl.player) { try { _vpl.player.stopVideo(); } catch {} }
   }

+  // Stop single YT player when switching away
+  if (_currentType === 'youtube' && data.type !== 'youtube') {
+    if (_ytSinglePlayer) { try { _ytSinglePlayer.destroy(); } catch {} _ytSinglePlayer = null; }
+  }
+
   // Track YouTube video ID and current type for overlays
   const ytMatch = (data.source || '').match(/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/);
-  _currentYtId  = ytMatch ? ytMatch[1] : null;
-  _currentType  = data.type;
+  _currentYtId      = ytMatch ? ytMatch[1] : null;
+  _currentIsYtLive  = false;  // reset — sätts om när YT.Player bekräftar live
+  _currentType      = data.type;

   if (data.type === 'kiosk_chat') {
     document.getElementById('frame').src = window.location.origin + '/chat-display';
     showLayer('iframe');

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

+  } else if (data.type === 'youtube') {
+    if (_currentYtId) {
+      showLayer('iframe');
+      _playYoutubeVideo(_currentYtId);
+    } else {
+      document.getElementById('frame').src = data.source;
+      showLayer('iframe');
+    }
+
   } else if (data.type === 'video') {
     const v = document.getElementById('video');
     v.src = data.source;
@@ -615,6 +657,7 @@ socket.on('switch', (data) => {
     showLayer('image');

   } else if (data.type === 'youtube_playlist') {
+    _vpl.shuffle = !!data.shuffle;
     _startVideoPlaylist(data.source);

   } else if (data.type === 'emulator') {
@@ -717,6 +760,38 @@ function _vplStart(ids) {
   });
 }

+// --- Enskild YouTube-video via YT.Player (för live-detektering) ---
+
+function _playYoutubeVideo(videoId) {
+  if (!window.YT || !window.YT.Player) {
+    setTimeout(() => _playYoutubeVideo(videoId), 500);
+    return;
+  }
+  if (_ytSinglePlayer) {
+    try { _ytSinglePlayer.destroy(); } catch {}
+    _ytSinglePlayer = null;
+  }
+  const layerFrame = document.getElementById('layer-iframe');
+  const oldFrame = document.getElementById('frame');
+  if (oldFrame) oldFrame.remove();
+  const div = document.createElement('div');
+  div.id = 'frame';
+  layerFrame.appendChild(div);
+
+  _ytSinglePlayer = new YT.Player('frame', {
+    videoId,
+    playerVars: { autoplay: 1, controls: 1, rel: 0, origin: window.location.origin },
+    events: {
+      onReady(e) {
+        e.target.playVideo();
+        // getDuration() === 0 innebär att videon är en live-ström
+        _currentIsYtLive = (e.target.getDuration() === 0);
+        _updateChatOverlay();
+      },
+    },
+  });
+}
+
 async function _startVideoPlaylist(url) {
   _vpl.url = url;
   showLayer('iframe');
diff --git a/kiosk/sway-apps.sh b/kiosk/sway-apps.sh
index 9442c2e..ced412b 100755
--- a/kiosk/sway-apps.sh
+++ b/kiosk/sway-apps.sh
@@ -27,7 +27,10 @@ fi
 CHROMIUM_FLAGS="--kiosk --ozone-platform=wayland --noerr --no-first-run \
   --disable-infobars --disable-session-crashed-bubble \
   --disable-features=Translate --disable-web-security \
-  --autoplay-policy=no-user-gesture-required --no-sandbox"
+  --autoplay-policy=no-user-gesture-required --no-sandbox \
+  --disable-background-timer-throttling \
+  --disable-renderer-backgrounding \
+  --disable-backgrounding-occluded-windows"

 # Vänta tills kiosk-servern svarar
 until curl -sf -o /dev/null "$KIOSK_URL" 2>/dev/null; do sleep 1; done
diff --git a/kiosk/templates/addons/livechat/panel.html b/kiosk/templates/addons/livechat/panel.html
index 19479e6..e72bfc3 100644
--- a/kiosk/templates/addons/livechat/panel.html
+++ b/kiosk/templates/addons/livechat/panel.html
@@ -1,8 +1,6 @@
-    <!-- Sektion: Livechat -->
+    <!-- Sektion: Kiosk Livechat -->
     <section>
-      <h2>Livechat</h2>
-
-      <h3>Kiosk Livechat</h3>
+      <h2>Kiosk Livechat</h2>
       <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">
@@ -38,49 +36,4 @@
         <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/settings/panel.html b/kiosk/templates/addons/settings/panel.html
index a4c70df..f95aaa6 100644
--- a/kiosk/templates/addons/settings/panel.html
+++ b/kiosk/templates/addons/settings/panel.html
@@ -45,4 +45,20 @@
         <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>
+
+      <h3>Admin-konto</h3>
+      <div class="prop-grid">
+        <label>Nuvarande lösenord</label>
+        <input type="password" id="cred-current" placeholder="Nuvarande lösenord" autocomplete="current-password">
+
+        <label>Nytt användarnamn</label>
+        <input type="text" id="cred-user" placeholder="Användarnamn" autocomplete="username">
+
+        <label>Nytt lösenord</label>
+        <input type="password" id="cred-pass" placeholder="Minst 4 tecken" autocomplete="new-password">
+      </div>
+      <div class="row" style="margin-top:.5rem">
+        <button onclick="bytaLösenord()">Byt inloggning</button>
+        <span id="cred-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
     </section>
diff --git a/kiosk/templates/addons/youtube_livechat/panel.html b/kiosk/templates/addons/youtube_livechat/panel.html
new file mode 100644
index 0000000..8b6db2d
--- /dev/null
+++ b/kiosk/templates/addons/youtube_livechat/panel.html
@@ -0,0 +1,47 @@
+    <!-- Sektion: YouTube Live Chat -->
+    <section>
+      <h2>YouTube Live Chat</h2>
+      <p style="font-size:.85rem;color:#555;margin-bottom:.75rem">Visas automatiskt som overlay när en YouTube-liveström spelas upp. Döljs automatiskt för vanliga (inspelade) YouTube-videor.</p>
+
+      <div class="prop-grid">
+        <label>Position</label>
+        <select id="yt-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="yt-chat-width" min="5" max="60" step="1" value="25"
+                 oninput="document.getElementById('yt-chat-width-val').textContent=this.value+'%'">
+          <span id="yt-chat-width-val" style="color:#aaa;min-width:3rem">25%</span>
+        </div>
+
+        <label>Höjd</label>
+        <div class="row">
+          <input type="range" id="yt-chat-height" min="30" max="100" step="5" value="100"
+                 oninput="document.getElementById('yt-chat-height-val').textContent=this.value+'%'">
+          <span id="yt-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="yt-chat-bg-color" value="#000000">
+
+        <label>Transparens</label>
+        <div class="row">
+          <input type="range" id="yt-chat-bg-opacity" min="0" max="100" step="5" value="70"
+                 oninput="document.getElementById('yt-chat-bg-opacity-val').textContent=this.value+'%'">
+          <span id="yt-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="yt-chat-theme-btn" class="secondary">Chatt: Auto</button>
+        <span id="yt-chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+      </div>
+    </section>
diff --git a/kiosk/templates/admin.html b/kiosk/templates/admin.html
index e7c4d9d..aa05fb5 100644
--- a/kiosk/templates/admin.html
+++ b/kiosk/templates/admin.html
@@ -8,7 +8,10 @@
 </head>
 <body>
   <div class="container">
-    <h1>Kiosk Admin</h1>
+    <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem">
+      <h1 style="margin:0">Kiosk Admin</h1>
+      <a href="/logout" style="font-size:.85rem;color:#94a3b8;text-decoration:none;padding:.35rem .75rem;border:1px solid #334155;border-radius:5px" onmouseover="this.style.color='#f87171'" onmouseout="this.style.color='#94a3b8'">Logga ut</a>
+    </div>

     <!-- Föreslagna uppspelningar från chatten -->
     <div id="play-suggestions" style="display:none; margin-bottom:.75rem; flex-direction:column; gap:.4rem;"></div>
@@ -89,6 +92,11 @@
             <button type="button" class="secondary" onclick="extractJobPlaylist('job-form')">Extrahera spellista</button>
             <span id="job-extract-status" style="font-size:.75rem;color:#444"></span>
           </div>
+          <div id="job-shuffle-wrap" style="display:none;grid-column:1/-1">
+            <label style="display:flex;align-items:center;gap:.5rem;color:#aaa;font-size:.9rem">
+              <input type="checkbox" name="shuffle"> Slumpa ordning
+            </label>
+          </div>
           <div id="job-emu-wrap" style="display:none;grid-column:1/-1;flex-direction:column;gap:.25rem">
             <button type="button" class="secondary" onclick="openEmuBrowser('job-form')">Välj ROM-fil</button>
             <span id="job-emu-name" style="font-size:.8rem;color:#94a3b8;font-family:monospace"></span>
@@ -128,6 +136,11 @@
                   <button type="button" class="secondary" onclick="extractJobPlaylist('edit-form')">Extrahera spellista</button>
                   <span id="edit-extract-status" style="font-size:.75rem;color:#444"></span>
                 </div>
+                <div id="edit-shuffle-wrap" style="display:none;grid-column:1/-1">
+                  <label style="display:flex;align-items:center;gap:.5rem;color:#aaa;font-size:.9rem">
+                    <input type="checkbox" name="shuffle"> Slumpa ordning
+                  </label>
+                </div>
                 <div id="edit-emu-wrap" style="display:none;grid-column:1/-1;flex-direction:column;gap:.25rem">
                   <button type="button" class="secondary" onclick="openEmuBrowser('edit-form')">Välj ROM-fil</button>
                   <span id="edit-emu-name" style="font-size:.8rem;color:#94a3b8;font-family:monospace"></span>
diff --git a/kiosk/templates/emulator.html b/kiosk/templates/emulator.html
index 6f5f72e..447daba 100644
--- a/kiosk/templates/emulator.html
+++ b/kiosk/templates/emulator.html
@@ -29,6 +29,7 @@
     EJS_pathtodata  = '{{ ejs_path }}';
     EJS_language    = 'sv-SE';
     EJS_color       = '#8b5cf6';
+    EJS_startOnLoaded = true;

     EJS_Buttons = {
       playPause:    true,