foxygit / infodisplay Log in
commit e49cacc5171a807c67d729cc2990313a74cfb856
Author:     Jens Kristoffersson <jens.se@icloud.com>
AuthorDate: Sat Apr 4 13:09:53 2026 +0200
Commit:     Jens Kristoffersson <jens.se@icloud.com>
CommitDate: Sat Apr 4 13:09:53 2026 +0200

    wow
---
 kiosk/app.py                                       | 456 ++++++++++++++++++-
 kiosk/static/admin.js                              | 480 +++++++++++++++++++-
 .../4f4552c96a12496eb7da655257025324.png           | Bin 0 -> 1223975 bytes
 .../6a8ee0d719e044f1b6d8ca3ef100dce8.jpg           | Bin 0 -> 2239558 bytes
 .../77cb365c5f954a9d8f1d6b22315ab8f1.jpg           | Bin 0 -> 2313964 bytes
 .../997480987c574bf2b9c5027694cccaf3.png           | Bin 0 -> 1274853 bytes
 kiosk/static/display.js                            | 499 ++++++++++++++++++++-
 kiosk/templates/admin.html                         | 136 +++++-
 kiosk/templates/chat_display.html                  |  56 ++-
 kiosk/templates/chat_mobile.html                   | 350 ++++++++++++++-
 kiosk/templates/display.html                       |  36 +-
 11 files changed, 1986 insertions(+), 27 deletions(-)

diff --git a/kiosk/app.py b/kiosk/app.py
index 5499f33..66e894b 100644
--- a/kiosk/app.py
+++ b/kiosk/app.py
@@ -32,7 +32,20 @@ def _load_chat_users():
 def _save_chat_users(users):
     CHAT_USERS_FILE.write_text(_json.dumps(users, indent=2))

-_chat_tokens = {}  # token -> {username, emoji, color}
+_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',
@@ -52,6 +65,10 @@ def _get_avatar_url(username):
     return None


+# Återställ tokens nu när _get_avatar_url är definierad
+_restore_chat_tokens()
+
+
 # Load kiosk.conf (if present) into DB state so settings survive app restarts
 def _load_kiosk_conf():
     conf = Path(os.path.dirname(__file__)) / 'kiosk.conf'
@@ -281,13 +298,14 @@ def chat_register():
     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)
-    token = str(_uuid.uuid4())
     _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})

@@ -307,6 +325,8 @@ def chat_login():
     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})


@@ -317,6 +337,8 @@ def chat_validate():
     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')})


@@ -330,6 +352,28 @@ def serve_chat_avatar(username):
     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', '')
@@ -389,8 +433,7 @@ class ChatNamespace(Namespace):
         # 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('system_message', {'text': f'{username} gick med'}, namespace='/chat')
-        socketio.emit('viewer_count',   {'count': len(self._users)},       namespace='/chat')
+        socketio.emit('viewer_count', {'count': len(self._users)}, namespace='/chat')

     def on_chat_message(self, data):
         user = self._users.get(request.sid)
@@ -411,11 +454,35 @@ class ChatNamespace(Namespace):
             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('system_message', {'text': f"{user['name']} lämnade"}, namespace='/chat')
-            socketio.emit('viewer_count',   {'count': len(self._users)},           namespace='/chat')
+            socketio.emit('viewer_count', {'count': len(self._users)}, namespace='/chat')


 socketio.on_namespace(ChatNamespace('/chat'))
@@ -1203,6 +1270,187 @@ def set_clock():
     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():
@@ -1393,6 +1641,202 @@ def api_wifi_remove(net_id):
     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
+
+
+@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())
+
+
 # --- System ---

 @app.route('/api/reboot', methods=['POST'])
diff --git a/kiosk/static/admin.js b/kiosk/static/admin.js
index fc5ed01..de0920e 100644
--- a/kiosk/static/admin.js
+++ b/kiosk/static/admin.js
@@ -45,6 +45,24 @@ function youtubeEmbedUrl(url) {
   return `https://www.youtube.com/embed/${id}?autoplay=1`;
 }

+function extractYouTubePlaylistId(url) {
+  const m = url.match(/[?&]list=([a-zA-Z0-9_-]+)/);
+  return m ? m[1] : null;
+}
+
+function youtubePlaylistEmbedUrl(url) {
+  const listId = extractYouTubePlaylistId(url);
+  if (!listId) return null;
+  return `https://www.youtube.com/embed?listType=playlist&list=${listId}&autoplay=1&loop=1`;
+}
+
+function normalizeMusicYouTubeUrl(url) {
+  // Convert music.youtube.com or any YouTube playlist URL to an embeddable URL
+  const listId = extractYouTubePlaylistId(url);
+  if (!listId) return url;
+  return `https://www.youtube.com/embed?listType=playlist&list=${listId}&autoplay=1&loop=1&enablejsapi=1`;
+}
+

 function visaLokalFil(path, type) {
   const src = '/files?path=' + encodeURIComponent(path);
@@ -218,6 +236,60 @@ function sendReorder(tbody) {
   }).then(r => { if (r.ok) laddaJobb(); });
 }

+function _jobTypeChanged(formId, wrapId, statusId) {
+  const form = document.getElementById(formId);
+  const wrap = document.getElementById(wrapId);
+  if (!form || !wrap) return;
+  const isPlaylist = form.type.value === 'youtube_playlist';
+  wrap.style.display = isPlaylist ? 'flex' : 'none';
+  if (!isPlaylist && statusId) {
+    const s = document.getElementById(statusId);
+    if (s) s.textContent = '';
+  }
+}
+
+async function extractJobPlaylist(formId) {
+  const form = document.getElementById(formId);
+  const isAdd = formId === 'job-form';
+  const statusId = isAdd ? 'job-extract-status' : 'edit-extract-status';
+  const status = document.getElementById(statusId);
+  const url = form.source.value.trim();
+  if (!url) { status.textContent = 'Ange URL först.'; return; }
+  status.style.color = '#444';
+  status.textContent = 'Hämtar spellista…';
+  try {
+    const res = await apiFetch('/api/playlist/extract', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ url }),
+    });
+    const d = await res.json();
+    if (!res.ok) {
+      status.textContent = '⚠ ' + (d.error || 'Okänt fel');
+    } else {
+      const totalMin = d.total_seconds ? (d.total_seconds / 60) : null;
+      // Auto-fyll duration om fältet är tomt
+      if (totalMin && !form.duration.value) {
+        form.duration.value = Math.round(totalMin * 100) / 100;
+      }
+      const timeStr = totalMin ? _fmtDuration(d.total_seconds) : '';
+      status.style.color = '#166534';
+      status.textContent = `✓ ${d.count} videos hämtade${timeStr ? ' · total speltid ' + timeStr : ''}.`;
+    }
+  } catch (err) {
+    status.textContent = '⚠ ' + err.message;
+  }
+}
+
+function _fmtDuration(secs) {
+  const h = Math.floor(secs / 3600);
+  const m = Math.floor((secs % 3600) / 60);
+  const s = secs % 60;
+  if (h > 0) return `${h}h ${m}m`;
+  if (m > 0) return `${m}m ${s}s`;
+  return `${s}s`;
+}
+
 async function laggTillJobb(e) {
   e.preventDefault();
   const form = e.target;
@@ -229,6 +301,9 @@ async function laggTillJobb(e) {
     if (!embed) { alert('Ogiltig YouTube-URL'); return; }
     type = 'url';
     source = embed;
+  } else if (type === 'youtube_playlist') {
+    if (!extractYouTubePlaylistId(source)) { alert('Ogiltig YouTube-spellisteURL (måste innehålla ?list=...)'); return; }
+    // Behåll type=youtube_playlist och rå URL — display cyclar via IFrame API
   } else if (type === 'kiosk_chat') {
     source = '/chat-display';
   }
@@ -260,11 +335,12 @@ function openEdit(id) {
     if (!job) return;
     const modal = document.getElementById('edit-modal');
     const form = document.getElementById('edit-form');
-    form.id.value = job.id;
+    form.elements.namedItem('id').value = job.id;
     form.label.value = job.label;
     form.duration.value = job.duration;
     form.type.value = job.type;
     form.source.value = job.source;
+    _jobTypeChanged('edit-form', 'edit-extract-wrap', 'edit-extract-status');
     modal.style.display = 'flex';
   });
 }
@@ -858,6 +934,236 @@ async function wifiRemove(id, ssid) {
   } catch {}
 }

+// --- Bluetooth ---
+
+let _btPowered    = true;
+let _btScanning   = false;
+let _btScanTimer  = null;
+
+function _btDeviceIcon(type) {
+  if (type === 'audio')  return '🔊';
+  if (type === 'phone')  return '📱';
+  if (type === 'input')  return '⌨';
+  return '📶';
+}
+
+function _btDeviceRow(dev, section) {
+  const icon     = _btDeviceIcon(dev.type);
+  const label    = escHtml(dev.name || dev.address);
+  const addrSpan = dev.name
+    ? `<span style="font-size:.75rem;color:#555;margin-left:.4rem;font-family:monospace">${escHtml(dev.address)}</span>`
+    : '';
+  const nameHtml = `<span style="flex:1">${icon} ${label}${addrSpan}</span>`;
+
+  if (section === 'found') {
+    return `
+      <div style="display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--surface);border-radius:6px">
+        ${nameHtml}
+        <button class="secondary" style="padding:.2rem .6rem;font-size:.8rem" onclick="btPair('${escHtml(dev.address)}','${escHtml(dev.name)}')">Koppla</button>
+      </div>`;
+  }
+
+  const connBtn = dev.connected
+    ? `<button class="secondary" style="padding:.2rem .6rem;font-size:.8rem" onclick="btDisconnect('${escHtml(dev.address)}')">Koppla från</button>`
+    : `<button class="secondary" style="padding:.2rem .6rem;font-size:.8rem" onclick="btConnect('${escHtml(dev.address)}')">Anslut</button>`;
+  const connDot = dev.connected
+    ? `<span style="color:#22c55e;font-size:.75rem">● ansluten</span>`
+    : `<span style="color:#555;font-size:.75rem">○ ej ansluten</span>`;
+
+  return `
+    <div style="display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--surface);border-radius:6px">
+      ${nameHtml}
+      ${connDot}
+      ${connBtn}
+      <button class="danger" style="padding:.2rem .6rem;font-size:.8rem" onclick="btRemove('${escHtml(dev.address)}','${escHtml(dev.name || dev.address)}')">Ta bort</button>
+    </div>`;
+}
+
+async function laddaBluetooth() {
+  try {
+    const [stRes, devRes] = await Promise.all([
+      apiFetch('/api/bluetooth/status'),
+      apiFetch('/api/bluetooth/devices'),
+    ]);
+    if (!stRes || !devRes) return;
+    const st  = await stRes.json();
+    const dev = await devRes.json();
+
+    _btPowered  = st.powered;
+    _btScanning = dev.scanning;
+
+    const dot      = document.getElementById('bt-status-dot');
+    const nameEl   = document.getElementById('bt-adapter-name');
+    const addrEl   = document.getElementById('bt-adapter-addr');
+    const powerBtn = document.getElementById('bt-power-btn');
+    const scanBtn  = document.getElementById('bt-scan-btn');
+
+    if (!st.available) {
+      dot.style.background = '#ef4444';
+      nameEl.textContent = 'Bluetooth ej tillgängligt';
+      return;
+    }
+    dot.style.background = st.powered ? '#22c55e' : '#555';
+    nameEl.textContent = st.name || 'Bluetooth';
+    addrEl.textContent = st.address || '';
+    powerBtn.textContent = st.powered ? 'Stäng av adapter' : 'Slå på adapter';
+
+    const known  = dev.devices || [];
+    const paired = known.filter(d => d.paired);
+    const knownList = document.getElementById('bt-known-list');
+    if (paired.length) {
+      knownList.innerHTML = paired.map(d => _btDeviceRow(d, 'known')).join('');
+    } else {
+      knownList.innerHTML = '<span style="color:#555;font-style:italic">Inga kopplade enheter</span>';
+    }
+
+    if (_btScanning) {
+      scanBtn.disabled = true;
+      document.getElementById('bt-scan-status').textContent = 'Skannar... (15 s)';
+      _btPollScan();
+    }
+  } catch (e) {
+    document.getElementById('bt-adapter-name').textContent = 'Fel: ' + e.message;
+  }
+}
+
+async function btScan() {
+  if (_btScanning) return;
+  const btn    = document.getElementById('bt-scan-btn');
+  const status = document.getElementById('bt-scan-status');
+  const wrap   = document.getElementById('bt-found-wrap');
+  const list   = document.getElementById('bt-found-list');
+
+  btn.disabled = true;
+  status.textContent = 'Skannar... (15 s)';
+  list.innerHTML = '<span style="color:#555;font-style:italic">Söker...</span>';
+  wrap.style.display = 'block';
+
+  try {
+    const res = await apiFetch('/api/bluetooth/scan', { method: 'POST' });
+    if (!res) { btn.disabled = false; return; }
+    _btScanning = true;
+    _btPollScan();
+  } catch (e) {
+    status.textContent = 'Fel: ' + e.message;
+    btn.disabled = false;
+  }
+}
+
+function _btPollScan() {
+  if (_btScanTimer) clearInterval(_btScanTimer);
+
+  const knownAddrs = new Set();
+  document.querySelectorAll('#bt-known-list [data-btaddr]').forEach(el => knownAddrs.add(el.dataset.btaddr));
+
+  _btScanTimer = setInterval(async () => {
+    try {
+      const res = await apiFetch('/api/bluetooth/devices');
+      if (!res) return;
+      const d = await res.json();
+
+      // Uppdatera kopplade enheter (parade)
+      const known  = (d.devices || []);
+      const paired = known.filter(k => k.paired);
+      const knownList = document.getElementById('bt-known-list');
+      knownList.innerHTML = paired.length
+        ? paired.map(dev => _btDeviceRow(dev, 'known')).join('')
+        : '<span style="color:#555;font-style:italic">Inga kopplade enheter</span>';
+
+      // Visa hittade men ej parade enheter under "Hittade enheter"
+      const foundList = document.getElementById('bt-found-list');
+      const found = known.filter(k => !k.paired);
+      if (found.length) {
+        foundList.innerHTML = found.map(dev => _btDeviceRow(dev, 'found')).join('');
+      }
+
+      if (!d.scanning) {
+        clearInterval(_btScanTimer);
+        _btScanTimer  = null;
+        _btScanning   = false;
+        document.getElementById('bt-scan-btn').disabled   = false;
+        document.getElementById('bt-scan-status').textContent = found.length + ' enhet(er) hittades';
+        if (!found.length) {
+          document.getElementById('bt-found-list').innerHTML = '<span style="color:#555;font-style:italic">Inga nya enheter hittades</span>';
+        }
+      }
+    } catch {}
+  }, 2500);
+}
+
+async function btPair(addr, name) {
+  const status = document.getElementById('bt-scan-status');
+  status.textContent = `Kopplar ${name}...`;
+  try {
+    const res = await apiFetch('/api/bluetooth/pair', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ address: addr }),
+    });
+    if (!res) return;
+    const d = await res.json();
+    if (d.ok) {
+      status.textContent = `✓ ${name} kopplad`;
+      await laddaBluetooth();
+    } else {
+      status.textContent = `✗ ${d.message || 'Misslyckades'}`;
+    }
+  } catch (e) {
+    status.textContent = 'Fel: ' + e.message;
+  }
+}
+
+async function btConnect(addr) {
+  const status = document.getElementById('bt-scan-status');
+  status.textContent = 'Ansluter...';
+  try {
+    const res = await apiFetch('/api/bluetooth/connect', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ address: addr }),
+    });
+    if (!res) return;
+    const d = await res.json();
+    status.textContent = d.ok ? '✓ Ansluten' : '✗ ' + (d.message || 'Misslyckades');
+    await laddaBluetooth();
+  } catch (e) {
+    status.textContent = 'Fel: ' + e.message;
+  }
+}
+
+async function btDisconnect(addr) {
+  await apiFetch('/api/bluetooth/disconnect', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ address: addr }),
+  });
+  await laddaBluetooth();
+}
+
+async function btRemove(addr, name) {
+  if (!confirm(`Ta bort "${name}" från kopplade enheter?`)) return;
+  await apiFetch('/api/bluetooth/device/' + encodeURIComponent(addr), { method: 'DELETE' });
+  await laddaBluetooth();
+}
+
+async function btTogglePower() {
+  const btn = document.getElementById('bt-power-btn');
+  btn.disabled = true;
+  try {
+    const res = await apiFetch('/api/bluetooth/power', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ on: !_btPowered }),
+    });
+    if (!res) { btn.disabled = false; return; }
+    const st = await res.json();
+    _btPowered = st.powered;
+    document.getElementById('bt-status-dot').style.background = st.powered ? '#22c55e' : '#555';
+    btn.textContent = st.powered ? 'Stäng av adapter' : 'Slå på adapter';
+  } catch {}
+  btn.disabled = false;
+}
+
 // --- Klocka ---

 let _clockPos = 'top-right';
@@ -907,6 +1213,176 @@ async function _sendClock(enabled) {
 function aktiveraClock()  { _sendClock(true);  }
 function avaktiveraClock() { _sendClock(false); }

+// --- Musik ---
+
+function musicSourceTypeChanged() {
+  const isYt = document.getElementById('music-source-type').value === 'youtube';
+  document.getElementById('music-local-label').style.display = isYt ? 'none' : '';
+  document.getElementById('music-local-wrap').style.display  = isYt ? 'none' : '';
+  document.getElementById('music-yt-label').style.display    = isYt ? '' : 'none';
+  document.getElementById('music-yt-wrap').style.display     = isYt ? '' : 'none';
+  document.getElementById('music-track-list-wrap').style.display = 'none';
+}
+
+function openMusicFolderBrowser() {
+  // Öppna filbläddraren men tillåt val av mapp
+  _browserCallback = (path) => {
+    // Användaren valde en fil — ta mappens sökväg
+    const folder = path.includes('/') ? path.substring(0, path.lastIndexOf('/')) : path;
+    document.getElementById('music-source-local').value = folder;
+    laddaMusicTracks(folder);
+  };
+  document.getElementById('browser-modal').style.display = 'flex';
+  browseTo('/home/mrfox');
+}
+
+async function laddaMusicTracks(folder) {
+  if (!folder) return;
+  const wrap = document.getElementById('music-track-list-wrap');
+  const list = document.getElementById('music-track-list');
+  list.textContent = 'Laddar...';
+  wrap.style.display = 'block';
+  try {
+    const res = await apiFetch('/api/music/tracks?path=' + encodeURIComponent(folder));
+    if (!res) return;
+    const tracks = await res.json();
+    if (!tracks.length) {
+      list.textContent = 'Inga musikfiler hittades i mappen.';
+    } else {
+      list.innerHTML = tracks.map((t, i) =>
+        `<div>${i + 1}. ${escHtml(t.name)}</div>`
+      ).join('');
+    }
+  } catch {
+    list.textContent = 'Kunde inte lista spår.';
+  }
+}
+
+async function laddaMusik() {
+  const res = await apiFetch('/api/music');
+  if (!res) return;
+  const d = await res.json();
+  document.getElementById('music-source-type').value       = d.source_type || 'local';
+  document.getElementById('music-source-local').value      = d.source_type === 'local'   ? d.source : '';
+  document.getElementById('music-source-youtube').value    = d.source_type === 'youtube' ? d.source : '';
+  document.getElementById('music-volume').value            = d.volume != null ? d.volume : 50;
+  document.getElementById('music-volume-val').textContent  = (d.volume != null ? d.volume : 50) + '%';
+  document.getElementById('music-shuffle').checked         = !!d.shuffle;
+  document.getElementById('music-show-overlay').checked    = d.show_overlay !== false;
+  document.getElementById('music-overlay-position').value  = d.overlay_position || 'bottom-left';
+  document.getElementById('music-status').textContent      = d.enabled ? '● Aktiv' : '';
+  musicSourceTypeChanged();
+
+  // Visa spårlista om lokal mapp är satt
+  if (d.source_type === 'local' && d.source) {
+    await laddaMusicTracks(d.source);
+  }
+
+  // Visa "Spelar nu"-banner om musik spelas
+  if (d.now_playing && (d.now_playing.title || d.now_playing.artist)) {
+    _updateNowPlayingBanner(d.now_playing.title, d.now_playing.artist);
+  }
+}
+
+async function extractMusicPlaylist() {
+  const url = document.getElementById('music-source-youtube').value.trim();
+  const status = document.getElementById('music-extract-status');
+  const btn = document.getElementById('music-extract-btn');
+  if (!url) { status.textContent = 'Ange en URL först.'; return; }
+  btn.disabled = true;
+  status.textContent = 'Hämtar spellista…';
+  try {
+    const res = await apiFetch('/api/music/extract', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ url }),
+    });
+    const d = await res.json();
+    if (!res.ok) {
+      status.textContent = '⚠ ' + (d.error || 'Okänt fel');
+    } else {
+      status.style.color = '#166534';
+      status.textContent = `✓ ${d.count} låtar hämtade och redo att spelas.`;
+    }
+  } catch (e) {
+    status.textContent = '⚠ Nätverksfel: ' + e.message;
+  }
+  btn.disabled = false;
+}
+
+async function setMusik(enabled) {
+  const sourceType = document.getElementById('music-source-type').value;
+  const rawSource = sourceType === 'youtube'
+    ? document.getElementById('music-source-youtube').value.trim()
+    : document.getElementById('music-source-local').value.trim();
+  const source = sourceType === 'youtube' ? normalizeMusicYouTubeUrl(rawSource) : rawSource;
+
+  const res = await apiFetch('/api/music', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      enabled,
+      source_type:      sourceType,
+      source,
+      volume:           parseInt(document.getElementById('music-volume').value, 10),
+      shuffle:          document.getElementById('music-shuffle').checked,
+      show_overlay:     document.getElementById('music-show-overlay').checked,
+      overlay_position: document.getElementById('music-overlay-position').value,
+    }),
+  });
+  if (!res) return;
+  document.getElementById('music-status').textContent = enabled ? '● Aktiv' : '';
+}
+
+function _updateNowPlayingBanner(title, artist) {
+  const banner = document.getElementById('now-playing-banner');
+  const text   = document.getElementById('np-banner-text');
+  if (!banner) return;
+  if (title || artist) {
+    const label = artist ? `${artist} — ${title}` : title;
+    text.textContent = 'Spelar nu: ' + label;
+    banner.style.display = 'flex';
+  } else {
+    banner.style.display = 'none';
+  }
+}
+
+// Lyssna på now_playing-händelser från servern
+socket.on('now_playing', (data) => {
+  _updateNowPlayingBanner(data.title || '', data.artist || '');
+});
+
+// --- Föreslagna uppspelningar från chatten ---
+
+socket.on('play_suggestion', (data) => {
+  const area = document.getElementById('play-suggestions');
+  if (!area) return;
+
+  const url   = data.url || '';
+  const short = url.length > 55 ? url.slice(0, 52) + '…' : url;
+
+  const item = document.createElement('div');
+  item.style.cssText = 'display:flex;align-items:center;gap:.5rem;padding:.45rem .75rem;background:rgba(129,140,248,.1);border:1px solid rgba(129,140,248,.25);border-radius:8px;font-size:.88rem;';
+  item.innerHTML = `
+    <span style="font-size:1rem;flex-shrink:0">🎬</span>
+    <span style="flex:1;min-width:0"><b style="color:#c4b5fd">${escHtml(data.name)}</b> vill visa: <span style="color:#818cf8;word-break:break-all">${escHtml(short)}</span></span>
+    <button class="secondary" style="padding:.25rem .65rem;font-size:.82rem;flex-shrink:0" onclick="visaSuggestion('${escHtml(url)}',this.closest('div'))">Spela nu</button>
+    <button style="background:none;border:none;color:#555;cursor:pointer;font-size:1.1rem;line-height:1;padding:.1rem .3rem;flex-shrink:0" onclick="this.closest('div').remove();_updateSuggestionsVisibility()">✕</button>`;
+  area.prepend(item);
+  area.style.display = 'flex';
+});
+
+function _updateSuggestionsVisibility() {
+  const area = document.getElementById('play-suggestions');
+  if (area && !area.children.length) area.style.display = 'none';
+}
+
+function visaSuggestion(url, itemEl) {
+  const embed = youtubeEmbedUrl(url) || youtubePlaylistEmbedUrl(url);
+  socket.emit('admin_switch', { type: 'url', source: embed || url });
+  if (itemEl) { itemEl.remove(); _updateSuggestionsVisibility(); }
+}
+
 // --- Sovläge ---

 async function laddaSovlage() {
@@ -1047,7 +1523,9 @@ async function init() {
   await laddaTicker();
   await laddaChat();
   await laddaClock();
+  await laddaMusik();
   await laddaWifiStatus();
+  await laddaBluetooth();
   await laddaWifiSaved();
   await laddaSovlage();
   setInterval(laddaSysinfo, 10000);
diff --git a/kiosk/static/chat_images/4f4552c96a12496eb7da655257025324.png b/kiosk/static/chat_images/4f4552c96a12496eb7da655257025324.png
new file mode 100644
index 0000000..c4007e2
Binary files /dev/null and b/kiosk/static/chat_images/4f4552c96a12496eb7da655257025324.png differ
diff --git a/kiosk/static/chat_images/6a8ee0d719e044f1b6d8ca3ef100dce8.jpg b/kiosk/static/chat_images/6a8ee0d719e044f1b6d8ca3ef100dce8.jpg
new file mode 100644
index 0000000..eb2bb24
Binary files /dev/null and b/kiosk/static/chat_images/6a8ee0d719e044f1b6d8ca3ef100dce8.jpg differ
diff --git a/kiosk/static/chat_images/77cb365c5f954a9d8f1d6b22315ab8f1.jpg b/kiosk/static/chat_images/77cb365c5f954a9d8f1d6b22315ab8f1.jpg
new file mode 100644
index 0000000..a2c25c5
Binary files /dev/null and b/kiosk/static/chat_images/77cb365c5f954a9d8f1d6b22315ab8f1.jpg differ
diff --git a/kiosk/static/chat_images/997480987c574bf2b9c5027694cccaf3.png b/kiosk/static/chat_images/997480987c574bf2b9c5027694cccaf3.png
new file mode 100644
index 0000000..6d75511
Binary files /dev/null and b/kiosk/static/chat_images/997480987c574bf2b9c5027694cccaf3.png differ
diff --git a/kiosk/static/display.js b/kiosk/static/display.js
index 8569e02..9ea47de 100644
--- a/kiosk/static/display.js
+++ b/kiosk/static/display.js
@@ -272,11 +272,17 @@ _kcSocket.on('chat_history', (msgs) => {
   const container = document.getElementById('kc-msgs');
   if (container) container.innerHTML = '';
   _kcMsgs.length = 0;
-  msgs.forEach(m => _kcAddMsg(m.name, m.text, m.color, m.emoji, m.avatar_url));
+  msgs.forEach(m => {
+    if (m.kind === 'play_suggestion') _kcAddSuggestion(m);
+    else _kcAddMsg(m.name, m.text, m.color, m.emoji, m.avatar_url);
+  });
 });
 _kcSocket.on('chat_message', (d) => {
   _kcAddMsg(d.name, d.text, d.color, d.emoji, d.avatar_url);
 });
+_kcSocket.on('play_suggestion', (d) => {
+  _kcAddSuggestion(d);
+});
 _kcSocket.on('system_message', (d) => {
   _kcAddSystem(d.text);
 });
@@ -285,6 +291,17 @@ _kcSocket.on('viewer_count', (d) => {
   if (el) el.textContent = d.count + ' aktiva';
 });

+const _KC_IMG_RE = /^(?:https?:\/\/|\/)[^\s"<>]+\.(gif|jpe?g|png|webp|avif)(\?[^\s"<>]*)?$/i;
+
+function _kcRenderContent(text) {
+  const t = text.trim();
+  if (_KC_IMG_RE.test(t)) {
+    const safe = t.replace(/"/g, '%22');
+    return `<img src="${safe}" style="max-width:180px;max-height:140px;border-radius:7px;display:block;margin-top:.2rem" loading="lazy" onerror="this.style.display='none'">`;
+  }
+  return _kcEsc(text);
+}
+
 function _kcAvatarHtml(emoji, name, avatarUrl) {
   if (avatarUrl) return `<img src="${avatarUrl}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
   return _kcEsc(emoji || _kcInitial(name));
@@ -297,7 +314,25 @@ function _kcAddMsg(name, text, color, emoji, avatarUrl) {
     <div class="kc-avatar" style="background:${color}22;color:${color}">${_kcAvatarHtml(emoji, name, avatarUrl)}</div>
     <div class="kc-body">
       <div class="kc-name" style="color:${color}">${_kcEsc(name)}</div>
-      <div class="kc-text">${_kcEsc(text)}</div>
+      <div class="kc-text">${_kcRenderContent(text)}</div>
+    </div>`;
+  _kcAppend(el);
+}
+
+function _kcAddSuggestion(d) {
+  const short = (d.text || d.url || '').length > 50
+    ? (d.text || d.url).slice(0, 47) + '…'
+    : (d.text || d.url);
+  const el = document.createElement('div');
+  el.className = 'kc-msg';
+  el.innerHTML = `
+    <div class="kc-avatar" style="background:${d.color}22;color:${d.color}">${_kcAvatarHtml(d.emoji, d.name, d.avatar_url)}</div>
+    <div class="kc-body">
+      <div class="kc-name" style="color:${d.color}">${_kcEsc(d.name)}</div>
+      <div style="border-left:3px solid #818cf8;padding:.3rem .5rem;background:#818cf815;border-radius:0 6px 6px 0;margin-top:.15rem">
+        <div style="font-size:.68rem;color:#818cf8;margin-bottom:.1rem">🎬 Förslag till displayen</div>
+        <div style="font-size:.82rem;color:#e8e8e8;word-break:break-all">${_kcEsc(short)}</div>
+      </div>
     </div>`;
   _kcAppend(el);
 }
@@ -550,6 +585,11 @@ socket.on('switch', (data) => {
     _refreshClock();
   }

+  // Stop video playlist player when switching away from youtube_playlist
+  if (_currentType === 'youtube_playlist' && data.type !== 'youtube_playlist') {
+    if (_vpl.player) { try { _vpl.player.stopVideo(); } catch {} }
+  }
+
   // 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;
@@ -574,6 +614,9 @@ socket.on('switch', (data) => {
     document.getElementById('image').src = data.source;
     showLayer('image');

+  } else if (data.type === 'youtube_playlist') {
+    _startVideoPlaylist(data.source);
+
   } else if (data.type === 'pdf' || data.type === 'game') {
     document.getElementById('frame').src = data.source;
     showLayer('iframe');
@@ -594,6 +637,458 @@ socket.on('switch', (data) => {

 socket.on('ticker', applyTicker);

+// --- Video-spellista (youtube_playlist) ---
+
+let _vpl = {
+  ids: [],
+  index: 0,
+  shuffle: false,
+  shuffleOrder: [],
+  shufflePos: 0,
+  player: null,
+  url: '',       // senaste playlist-URL (för cache-nyckel)
+};
+
+function _vplNext() {
+  if (!_vpl.ids.length) return;
+  if (_vpl.shuffle) {
+    _vpl.shufflePos++;
+    if (_vpl.shufflePos >= _vpl.shuffleOrder.length) {
+      _vpl.shuffleOrder = _musicShuffle(_vpl.ids.map((_, i) => i));
+      _vpl.shufflePos = 0;
+    }
+    _vpl.index = _vpl.shuffleOrder[_vpl.shufflePos];
+  } else {
+    _vpl.index = (_vpl.index + 1) % _vpl.ids.length;
+  }
+  if (_vpl.player) _vpl.player.loadVideoById(_vpl.ids[_vpl.index]);
+}
+
+function _vplStart(ids) {
+  if (!window.YT || !window.YT.Player) {
+    // API inte laddat än — försök igen om 500ms
+    setTimeout(() => _vplStart(ids), 500);
+    return;
+  }
+  _vpl.ids = ids;
+  _vpl.index = 0;
+  if (_vpl.shuffle) {
+    _vpl.shuffleOrder = _musicShuffle(ids.map((_, i) => i));
+    _vpl.shufflePos = 0;
+    _vpl.index = _vpl.shuffleOrder[0];
+  }
+  const videoId = ids[_vpl.index];
+
+  // Rensa gamla spelaren
+  if (_vpl.player) {
+    try { _vpl.player.destroy(); } catch {}
+    _vpl.player = null;
+  }
+
+  // Återskapa <iframe id="frame"> (YT.Player ersätter den)
+  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);
+
+  _vpl.player = new YT.Player('frame', {
+    videoId,
+    playerVars: {
+      autoplay: 1,
+      controls: 1,
+      rel: 0,
+      modestbranding: 1,
+      origin: window.location.origin,
+    },
+    events: {
+      onReady(e) { e.target.playVideo(); },
+      onStateChange(e) {
+        if (e.data === YT.PlayerState.ENDED) _vplNext();
+      },
+      onError(_e) { _vplNext(); },
+    },
+  });
+}
+
+async function _startVideoPlaylist(url) {
+  _vpl.url = url;
+  showLayer('iframe');
+
+  // Försök hämta cachade IDs
+  let ids = [];
+  try {
+    const r = await fetch('/api/playlist/ids?url=' + encodeURIComponent(url));
+    if (r.ok) {
+      const d = await r.json();
+      ids = d.ids || [];
+    }
+  } catch {}
+
+  if (!ids.length) {
+    // Inte extraherat än — extrahera nu (kan ta ~10s)
+    document.getElementById('frame').src = '';
+    try {
+      const r = await fetch('/api/playlist/extract', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ url }),
+      });
+      if (r.ok) {
+        const d = await r.json();
+        ids = d.ids || [];
+      }
+    } catch {}
+  }
+
+  if (!ids.length) {
+    console.warn('[VideoPlaylist] Inga videos hittades för', url);
+    return;
+  }
+
+  if (_music.ytApiReady) {
+    _vplStart(ids);
+  } else {
+    // Vänta på YT API
+    const orig = window.onYouTubeIframeAPIReady;
+    window.onYouTubeIframeAPIReady = function() {
+      if (orig) orig();
+      _vplStart(ids);
+    };
+  }
+}
+
 socket.on('sleep_toggle', (data) => {
   document.getElementById('layer-sleep').style.display = data.active ? 'block' : 'none';
 });
+
+// --- Musikspelaren ---
+
+let _music = {
+  enabled: false,
+  source_type: 'local',   // 'local' | 'youtube'
+  source: '',
+  volume: 50,
+  shuffle: false,
+  show_overlay: true,
+  overlay_position: 'bottom-left',
+  tracks: [],             // [{name, path}] för lokal
+  trackIndex: 0,
+  shuffleOrder: [],
+  shufflePos: 0,
+  // YouTube IFrame Player
+  ytPlayer: null,
+  ytIds: [],              // extraherade video-ID:n
+  ytIndex: 0,
+  ytShuffleOrder: [],
+  ytShufflePos: 0,
+  ytApiReady: false,
+};
+
+// Ladda YouTube IFrame Player API en gång
+(function() {
+  if (window._ytApiLoading) return;
+  window._ytApiLoading = true;
+  const tag = document.createElement('script');
+  tag.src = 'https://www.youtube.com/iframe_api';
+  document.head.appendChild(tag);
+})();
+
+window.onYouTubeIframeAPIReady = function() {
+  _music.ytApiReady = true;
+  // Om musik redan är aktiverat och väntar på API
+  if (_music.enabled && _music.source_type === 'youtube' && _music.ytIds.length) {
+    _musicYtStartPlayer();
+  }
+};
+
+function _musicYtStartPlayer() {
+  if (!window.YT || !window.YT.Player) return;
+
+  const ids = _music.ytIds;
+  if (!ids.length) return;
+
+  // Bestäm vilket index som ska spelas
+  let startIdx = _music.ytIndex;
+  if (_music.shuffle) {
+    if (!_music.ytShuffleOrder.length) {
+      _music.ytShuffleOrder = _musicShuffle(ids.map((_, i) => i));
+      _music.ytShufflePos = 0;
+    }
+    startIdx = _music.ytShuffleOrder[_music.ytShufflePos % _music.ytShuffleOrder.length];
+  }
+  const videoId = ids[startIdx];
+
+  if (_music.ytPlayer) {
+    // Byt video utan att skapa ny player
+    _music.ytPlayer.loadVideoById(videoId);
+    _music.ytPlayer.setVolume(_music.volume);
+    _musicUpdateNowPlaying('Laddar...', '');
+    return;
+  }
+
+  // Ta bort gammal iframe och skapa ny div som YT.Player tar över
+  const oldFrame = document.getElementById('music-yt-frame');
+  if (oldFrame) oldFrame.remove();
+  const div = document.createElement('div');
+  div.id = 'music-yt-frame';
+  document.body.appendChild(div);
+
+  _music.ytPlayer = new YT.Player('music-yt-frame', {
+    width: '320',
+    height: '180',
+    videoId: videoId,
+    playerVars: {
+      autoplay: 1,
+      controls: 0,
+      disablekb: 1,
+      fs: 0,
+      rel: 0,
+      modestbranding: 1,
+      origin: window.location.origin,
+    },
+    events: {
+      onReady(e) {
+        e.target.setVolume(_music.volume);
+        e.target.playVideo();
+        _musicUpdateNowPlaying('YouTube-spellista', '');
+        // Flytta iframen off-screen
+        const iframe = document.getElementById('music-yt-frame');
+        if (iframe) {
+          iframe.style.cssText = 'position:fixed;width:320px;height:180px;left:-400px;top:0;pointer-events:none;border:none;';
+        }
+      },
+      onStateChange(e) {
+        if (e.data === YT.PlayerState.ENDED) {
+          _musicYtNext();
+        }
+      },
+      onError(_e) {
+        // Hoppa till nästa vid fel (blockerad video etc.)
+        _musicYtNext();
+      },
+    },
+  });
+}
+
+function _musicYtNext() {
+  if (!_music.ytIds.length) return;
+  if (_music.shuffle) {
+    _music.ytShufflePos++;
+    if (_music.ytShufflePos >= _music.ytShuffleOrder.length) {
+      _music.ytShuffleOrder = _musicShuffle(_music.ytIds.map((_, i) => i));
+      _music.ytShufflePos = 0;
+    }
+    _music.ytIndex = _music.ytShuffleOrder[_music.ytShufflePos];
+  } else {
+    _music.ytIndex = (_music.ytIndex + 1) % _music.ytIds.length;
+  }
+  if (_music.ytPlayer) {
+    _music.ytPlayer.loadVideoById(_music.ytIds[_music.ytIndex]);
+  }
+}
+
+function _musicParseFilename(filename) {
+  // Ta bort filändelse
+  let name = filename.replace(/\.[^.]+$/, '');
+  // Försök tolka "Artist - Titel" eller "Nr. Artist - Titel"
+  name = name.replace(/^\d+[\.\-\s]+/, ''); // ta bort spårnummer
+  const dashIdx = name.indexOf(' - ');
+  if (dashIdx > 0) {
+    return { artist: name.slice(0, dashIdx).trim(), title: name.slice(dashIdx + 3).trim() };
+  }
+  return { artist: '', title: name.trim() };
+}
+
+function _musicShuffle(arr) {
+  const a = arr.slice();
+  for (let i = a.length - 1; i > 0; i--) {
+    const j = Math.floor(Math.random() * (i + 1));
+    [a[i], a[j]] = [a[j], a[i]];
+  }
+  return a;
+}
+
+function _musicSetVolume(vol) {
+  const audio = document.getElementById('music-player');
+  if (audio) audio.volume = vol / 100;
+}
+
+function _musicUpdateNowPlaying(title, artist) {
+  const overlay = document.getElementById('now-playing-overlay');
+  if (!overlay) return;
+
+  document.getElementById('now-playing-title').textContent  = title;
+  document.getElementById('now-playing-artist').textContent = artist;
+
+  if (_music.enabled && _music.show_overlay && (title || artist)) {
+    overlay.style.display = 'block';
+    _musicPositionOverlay(overlay);
+    // Restart animation
+    const inner = document.getElementById('now-playing-inner');
+    inner.style.animation = 'none';
+    inner.offsetHeight;
+    inner.style.animation = 'np-fadein .4s ease';
+  } else {
+    overlay.style.display = 'none';
+  }
+
+  // Rapportera till server (admin-visning)
+  fetch('/api/music/nowplaying', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ title, artist }),
+  }).catch(() => {});
+}
+
+function _musicPositionOverlay(overlay) {
+  const pos = _music.overlay_position || 'bottom-left';
+  const pad = 16;
+  const ticker = document.getElementById('ticker');
+  const tickerH = (ticker && ticker.offsetHeight > 0) ? ticker.offsetHeight : 0;
+  const topOff  = (ticker && ticker.classList.contains('top'))    ? tickerH : 0;
+  const botOff  = (ticker && ticker.classList.contains('bottom')) ? tickerH : 0;
+
+  overlay.style.top = overlay.style.bottom = overlay.style.left = overlay.style.right = '';
+  if (pos === 'top-left')     { overlay.style.top    = (pad + topOff) + 'px'; overlay.style.left  = pad + 'px'; }
+  if (pos === 'top-right')    { overlay.style.top    = (pad + topOff) + 'px'; overlay.style.right = pad + 'px'; }
+  if (pos === 'bottom-left')  { overlay.style.bottom = (pad + botOff) + 'px'; overlay.style.left  = pad + 'px'; }
+  if (pos === 'bottom-right') { overlay.style.bottom = (pad + botOff) + 'px'; overlay.style.right = pad + 'px'; }
+}
+
+async function _musicLoadTracks(folder) {
+  try {
+    const r = await fetch('/api/music/tracks?path=' + encodeURIComponent(folder));
+    if (!r.ok) return [];
+    return await r.json();
+  } catch { return []; }
+}
+
+function _musicPlayTrack(idx) {
+  const audio = document.getElementById('music-player');
+  if (!audio || !_music.tracks.length) return;
+
+  const realIdx = _music.shuffle
+    ? _music.shuffleOrder[_music.shufflePos % _music.shuffleOrder.length]
+    : idx % _music.tracks.length;
+
+  const track = _music.tracks[realIdx];
+  if (!track) return;
+
+  audio.src = '/files?path=' + encodeURIComponent(track.path);
+  audio.volume = _music.volume / 100;
+  audio.play().catch(() => {});
+
+  const { title, artist } = _musicParseFilename(track.name);
+  _musicUpdateNowPlaying(title, artist);
+}
+
+function _musicNext() {
+  if (!_music.tracks.length) return;
+  if (_music.shuffle) {
+    _music.shufflePos++;
+    if (_music.shufflePos >= _music.shuffleOrder.length) {
+      _music.shuffleOrder = _musicShuffle(_music.tracks.map((_, i) => i));
+      _music.shufflePos = 0;
+    }
+  } else {
+    _music.trackIndex = (_music.trackIndex + 1) % _music.tracks.length;
+  }
+  _musicPlayTrack(_music.trackIndex);
+}
+
+async function applyMusicSettings(data) {
+  _music.enabled          = !!data.enabled;
+  _music.source_type      = data.source_type || 'local';
+  _music.source           = data.source || '';
+  _music.volume           = data.volume != null ? data.volume : 50;
+  _music.shuffle          = !!data.shuffle;
+  _music.show_overlay     = data.show_overlay !== false;
+  _music.overlay_position = data.overlay_position || 'bottom-left';
+
+  const audio   = document.getElementById('music-player');
+  const ytFrame = document.getElementById('music-yt-frame');
+
+  if (!_music.enabled) {
+    if (audio)   { audio.pause(); audio.src = ''; }
+    if (ytFrame) { ytFrame.src = ''; }
+    if (_music.ytPlayer) { try { _music.ytPlayer.stopVideo(); } catch {} }
+    _musicUpdateNowPlaying('', '');
+    return;
+  }
+
+  _musicSetVolume(_music.volume);
+
+  if (_music.source_type === 'youtube') {
+    // YouTube-spellista via IFrame Player API (extraherade video-ID:n)
+    if (audio) { audio.pause(); audio.src = ''; }
+    if (ytFrame) { ytFrame.src = ''; }  // stäng eventuell gammal embed-iframe
+
+    // Hämta extraherade ID:n från servern
+    let ids = [];
+    try {
+      const r = await fetch('/api/music/playlist');
+      if (r.ok) {
+        const d = await r.json();
+        ids = d.ids || [];
+      }
+    } catch { /* ignore */ }
+
+    if (!ids.length) {
+      _musicUpdateNowPlaying('Ingen spellista', 'Extrahera först i admin');
+      return;
+    }
+
+    _music.ytIds = ids;
+    _music.ytIndex = 0;
+    _music.ytShuffleOrder = [];
+    _music.ytShufflePos = 0;
+
+    if (_music.ytApiReady) {
+      _musicYtStartPlayer();
+    }
+    // annars startas spelaren i onYouTubeIframeAPIReady när API:t laddat klart
+
+  } else {
+    // Lokal MP3-spellista
+    if (ytFrame) { ytFrame.src = ''; }
+
+    const tracks = await _musicLoadTracks(_music.source);
+    _music.tracks = tracks;
+    _music.trackIndex = 0;
+
+    if (!tracks.length) {
+      _musicUpdateNowPlaying('', '');
+      return;
+    }
+
+    if (_music.shuffle) {
+      _music.shuffleOrder = _musicShuffle(tracks.map((_, i) => i));
+      _music.shufflePos = 0;
+    }
+
+    // Börja bara spela om inget spelas redan (undvik avbrott vid settings-uppdatering)
+    if (audio && (audio.paused || audio.ended || !audio.src)) {
+      _musicPlayTrack(0);
+    } else {
+      _musicSetVolume(_music.volume);
+    }
+
+    // Sätt upp händelselyssnare för nästa spår
+    if (audio) {
+      audio.onended = _musicNext;
+    }
+  }
+}
+
+socket.on('music_settings', applyMusicSettings);
+
+socket.on('now_playing', () => {
+  // Ignoreras på display-sidan (vi sänder, vi tar inte emot)
+});
+
+// Hämta musikinställningar vid anslutning
+socket.on('connect', () => {
+  fetch('/api/music').then(r => r.json()).then(applyMusicSettings).catch(() => {});
+});
diff --git a/kiosk/templates/admin.html b/kiosk/templates/admin.html
index 067f7fa..41cd947 100644
--- a/kiosk/templates/admin.html
+++ b/kiosk/templates/admin.html
@@ -10,6 +10,9 @@
   <div class="container">
     <h1>Kiosk Admin</h1>

+    <!-- 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 -->
     <section>
       <h2>Visa nu</h2>
@@ -40,6 +43,12 @@
     <section>
       <h2>Schema</h2>

+      <!-- Spelar nu-banner (visas när musik spelas) -->
+      <div id="now-playing-banner" style="display:none; margin-bottom:.75rem; padding:.45rem .85rem; background:rgba(139,92,246,0.12); border:1px solid rgba(139,92,246,0.3); border-radius:7px; display:flex; align-items:center; gap:.6rem; font-size:.88rem; color:#c4b5fd;">
+        <span style="font-size:1rem;">♪</span>
+        <span id="np-banner-text" style="flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">Spelar nu...</span>
+      </div>
+
       <table id="jobs-table">
         <thead>
           <tr>
@@ -62,10 +71,11 @@
         <div class="form-grid">
           <input name="label" type="text" placeholder="Namn" required>
           <input name="duration" type="number" min="0.01" step="any" placeholder="Minuter (ex. 0.5)" required>
-          <select name="type">
+          <select name="type" onchange="_jobTypeChanged('job-form','job-extract-wrap','job-extract-status')">
             <option value="url">URL / HTML</option>
-            <option value="youtube">YouTube</option>
-            <option value="video">Video</option>
+            <option value="youtube">YouTube (video)</option>
+            <option value="youtube_playlist">YouTube-spellista (video)</option>
+            <option value="video">Video (lokal)</option>
             <option value="image">Bild</option>
             <option value="pdf">PDF / Presentation</option>
             <option value="kiosk_chat">Kiosk Livechat</option>
@@ -74,6 +84,10 @@
             <input name="source" type="text" placeholder="URL eller välj fil" required>
             <button type="button" class="secondary" onclick="openBrowser(setSchemaCalla)">Bläddra</button>
           </div>
+          <div id="job-extract-wrap" style="display:none;grid-column:1/-1;display:none;flex-direction:column;gap:.25rem">
+            <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>
           <label style="display:flex;align-items:center;gap:.5rem;color:#aaa;font-size:.9rem">
             <input type="checkbox" name="show_chat"> Visa livechat (YouTube)
           </label>
@@ -94,15 +108,20 @@
               <div class="form-grid">
                 <input name="label" type="text" placeholder="Namn" required>
                 <input name="duration" type="number" min="0.01" step="any" placeholder="Minuter" required>
-                <select name="type">
+                <select name="type" onchange="_jobTypeChanged('edit-form','edit-extract-wrap','edit-extract-status')">
                   <option value="url">URL / HTML</option>
-                  <option value="youtube">YouTube</option>
-                  <option value="video">Video</option>
+                  <option value="youtube">YouTube (video)</option>
+                  <option value="youtube_playlist">YouTube-spellista (video)</option>
+                  <option value="video">Video (lokal)</option>
                   <option value="image">Bild</option>
                   <option value="pdf">PDF / Presentation</option>
                   <option value="kiosk_chat">Kiosk Livechat</option>
                 </select>
                 <input name="source" type="text" placeholder="URL eller filväg" required>
+                <div id="edit-extract-wrap" style="display:none;grid-column:1/-1;flex-direction:column;gap:.25rem">
+                  <button type="button" class="secondary" onclick="extractJobPlaylist('edit-form')">Extrahera spellista</button>
+                  <span id="edit-extract-status" style="font-size:.75rem;color:#444"></span>
+                </div>
               </div>
               <div style="margin-top:.6rem"><button type="submit">Spara</button></div>
             </form>
@@ -407,7 +426,75 @@
       </div>
     </section>

-    <!-- Sektion 6: Sovläge -->
+    <!-- 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">
@@ -493,6 +580,41 @@
       </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>
diff --git a/kiosk/templates/chat_display.html b/kiosk/templates/chat_display.html
index 6f22f35..147cb99 100644
--- a/kiosk/templates/chat_display.html
+++ b/kiosk/templates/chat_display.html
@@ -89,6 +89,7 @@
       font-size: 1rem;
       flex-shrink: 0;
       margin-top: .1rem;
+      overflow: hidden;
     }

     .msg-body { flex: 1; min-width: 0; }
@@ -112,6 +113,18 @@
       font-style: italic;
     }

+    .msg-img { max-width: 240px; max-height: 180px; border-radius: 8px; display: block; margin-top: .3rem; }
+
+    .msg-suggestion {
+      border-left: 3px solid #818cf8;
+      padding: .4rem .65rem;
+      background: #818cf818;
+      border-radius: 0 8px 8px 0;
+      margin-top: .2rem;
+    }
+    .msg-suggestion .sug-label { font-size: .72rem; color: #818cf8; margin-bottom: .15rem; }
+    .msg-suggestion .sug-url   { font-size: .9rem; color: #e8e8e8; word-break: break-all; }
+
     /* === QR panel === */
     #qr-col {
       width: 180px;
@@ -219,7 +232,20 @@ function addMsg(name, text, color, emoji, avatarUrl) {
     <div class="msg-avatar" style="${avatarBg(color)}">${avatarContent(emoji, name, avatarUrl)}</div>
     <div class="msg-body">
       <div class="msg-name" style="color:${color}">${escHtml(name)}</div>
-      <div class="msg-text">${escHtml(text)}</div>
+      <div class="msg-text">${_renderContent(text)}</div>
+    </div>`;
+  msgInner.appendChild(div);
+  while (msgInner.children.length > MAX_MSGS) msgInner.removeChild(msgInner.firstChild);
+}
+
+function addSuggestion(d) {
+  const div = document.createElement('div');
+  div.className = 'msg';
+  div.innerHTML = `
+    <div class="msg-avatar" style="${avatarBg(d.color)}">${avatarContent(d.emoji, d.name, d.avatar_url)}</div>
+    <div class="msg-body">
+      <div class="msg-name" style="color:${d.color}">${escHtml(d.name)}</div>
+      ${_suggestionHtml(d.text || d.url)}
     </div>`;
   msgInner.appendChild(div);
   while (msgInner.children.length > MAX_MSGS) msgInner.removeChild(msgInner.firstChild);
@@ -237,15 +263,41 @@ function escHtml(s) {
   return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
 }

+const _IMG_RE = /^(?:https?:\/\/|\/)[^\s"<>]+\.(gif|jpe?g|png|webp|avif)(\?[^\s"<>]*)?$/i;
+
+function _renderContent(text) {
+  const t = text.trim();
+  if (_IMG_RE.test(t)) {
+    const safe = t.replace(/"/g, '%22');
+    return `<img src="${safe}" class="msg-img" loading="lazy" onerror="this.style.display='none'">`;
+  }
+  return escHtml(text);
+}
+
+function _suggestionHtml(url) {
+  const short = url.length > 60 ? url.slice(0, 57) + '…' : url;
+  return `<div class="msg-suggestion">
+    <div class="sug-label">🎬 Förslag till displayen</div>
+    <div class="sug-url">${escHtml(short)}</div>
+  </div>`;
+}
+
 socket.on('chat_history', (msgs) => {
   msgInner.innerHTML = '';
-  msgs.forEach(m => addMsg(m.name, m.text, m.color, m.emoji, m.avatar_url));
+  msgs.forEach(m => {
+    if (m.kind === 'play_suggestion') addSuggestion(m);
+    else addMsg(m.name, m.text, m.color, m.emoji, m.avatar_url);
+  });
 });

 socket.on('chat_message', (d) => {
   addMsg(d.name, d.text, d.color, d.emoji, d.avatar_url);
 });

+socket.on('play_suggestion', (d) => {
+  addSuggestion(d);
+});
+
 socket.on('system_message', (d) => {
   addSystem(d.text);
 });
diff --git a/kiosk/templates/chat_mobile.html b/kiosk/templates/chat_mobile.html
index 96d6880..bbcc27c 100644
--- a/kiosk/templates/chat_mobile.html
+++ b/kiosk/templates/chat_mobile.html
@@ -242,6 +242,7 @@
       display: flex; align-items: center; justify-content: center;
       font-size: 1rem;
       flex-shrink: 0;
+      overflow: hidden;
     }

     .cm-bubble {
@@ -325,6 +326,121 @@

     #send-btn:disabled { background: #333; color: #555; cursor: default; }
     #send-btn:not(:disabled):hover { background: var(--accent-h); }
+
+    /* Bifogningsknapp */
+    #attach-btn {
+      width: 2.4rem; height: 2.4rem;
+      border-radius: 50%;
+      background: var(--surface);
+      color: var(--text);
+      border: 1px solid var(--border);
+      font-size: 1.25rem;
+      cursor: pointer;
+      flex-shrink: 0;
+      align-self: flex-end;
+      display: flex; align-items: center; justify-content: center;
+      transition: background .15s;
+    }
+    #attach-btn:hover { background: #2a2a2a; }
+    #attach-btn.open  { background: var(--accent); border-color: var(--accent); color:#fff; }
+
+    /* Bifogningspanel */
+    #attach-panel {
+      display: none;
+      padding: .5rem .75rem .4rem;
+      background: var(--surface);
+      border-top: 1px solid var(--border);
+      gap: .5rem;
+      flex-wrap: wrap;
+    }
+    .attach-opt {
+      flex: 1;
+      min-width: 80px;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      gap: .3rem;
+      padding: .65rem .5rem;
+      background: var(--bg);
+      border: 1px solid var(--border);
+      border-radius: 12px;
+      cursor: pointer;
+      font-size: .8rem;
+      color: var(--muted);
+      transition: border-color .15s, color .15s;
+    }
+    .attach-opt:hover { border-color: var(--accent); color: var(--text); }
+    .attach-opt .opt-icon { font-size: 1.6rem; line-height: 1; }
+
+    /* GIF-URL-rad */
+    #gif-url-row {
+      display: none;
+      gap: .4rem;
+      padding: .4rem .75rem .5rem;
+      background: var(--surface);
+      border-top: 1px solid var(--border);
+    }
+    #gif-url-input {
+      flex: 1;
+      background: var(--bg);
+      border: 1px solid var(--border);
+      color: var(--text);
+      padding: .55rem .85rem;
+      border-radius: 20px;
+      font-size: .9rem;
+    }
+    #gif-url-input::placeholder { color: var(--muted); }
+    #gif-url-send {
+      background: var(--accent); color: #fff; border: none;
+      padding: .55rem 1rem; border-radius: 20px; font-size: .9rem; cursor: pointer;
+    }
+
+    /* Upload-indikator */
+    #upload-indicator {
+      display: none;
+      align-items: center;
+      gap: .5rem;
+      padding: .4rem .75rem;
+      background: var(--surface);
+      border-top: 1px solid var(--border);
+      font-size: .82rem;
+      color: var(--muted);
+    }
+    #upload-bar { flex:1; height:4px; background:#333; border-radius:2px; overflow:hidden; }
+    #upload-fill { height:100%; background: var(--accent); width:0%; transition:width .1s; }
+
+    /* Kommandotips */
+    #cmd-hint {
+      display: none;
+      background: var(--surface);
+      border-top: 1px solid var(--border);
+      padding: .3rem .5rem;
+      flex-shrink: 0;
+    }
+    .cmd-item {
+      padding: .45rem .6rem;
+      border-radius: 8px;
+      font-size: .88rem;
+      cursor: pointer;
+      color: var(--text);
+    }
+    .cmd-item:hover { background: #ffffff0d; }
+    .cmd-item strong { color: #818cf8; }
+
+    /* Förslag-kort i chatten */
+    .cm-suggestion {
+      border-left: 3px solid var(--accent);
+      padding: .4rem .65rem;
+      background: #2563eb18;
+      border-radius: 0 8px 8px 0;
+      margin-top: .2rem;
+      font-size: .85rem;
+    }
+    .cm-suggestion .sug-label { color: #818cf8; font-size: .75rem; margin-bottom: .2rem; }
+    .cm-suggestion .sug-url   { color: #e8e8e8; word-break: break-all; }
+
+    /* Inline-bilder */
+    .cm-img { max-width: 220px; max-height: 180px; border-radius: 8px; display: block; margin-top: .25rem; }
   </style>
 </head>
 <body>
@@ -380,7 +496,43 @@

   <div id="chat-log"></div>

+  <!-- Upload-indikator -->
+  <div id="upload-indicator">
+    <span id="upload-label">Laddar upp...</span>
+    <div id="upload-bar"><div id="upload-fill"></div></div>
+  </div>
+
+  <!-- GIF/bild URL-rad -->
+  <div id="gif-url-row">
+    <input id="gif-url-input" type="url" placeholder="Klistra in bild- eller GIF-URL...">
+    <button id="gif-url-send">Skicka</button>
+  </div>
+
+  <!-- Bifogningspanel -->
+  <div id="attach-panel" style="display:none">
+    <div class="attach-opt" onclick="triggerFileInput('camera')">
+      <span class="opt-icon">📷</span>
+      <span>Kamera</span>
+    </div>
+    <div class="attach-opt" onclick="triggerFileInput('gallery')">
+      <span class="opt-icon">🖼</span>
+      <span>Galleri</span>
+    </div>
+    <div class="attach-opt" onclick="showGifUrlRow()">
+      <span class="opt-icon">🔗</span>
+      <span>Bild-URL</span>
+    </div>
+  </div>
+
+  <!-- Dolda file inputs -->
+  <input type="file" id="file-camera"  accept="image/*" capture="environment" style="display:none">
+  <input type="file" id="file-gallery" accept="image/*,image/gif" style="display:none">
+
+  <div id="cmd-hint">
+    <div class="cmd-item" onclick="insertCmd('/spela ')">🎬 <strong>/spela</strong> URL — Föreslå vad som visas på displayen</div>
+  </div>
   <div id="input-row">
+    <button id="attach-btn" title="Bifoga bild">+</button>
     <textarea id="msg-input" placeholder="Skriv ett meddelande..." rows="1"></textarea>
     <button id="send-btn" disabled>➤</button>
   </div>
@@ -394,6 +546,33 @@ const TOKEN_KEY = 'kiosk_chat_token';
 const socket    = io('/chat');
 let currentUser = null;  // {username, emoji, color, token}

+// --- Innehållsrendering ---
+const _IMG_RE = /^(?:https?:\/\/|\/)[^\s"<>]+\.(gif|jpe?g|png|webp|avif)(\?[^\s"<>]*)?$/i;
+
+function _renderContent(text) {
+  const t = text.trim();
+  if (_IMG_RE.test(t)) {
+    const safe = t.replace(/"/g, '%22');
+    return `<img src="${safe}" class="cm-img" loading="lazy" onerror="this.style.display='none'">`;
+  }
+  return escHtml(text);
+}
+
+function _suggestionHtml(url) {
+  const short = url.length > 55 ? url.slice(0, 52) + '…' : url;
+  return `<div class="cm-suggestion">
+    <div class="sug-label">🎬 Förslag till displayen</div>
+    <div class="sug-url">${escHtml(short)}</div>
+  </div>`;
+}
+
+function insertCmd(cmd) {
+  msgInput.value = cmd;
+  msgInput.focus();
+  msgInput.dispatchEvent(new Event('input'));
+  document.getElementById('cmd-hint').style.display = 'none';
+}
+
 // ---- Avatar pick (register) ----
 let regAvatarFile = null;

@@ -558,11 +737,17 @@ function doLogout() {

 // ---- Socket events ----
 socket.on('chat_history', (msgs) => {
-  if (!currentUser) return;  // ignorera historik som anländer före inloggning
+  if (!currentUser) return;
   chatLog.innerHTML = '';
   msgs.forEach(m => {
-    if (m.name === currentUser.username) addMyMsg(m.text);
-    else addOtherMsg(m);
+    if (m.kind === 'play_suggestion') {
+      if (m.name === currentUser.username) _addMySuggestion(m.text);
+      else _addOtherSuggestion(m);
+    } else if (m.name === currentUser.username) {
+      addMyMsg(m.text);
+    } else {
+      addOtherMsg(m);
+    }
   });
 });

@@ -571,6 +756,12 @@ socket.on('chat_message', (d) => {
   if (d.name !== currentUser.username) addOtherMsg(d);
 });

+socket.on('play_suggestion', (d) => {
+  if (!currentUser) return;
+  if (d.name === currentUser.username) _addMySuggestion(d.text);
+  else _addOtherSuggestion(d);
+});
+
 socket.on('system_message', (d) => {
   addSystemMsg(d.text);
 });
@@ -595,11 +786,14 @@ socket.on('connect', () => {
 const chatLog  = document.getElementById('chat-log');
 const msgInput = document.getElementById('msg-input');
 const sendBtn  = document.getElementById('send-btn');
+const cmdHint  = document.getElementById('cmd-hint');

 msgInput.addEventListener('input', () => {
-  sendBtn.disabled = msgInput.value.trim().length === 0;
+  const val = msgInput.value;
+  sendBtn.disabled = val.trim().length === 0;
   msgInput.style.height = '';
   msgInput.style.height = Math.min(msgInput.scrollHeight, 96) + 'px';
+  cmdHint.style.display = val.startsWith('/') ? 'block' : 'none';
 });

 msgInput.addEventListener('keydown', (e) => {
@@ -607,16 +801,30 @@ msgInput.addEventListener('keydown', (e) => {
     e.preventDefault();
     if (!sendBtn.disabled) sendBtn.click();
   }
+  if (e.key === 'Escape') cmdHint.style.display = 'none';
 });

 sendBtn.addEventListener('click', () => {
   const text = msgInput.value.trim();
   if (!text || !currentUser) return;
-  socket.emit('chat_message', {text});
-  addMyMsg(text);
+
+  const playMatch = text.match(/^\/spela\s+(\S+)/i);
+  if (playMatch) {
+    const url = playMatch[1];
+    if (!/^https?:\/\//i.test(url)) {
+      addSystemMsg('Ange en giltig URL: /spela https://...');
+    } else {
+      socket.emit('play_suggestion', { url });
+    }
+  } else {
+    socket.emit('chat_message', { text });
+    addMyMsg(text);
+  }
+
   msgInput.value = '';
   msgInput.style.height = '';
   sendBtn.disabled = true;
+  cmdHint.style.display = 'none';
 });

 // ---- Message renderers ----
@@ -630,7 +838,7 @@ function addMyMsg(text) {
   div.className = 'cm mine';
   div.innerHTML = `
     <div class="cm-avatar" style="background:#2563eb22;color:#60a5fa">${myAvatarHtml()}</div>
-    <div class="cm-bubble">${escHtml(text)}</div>`;
+    <div class="cm-bubble">${_renderContent(text)}</div>`;
   chatLog.appendChild(div);
   chatLog.scrollTop = chatLog.scrollHeight;
 }
@@ -647,7 +855,30 @@ function addOtherMsg(d) {
     <div class="cm-avatar" style="background:${d.color}22;color:${d.color}">${otherAvatarHtml(d)}</div>
     <div>
       <div class="cm-name" style="color:${d.color}">${escHtml(d.name)}</div>
-      <div class="cm-bubble">${escHtml(d.text)}</div>
+      <div class="cm-bubble">${_renderContent(d.text)}</div>
+    </div>`;
+  chatLog.appendChild(div);
+  chatLog.scrollTop = chatLog.scrollHeight;
+}
+
+function _addMySuggestion(url) {
+  const div = document.createElement('div');
+  div.className = 'cm mine';
+  div.innerHTML = `
+    <div class="cm-avatar" style="background:#2563eb22;color:#60a5fa">${myAvatarHtml()}</div>
+    <div>${_suggestionHtml(url)}</div>`;
+  chatLog.appendChild(div);
+  chatLog.scrollTop = chatLog.scrollHeight;
+}
+
+function _addOtherSuggestion(d) {
+  const div = document.createElement('div');
+  div.className = 'cm';
+  div.innerHTML = `
+    <div class="cm-avatar" style="background:${d.color}22;color:${d.color}">${otherAvatarHtml(d)}</div>
+    <div>
+      <div class="cm-name" style="color:${d.color}">${escHtml(d.name)}</div>
+      ${_suggestionHtml(d.text)}
     </div>`;
   chatLog.appendChild(div);
   chatLog.scrollTop = chatLog.scrollHeight;
@@ -662,6 +893,109 @@ function addSystemMsg(text) {
 }

 window.addEventListener('beforeunload', () => socket.disconnect());
+
+// ---- Bifogning ----
+
+const attachBtn   = document.getElementById('attach-btn');
+const attachPanel = document.getElementById('attach-panel');
+const gifUrlRow   = document.getElementById('gif-url-row');
+const gifUrlInput = document.getElementById('gif-url-input');
+
+attachBtn.addEventListener('click', () => {
+  const open = attachPanel.style.display !== 'none';
+  attachPanel.style.display = open ? 'none' : 'flex';
+  gifUrlRow.style.display   = 'none';
+  attachBtn.classList.toggle('open', !open);
+});
+
+function closeAttachPanel() {
+  attachPanel.style.display = 'none';
+  gifUrlRow.style.display   = 'none';
+  attachBtn.classList.remove('open');
+}
+
+function triggerFileInput(mode) {
+  closeAttachPanel();
+  document.getElementById(mode === 'camera' ? 'file-camera' : 'file-gallery').click();
+}
+
+function showGifUrlRow() {
+  attachPanel.style.display = 'none';
+  gifUrlRow.style.display   = 'flex';
+  gifUrlInput.value = '';
+  gifUrlInput.focus();
+}
+
+document.getElementById('gif-url-send').addEventListener('click', () => {
+  const url = gifUrlInput.value.trim();
+  if (!url || !/^https?:\/\//i.test(url)) return;
+  socket.emit('chat_message', { text: url });
+  addMyMsg(url);
+  gifUrlInput.value = '';
+  gifUrlRow.style.display = 'none';
+  msgInput.focus();
+});
+
+gifUrlInput.addEventListener('keydown', (e) => {
+  if (e.key === 'Enter') document.getElementById('gif-url-send').click();
+  if (e.key === 'Escape') { gifUrlRow.style.display = 'none'; msgInput.focus(); }
+});
+
+async function uploadAndSendImage(file) {
+  if (!currentUser) return;
+
+  const indicator = document.getElementById('upload-indicator');
+  const fill      = document.getElementById('upload-fill');
+  const label     = document.getElementById('upload-label');
+
+  indicator.style.display = 'flex';
+  fill.style.width = '0%';
+  label.textContent = 'Laddar upp...';
+
+  const fd = new FormData();
+  fd.append('token', currentUser.token);
+  fd.append('image', file);
+
+  const xhr = new XMLHttpRequest();
+  xhr.upload.onprogress = (e) => {
+    if (e.lengthComputable) fill.style.width = Math.round(e.loaded / e.total * 100) + '%';
+  };
+
+  xhr.onload = () => {
+    indicator.style.display = 'none';
+    if (xhr.status === 200) {
+      const d = JSON.parse(xhr.responseText);
+      socket.emit('chat_message', { text: d.url });
+      addMyMsg(d.url);
+    } else {
+      addSystemMsg('Uppladdning misslyckades');
+    }
+  };
+
+  xhr.onerror = () => {
+    indicator.style.display = 'none';
+    addSystemMsg('Nätverksfel vid uppladdning');
+  };
+
+  xhr.open('POST', '/api/chat/image');
+  xhr.send(fd);
+}
+
+document.getElementById('file-camera').addEventListener('change',  (e) => {
+  if (e.target.files[0]) uploadAndSendImage(e.target.files[0]);
+  e.target.value = '';
+});
+document.getElementById('file-gallery').addEventListener('change', (e) => {
+  if (e.target.files[0]) uploadAndSendImage(e.target.files[0]);
+  e.target.value = '';
+});
+
+// Stäng panel vid klick utanför
+document.addEventListener('click', (e) => {
+  if (!attachBtn.contains(e.target) && !attachPanel.contains(e.target) && !gifUrlRow.contains(e.target)) {
+    closeAttachPanel();
+  }
+});
 </script>
 </body>
 </html>
diff --git a/kiosk/templates/display.html b/kiosk/templates/display.html
index 2a03622..f2a80ee 100644
--- a/kiosk/templates/display.html
+++ b/kiosk/templates/display.html
@@ -61,6 +61,7 @@
       font-size: 1rem;
       flex-shrink: 0;
       margin-top: .1rem;
+      overflow: hidden;
     }
     .kc-body { flex: 1; min-width: 0; }
     .kc-name {
@@ -141,7 +142,7 @@
 </head>
 <body>
   <div id="layer-iframe" class="layer">
-    <iframe id="frame" src="" frameborder="0" allow="autoplay; fullscreen; picture-in-picture"></iframe>
+    <iframe id="frame" src="" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
   </div>

   <div id="layer-video" class="layer">
@@ -191,6 +192,39 @@
   <!-- Söv-overlay — täcker allt -->
   <div id="layer-sleep" style="display:none; position:fixed; inset:0; background:#000; z-index:99999;"></div>

+  <!-- Musik: bakgrundsaudio (lokal MP3) -->
+  <audio id="music-player" preload="auto" style="display:none"></audio>
+
+  <!-- Musik: dold iframe för YouTube-spellista -->
+  <iframe id="music-yt-frame" src="" allow="autoplay; encrypted-media" style="position:fixed;width:320px;height:180px;left:-400px;top:0;pointer-events:none;border:none;"></iframe>
+
+  <!-- Now Playing-overlay -->
+  <div id="now-playing-overlay" style="display:none; position:fixed; z-index:9985; pointer-events:none; max-width:320px;">
+    <div id="now-playing-inner" style="
+      background: rgba(0,0,0,0.72);
+      backdrop-filter: blur(8px);
+      border-radius: 10px;
+      padding: .55rem .85rem;
+      display: flex;
+      align-items: center;
+      gap: .6rem;
+      animation: np-fadein .4s ease;
+    ">
+      <span id="now-playing-icon" style="font-size:1.3rem;flex-shrink:0;">♪</span>
+      <div style="min-width:0;">
+        <div id="now-playing-title" style="color:#fff;font-size:.88rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-shadow:0 1px 4px #000c;"></div>
+        <div id="now-playing-artist" style="color:#bbb;font-size:.75rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-shadow:0 1px 4px #000c;"></div>
+      </div>
+    </div>
+  </div>
+
+  <style>
+    @keyframes np-fadein {
+      from { opacity:0; transform:translateY(6px); }
+      to   { opacity:1; transform:translateY(0); }
+    }
+  </style>
+
   <script src="/static/socket.io.min.js"></script>
   <script src="/static/display.js"></script>
 </body>