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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
+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>