commit 6a8012e5ec1ddfaa0e403625aab448ef6d627e1a
Author: Jens Kristoffersson <jens.se@icloud.com>
AuthorDate: Sun Apr 5 22:13:20 2026 +0200
Commit: Jens Kristoffersson <jens.se@icloud.com>
CommitDate: Sun Apr 5 22:13:20 2026 +0200
uppdate 2022-06-01
---
kiosk/addons/emulator/routes.py | 3 +-
kiosk/addons/livechat/__init__.py | 4 +-
kiosk/addons/livechat/routes.py | 38 +----
kiosk/addons/music/routes.py | 17 ++-
kiosk/addons/settings/routes.py | 30 ++++
kiosk/addons/youtube_livechat/__init__.py | 16 ++
kiosk/addons/youtube_livechat/routes.py | 60 ++++++++
kiosk/app.py | 64 +++++++-
kiosk/db.py | 18 ++-
kiosk/scheduler.py | 5 +-
kiosk/static/admin.js | 168 ++++++++++++++-------
kiosk/static/display.js | 145 +++++++++++++-----
kiosk/sway-apps.sh | 5 +-
kiosk/templates/addons/livechat/panel.html | 51 +------
kiosk/templates/addons/settings/panel.html | 16 ++
kiosk/templates/addons/youtube_livechat/panel.html | 47 ++++++
kiosk/templates/admin.html | 15 +-
kiosk/templates/emulator.html | 1 +
18 files changed, 513 insertions(+), 190 deletions(-)
diff --git a/kiosk/addons/emulator/routes.py b/kiosk/addons/emulator/routes.py
index 30a1d01..0264470 100644
--- a/kiosk/addons/emulator/routes.py
+++ b/kiosk/addons/emulator/routes.py
@@ -142,8 +142,9 @@ def launch():
def stop():
db.set_state('emulator_active', '0')
extensions.socketio.emit('switch', {
- 'type': 'idle',
+ 'type': 'local',
'source': '',
+ 'html': '',
'scheduled': False,
})
return '', 204
diff --git a/kiosk/addons/livechat/__init__.py b/kiosk/addons/livechat/__init__.py
index 080b0be..b4b6c68 100644
--- a/kiosk/addons/livechat/__init__.py
+++ b/kiosk/addons/livechat/__init__.py
@@ -1,12 +1,12 @@
ADDON = {
"name": "livechat",
- "label": "Livechat",
+ "label": "Kiosk Livechat",
"version": "1.0",
"order": 5,
"has_overlay": False,
"has_panel": True,
"has_socketio": True,
- "state_prefix": "chat_",
+ "state_prefix": "kiosk_chat_",
"default_enabled": True,
}
diff --git a/kiosk/addons/livechat/routes.py b/kiosk/addons/livechat/routes.py
index 63a2b5a..b2da9d0 100644
--- a/kiosk/addons/livechat/routes.py
+++ b/kiosk/addons/livechat/routes.py
@@ -224,14 +224,6 @@ def upload_chat_image():
def get_chat():
state = db.get_state()
return jsonify({
- 'enabled': state.get('chat_enabled', '0') == '1',
- 'position': state.get('chat_position', 'right'),
- 'width_pct': int(state.get('chat_width_pct', '25')),
- 'height': int(state.get('chat_height', '100')),
- 'bg_color': state.get('chat_bg_color', '#000000'),
- 'bg_opacity': int(state.get('chat_bg_opacity', '70')),
- 'text_color': state.get('chat_text_color', '#ffffff'),
- 'font_size': int(state.get('chat_font_size', '14')),
'kiosk_enabled': state.get('kiosk_chat_enabled', '0') == '1',
'kiosk_position': state.get('kiosk_chat_position', 'right'),
'kiosk_width_pct': int(state.get('kiosk_chat_width', '30')),
@@ -245,37 +237,15 @@ def set_chat():
data = request.get_json()
if not data:
abort(400)
- enabled = bool(data.get('enabled', False))
- position = 'left' if data.get('position') == 'left' else 'right'
- width_pct = max(5, min(60, int(data.get('width_pct', 25))))
- height = max(30, min(100, int(data.get('height', 100))))
- bg_color = str(data.get('bg_color', '#000000'))
- bg_opacity = max(0, min(100, int(data.get('bg_opacity', 70))))
- text_color = str(data.get('text_color', '#ffffff'))
- font_size = max(8, min(48, int(data.get('font_size', 14))))
- if not _HEX_COLOR.match(bg_color): bg_color = '#000000'
- if not _HEX_COLOR.match(text_color): text_color = '#ffffff'
kiosk_enabled = bool(data.get('kiosk_enabled', False))
kiosk_position = 'left' if data.get('kiosk_position') == 'left' else 'right'
kiosk_width_pct = max(10, min(60, int(data.get('kiosk_width_pct', 30))))
kiosk_height = max(30, min(100, int(data.get('kiosk_height', 80))))
- db.set_state('chat_enabled', '1' if enabled else '0')
- db.set_state('chat_position', position)
- db.set_state('chat_width_pct', str(width_pct))
- db.set_state('chat_height', str(height))
- db.set_state('chat_bg_color', bg_color)
- db.set_state('chat_bg_opacity', str(bg_opacity))
- db.set_state('chat_text_color', text_color)
- db.set_state('chat_font_size', str(font_size))
- db.set_state('kiosk_chat_enabled', '1' if kiosk_enabled else '0')
- db.set_state('kiosk_chat_position', kiosk_position)
- db.set_state('kiosk_chat_width', str(kiosk_width_pct))
- db.set_state('kiosk_chat_height', str(kiosk_height))
+ db.set_state('kiosk_chat_enabled', '1' if kiosk_enabled else '0')
+ db.set_state('kiosk_chat_position', kiosk_position)
+ db.set_state('kiosk_chat_width', str(kiosk_width_pct))
+ db.set_state('kiosk_chat_height', str(kiosk_height))
payload = {
- 'enabled': enabled, 'position': position,
- 'width_pct': width_pct, 'height': height,
- 'bg_color': bg_color, 'bg_opacity': bg_opacity,
- 'text_color': text_color, 'font_size': font_size,
'kiosk_enabled': kiosk_enabled, 'kiosk_position': kiosk_position,
'kiosk_width_pct': kiosk_width_pct, 'kiosk_height': kiosk_height,
}
diff --git a/kiosk/addons/music/routes.py b/kiosk/addons/music/routes.py
index 4bb8173..278f118 100644
--- a/kiosk/addons/music/routes.py
+++ b/kiosk/addons/music/routes.py
@@ -133,6 +133,11 @@ def update_now_playing():
# --- YouTube playlist extraction (video & musik) ---
+def _playlist_db_key(url):
+ import hashlib
+ return 'playlist_ids_' + hashlib.md5(url.encode()).hexdigest()
+
+
@bp.route('/playlist/extract', methods=['POST'])
@login_required
def extract_playlist():
@@ -148,12 +153,18 @@ def extract_playlist():
except Exception as e:
return jsonify({'error': str(e)}), 500
_yt_playlist_cache[url] = ids
+ db.set_state(_playlist_db_key(url), ','.join(ids))
return jsonify({'count': len(ids), 'ids': ids, 'total_seconds': total_seconds})
@bp.route('/playlist/ids', methods=['GET'])
def get_playlist_ids():
- """Return cached video IDs for a playlist URL."""
+ """Return cached video IDs for a playlist URL (memory cache, then DB)."""
url = request.args.get('url', '').strip()
- ids = _yt_playlist_cache.get(url, [])
- return jsonify({'ids': ids, 'count': len(ids)})
+ ids = _yt_playlist_cache.get(url)
+ if not ids:
+ stored = db.get_state().get(_playlist_db_key(url), '')
+ ids = [v for v in stored.split(',') if v] if stored else []
+ if ids:
+ _yt_playlist_cache[url] = ids
+ return jsonify({'ids': ids or [], 'count': len(ids or [])})
diff --git a/kiosk/addons/settings/routes.py b/kiosk/addons/settings/routes.py
index 2728c1d..0f38885 100644
--- a/kiosk/addons/settings/routes.py
+++ b/kiosk/addons/settings/routes.py
@@ -5,6 +5,7 @@ import subprocess
from pathlib import Path
import eventlet
from flask import Blueprint, jsonify, request, abort
+from werkzeug.security import generate_password_hash, check_password_hash
import db
from auth import login_required
from utils.display import (
@@ -271,3 +272,32 @@ def toggle_internal_display():
return '', 204
eventlet.spawn(_apply_internal_display, enabled, env)
return '', 204
+
+
+@bp.route('/settings/credentials', methods=['POST'])
+@login_required
+def change_credentials():
+ data = request.get_json(silent=True) or {}
+ current = str(data.get('current_password', ''))
+ new_user = str(data.get('new_username', '')).strip()[:64]
+ new_pass = str(data.get('new_password', ''))
+
+ if not new_user or not new_pass:
+ return jsonify({'error': 'Användarnamn och lösenord krävs'}), 400
+ if len(new_pass) < 4:
+ return jsonify({'error': 'Lösenordet måste vara minst 4 tecken'}), 400
+
+ # Verify current credentials
+ import app as _app
+ stored_hash = db.get_state().get('admin_password_hash')
+ if stored_hash:
+ if not check_password_hash(stored_hash, current):
+ return jsonify({'error': 'Fel nuvarande lösenord'}), 403
+ else:
+ # Fall back to env/default comparison
+ if current != _app.KIOSK_PASS:
+ return jsonify({'error': 'Fel nuvarande lösenord'}), 403
+
+ db.set_state('admin_username', new_user)
+ db.set_state('admin_password_hash', generate_password_hash(new_pass))
+ return jsonify({'ok': True})
diff --git a/kiosk/addons/youtube_livechat/__init__.py b/kiosk/addons/youtube_livechat/__init__.py
new file mode 100644
index 0000000..d4e5f74
--- /dev/null
+++ b/kiosk/addons/youtube_livechat/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+ "name": "youtube_livechat",
+ "label": "YouTube Live Chat",
+ "version": "1.0",
+ "order": 6,
+ "has_overlay": False,
+ "has_panel": True,
+ "has_socketio": False,
+ "state_prefix": "yt_chat_",
+ "default_enabled": True,
+}
+
+
+def create_blueprint():
+ from .routes import bp
+ return bp
diff --git a/kiosk/addons/youtube_livechat/routes.py b/kiosk/addons/youtube_livechat/routes.py
new file mode 100644
index 0000000..c0879a4
--- /dev/null
+++ b/kiosk/addons/youtube_livechat/routes.py
@@ -0,0 +1,60 @@
+import re
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+from auth import login_required
+
+bp = Blueprint('youtube_livechat', __name__)
+
+_HEX_COLOR = re.compile(r'^#[0-9a-fA-F]{6}$')
+
+
+@bp.route('/api/yt_chat', methods=['GET'])
+def get_yt_chat():
+ state = db.get_state()
+ return jsonify({
+ 'enabled': state.get('yt_chat_enabled', '0') == '1',
+ 'position': state.get('yt_chat_position', 'right'),
+ 'width_pct': int(state.get('yt_chat_width_pct', '25')),
+ 'height': int(state.get('yt_chat_height', '100')),
+ 'bg_color': state.get('yt_chat_bg_color', '#000000'),
+ 'bg_opacity': int(state.get('yt_chat_bg_opacity', '70')),
+ 'text_color': state.get('yt_chat_text_color', '#ffffff'),
+ 'font_size': int(state.get('yt_chat_font_size', '14')),
+ })
+
+
+@bp.route('/api/yt_chat', methods=['POST'])
+@login_required
+def set_yt_chat():
+ data = request.get_json()
+ if not data:
+ abort(400)
+ enabled = bool(data.get('enabled', False))
+ position = 'left' if data.get('position') == 'left' else 'right'
+ width_pct = max(5, min(60, int(data.get('width_pct', 25))))
+ height = max(30, min(100, int(data.get('height', 100))))
+ bg_color = str(data.get('bg_color', '#000000'))
+ bg_opacity = max(0, min(100, int(data.get('bg_opacity', 70))))
+ text_color = str(data.get('text_color', '#ffffff'))
+ font_size = max(8, min(48, int(data.get('font_size', 14))))
+ if not _HEX_COLOR.match(bg_color): bg_color = '#000000'
+ if not _HEX_COLOR.match(text_color): text_color = '#ffffff'
+
+ db.set_state('yt_chat_enabled', '1' if enabled else '0')
+ db.set_state('yt_chat_position', position)
+ db.set_state('yt_chat_width_pct', str(width_pct))
+ db.set_state('yt_chat_height', str(height))
+ db.set_state('yt_chat_bg_color', bg_color)
+ db.set_state('yt_chat_bg_opacity', str(bg_opacity))
+ db.set_state('yt_chat_text_color', text_color)
+ db.set_state('yt_chat_font_size', str(font_size))
+
+ payload = {
+ 'enabled': enabled, 'position': position,
+ 'width_pct': width_pct, 'height': height,
+ 'bg_color': bg_color, 'bg_opacity': bg_opacity,
+ 'text_color': text_color, 'font_size': font_size,
+ }
+ extensions.socketio.emit('yt_chat_settings', payload)
+ return '', 204
diff --git a/kiosk/app.py b/kiosk/app.py
index e7eae5e..75ffc82 100644
--- a/kiosk/app.py
+++ b/kiosk/app.py
@@ -81,13 +81,26 @@ KIOSK_USER = os.environ.get('KIOSK_USER', 'admin')
KIOSK_PASS = os.environ.get('KIOSK_PASS', 'changeme')
+def _check_credentials(username, password):
+ """Kontrollera inloggning — DB-hash har prioritet över env/default."""
+ from werkzeug.security import check_password_hash as _chk
+ state = db.get_state()
+ stored_hash = state.get('admin_password_hash')
+ stored_user = state.get('admin_username') or KIOSK_USER
+ if username != stored_user:
+ return False
+ if stored_hash:
+ return _chk(stored_hash, password)
+ return password == KIOSK_PASS
+
+
# --- Login ---
@app.route('/login', methods=['GET', 'POST'])
def login():
error = None
if request.method == 'POST':
- if request.form['username'] == KIOSK_USER and request.form['password'] == KIOSK_PASS:
+ if _check_credentials(request.form['username'], request.form['password']):
session['logged_in'] = True
return redirect(request.args.get('next') or url_for('admin'))
error = 'Fel användarnamn eller lösenord'
@@ -151,7 +164,11 @@ def add_job():
required = ('label', 'duration', 'type', 'source')
if not all(k in data for k in required):
abort(400)
- job_id = db.add_job(data['label'], float(data['duration']), data['type'], data['source'])
+ job_id = db.add_job(
+ data['label'], float(data['duration']), data['type'], data['source'],
+ shuffle=bool(data.get('shuffle', False)),
+ show_chat=bool(data.get('show_chat', False)),
+ )
sched.reload_jobs()
return jsonify({'id': job_id}), 201
@@ -171,11 +188,15 @@ def toggle_job(job_id):
data = request.get_json(silent=True)
if data:
allowed = {}
- for k in ('label', 'duration', 'type', 'source', 'enabled'):
+ for k in ('label', 'duration', 'type', 'source', 'enabled', 'shuffle', 'show_chat'):
if k in data:
allowed[k] = data[k]
if 'duration' in allowed:
allowed['duration'] = float(allowed['duration'])
+ if 'shuffle' in allowed:
+ allowed['shuffle'] = 1 if allowed['shuffle'] else 0
+ if 'show_chat' in allowed:
+ allowed['show_chat'] = 1 if allowed['show_chat'] else 0
db.update_job(job_id, **allowed)
sched.reload_jobs()
return '', 204
@@ -215,6 +236,8 @@ def on_connect():
'type': state['current_type'],
'source': state.get('current_source', ''),
'scheduled': False,
+ 'shuffle': False,
+ 'show_chat': True,
})
if state.get('sleep_enabled') == '1':
active = _is_sleep_active(
@@ -222,6 +245,36 @@ def on_connect():
state.get('sleep_end', '07:00'),
)
emit('sleep_toggle', {'active': active})
+ # Emittera alla overlay-inställningar direkt via socket
+ # så display.js inte behöver lyckas med HTTP-fetches för att initiera.
+ emit('ticker', {
+ 'enabled': state.get('ticker_enabled', '0') == '1',
+ 'source': state.get('ticker_source', 'text'),
+ 'text': state.get('ticker_text', ''),
+ 'rss_url': state.get('ticker_rss_url', ''),
+ 'rss_interval': int(state.get('ticker_rss_interval', '10')),
+ 'mode': state.get('ticker_mode', 'sequential'),
+ 'separator': state.get('ticker_separator','◆'),
+ 'position': state.get('ticker_position', 'bottom'),
+ 'text_color': state.get('ticker_text_color','#ffffff'),
+ 'font_family': state.get('ticker_font_family','sans-serif'),
+ 'bar_color': state.get('ticker_bar_color', '#000000'),
+ 'pps': int(state.get('ticker_pps', '150')),
+ })
+ emit('chat_settings', {
+ 'kiosk_enabled': state.get('kiosk_chat_enabled', '0') == '1',
+ 'kiosk_position': state.get('kiosk_chat_position', 'right'),
+ 'kiosk_width_pct': int(state.get('kiosk_chat_width', '30')),
+ 'kiosk_height': int(state.get('kiosk_chat_height','80')),
+ })
+ emit('yt_chat_settings', {
+ 'enabled': state.get('yt_chat_enabled', '0') == '1',
+ 'position': state.get('yt_chat_position', 'right'),
+ 'width_pct': int(state.get('yt_chat_width_pct', '25')),
+ 'height': int(state.get('yt_chat_height', '100')),
+ 'bg_color': state.get('yt_chat_bg_color', '#000000'),
+ 'bg_opacity': int(state.get('yt_chat_bg_opacity', '70')),
+ })
emit('clock_settings', {
'enabled': state.get('clock_enabled', '0') == '1',
'style': state.get('clock_style', 'digital'),
@@ -281,8 +334,9 @@ def _apply_settings_after_cage():
_apply_display_mode(saved_mode, target_output=state.get('display_output') or None)
_apply_saved_audio()
- # Om inget WiFi: aktivera intern display automatiskt så admin-gränssnittet är tillgängligt
- eventlet.sleep(2) # ge sway tid att sätta upp outputs
+ # Vänta ordentligt innan workspace-byte så att display-Chromium hinner
+ # ladda klart och WebSocket-anslutningen stabiliseras.
+ eventlet.sleep(15)
env = _sway_env()
if env:
internal_pref = state.get('internal_display', 'off')
diff --git a/kiosk/db.py b/kiosk/db.py
index 6492fe4..d358655 100644
--- a/kiosk/db.py
+++ b/kiosk/db.py
@@ -20,7 +20,9 @@ def init_db():
type TEXT NOT NULL,
source TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
- position INTEGER
+ position INTEGER,
+ shuffle INTEGER DEFAULT 0,
+ show_chat INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS state (
@@ -35,6 +37,12 @@ def init_db():
ALTER TABLE jobs ADD COLUMN duration REAL NOT NULL DEFAULT 5;
UPDATE jobs SET duration = 5;
""")
+ # Migrering: add shuffle + show_chat columns if missing
+ if 'shuffle' not in cols:
+ conn.execute("ALTER TABLE jobs ADD COLUMN shuffle INTEGER DEFAULT 0")
+ if 'show_chat' not in cols:
+ conn.execute("ALTER TABLE jobs ADD COLUMN show_chat INTEGER DEFAULT 0")
+
# Migrering: add position column if missing
if 'position' not in cols:
conn.executescript("""
@@ -84,14 +92,14 @@ def get_enabled_jobs():
).fetchall()]
-def add_job(label, duration, type_, source):
+def add_job(label, duration, type_, source, shuffle=False, show_chat=False):
with get_conn() as conn:
# determine next position
curpos = conn.execute("SELECT MAX(position) AS m FROM jobs").fetchone()
nextpos = (curpos['m'] or 0) + 1
cur = conn.execute(
- "INSERT INTO jobs (label, duration, type, source, position) VALUES (?, ?, ?, ?, ?)",
- (label, duration, type_, source, nextpos)
+ "INSERT INTO jobs (label, duration, type, source, position, shuffle, show_chat) VALUES (?, ?, ?, ?, ?, ?, ?)",
+ (label, duration, type_, source, nextpos, 1 if shuffle else 0, 1 if show_chat else 0)
)
return cur.lastrowid
@@ -117,7 +125,7 @@ def update_job(job_id, **fields):
keys = []
vals = []
for k, v in fields.items():
- if k in ('label', 'duration', 'type', 'source', 'enabled'):
+ if k in ('label', 'duration', 'type', 'source', 'enabled', 'shuffle', 'show_chat'):
keys.append(f"{k} = ?")
vals.append(v)
if not keys:
diff --git a/kiosk/scheduler.py b/kiosk/scheduler.py
index 44731cf..6b58673 100644
--- a/kiosk/scheduler.py
+++ b/kiosk/scheduler.py
@@ -39,7 +39,10 @@ def _advance():
def _emit_and_schedule(job):
if _socketio:
- _socketio.emit('switch', {'type': job['type'], 'source': job['source'], 'scheduled': True})
+ _socketio.emit('switch', {
+ 'type': job['type'], 'source': job['source'], 'scheduled': True,
+ 'shuffle': bool(job.get('shuffle')), 'show_chat': bool(job.get('show_chat')),
+ })
db.set_current(job['type'], job['source'])
scheduler.add_job(
func=_advance,
diff --git a/kiosk/static/admin.js b/kiosk/static/admin.js
index 5f54a4f..0f22885 100644
--- a/kiosk/static/admin.js
+++ b/kiosk/static/admin.js
@@ -246,6 +246,11 @@ function _jobTypeChanged(formId, wrapId, statusId, emuWrapId) {
const isPlaylist = type === 'youtube_playlist';
const isEmulator = type === 'emulator';
+ // Show/hide shuffle row for playlist type
+ const shuffleWrapId = formId === 'job-form' ? 'job-shuffle-wrap' : 'edit-shuffle-wrap';
+ const shuffleWrap = document.getElementById(shuffleWrapId);
+ if (shuffleWrap) shuffleWrap.style.display = isPlaylist ? 'block' : 'none';
+
if (wrap) wrap.style.display = isPlaylist ? 'flex' : 'none';
if (emuWrap) emuWrap.style.display = isEmulator ? 'flex' : 'none';
@@ -332,7 +337,8 @@ async function laggTillJobb(e) {
duration: form.duration.value,
type,
source,
- show_chat: form.show_chat.checked,
+ show_chat: form.show_chat ? form.show_chat.checked : false,
+ shuffle: form.shuffle ? form.shuffle.checked : false,
};
const res = await apiFetch('/api/jobs', {
method: 'POST',
@@ -359,6 +365,7 @@ function openEdit(id) {
form.duration.value = job.duration;
form.type.value = job.type;
form.source.value = job.source;
+ if (form.shuffle) form.shuffle.checked = !!job.shuffle;
_jobTypeChanged('edit-form', 'edit-extract-wrap', 'edit-extract-status');
modal.style.display = 'flex';
});
@@ -377,10 +384,11 @@ async function saveEdit(e) {
const form = e.target;
const id = parseInt(form.id.value, 10);
const payload = {
- label: form.label.value,
+ label: form.label.value,
duration: parseFloat(form.duration.value),
- type: form.type.value,
- source: form.source.value
+ type: form.type.value,
+ source: form.source.value,
+ shuffle: form.shuffle ? form.shuffle.checked : false,
};
const res = await apiFetch('/api/jobs/' + id, {
method: 'PATCH',
@@ -562,6 +570,27 @@ async function sparaInstallningar() {
setTimeout(() => { status.textContent = ''; }, 3000);
}
+async function bytaLösenord() {
+ const current = document.getElementById('cred-current').value;
+ const user = document.getElementById('cred-user').value.trim();
+ const pass = document.getElementById('cred-pass').value;
+ const status = document.getElementById('cred-status');
+ if (!current || !user || !pass) { status.textContent = 'Fyll i alla fält'; return; }
+ const res = await apiFetch('/api/settings/credentials', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ current_password: current, new_username: user, new_password: pass }),
+ }).catch(() => null);
+ if (!res) { status.textContent = '✗ Fel'; return; }
+ const d = await res.json();
+ if (d.ok) {
+ status.textContent = '✓ Sparat — logga in igen';
+ document.getElementById('cred-current').value = '';
+ document.getElementById('cred-pass').value = '';
+ } else {
+ status.textContent = '✗ ' + (d.error || 'Fel');
+ }
+}
+
async function applyDisplayNow() {
const modeSelEl = document.getElementById('setting-display-mode');
let displayMode = '';
@@ -580,37 +609,84 @@ async function applyDisplayNow() {
else alert('Kunde inte tillämpa upplösning: ' + (j.error || 'okänt fel'));
}
-// --- Chat overlay ---
+// --- Kiosk Livechat overlay ---
async function laddaChat() {
const res = await apiFetch('/api/chat');
if (!res) return;
const d = await res.json();
- document.getElementById('chat-position').value = d.position || 'right';
- document.getElementById('chat-bg-color').value = d.bg_color || '#000000';
+ const kw = d.kiosk_width_pct != null ? d.kiosk_width_pct : 30;
+ const kh = d.kiosk_height != null ? d.kiosk_height : 80;
+ if (document.getElementById('kiosk-chat-enabled'))
+ document.getElementById('kiosk-chat-enabled').checked = !!d.kiosk_enabled;
+ if (document.getElementById('kiosk-chat-position'))
+ document.getElementById('kiosk-chat-position').value = d.kiosk_position || 'right';
+ if (document.getElementById('kiosk-chat-width')) {
+ document.getElementById('kiosk-chat-width').value = kw;
+ document.getElementById('kiosk-chat-width-val').textContent = kw + '%';
+ }
+ if (document.getElementById('kiosk-chat-height')) {
+ document.getElementById('kiosk-chat-height').value = kh;
+ document.getElementById('kiosk-chat-height-val').textContent = kh + '%';
+ }
+ if (document.getElementById('kiosk-chat-status'))
+ document.getElementById('kiosk-chat-status').textContent = d.kiosk_enabled ? '● Aktiv overlay' : '';
+}
+
+async function _sendKioskChat(kioskEnabled) {
+ const res = await apiFetch('/api/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ kiosk_enabled: kioskEnabled,
+ kiosk_position: document.getElementById('kiosk-chat-position').value,
+ kiosk_width_pct: parseInt(document.getElementById('kiosk-chat-width').value, 10),
+ kiosk_height: parseInt(document.getElementById('kiosk-chat-height').value, 10),
+ }),
+ });
+ if (!res) return;
+ if (document.getElementById('kiosk-chat-status'))
+ document.getElementById('kiosk-chat-status').textContent = kioskEnabled ? '● Aktiv overlay' : '';
+ if (document.getElementById('kiosk-chat-enabled'))
+ document.getElementById('kiosk-chat-enabled').checked = kioskEnabled;
+}
+
+// --- YouTube Live Chat overlay ---
+
+async function laddaYtChat() {
+ const res = await apiFetch('/api/yt_chat');
+ if (!res) return;
+ const d = await res.json();
+
+ if (document.getElementById('yt-chat-position'))
+ document.getElementById('yt-chat-position').value = d.position || 'right';
+ if (document.getElementById('yt-chat-bg-color'))
+ document.getElementById('yt-chat-bg-color').value = d.bg_color || '#000000';
const wp = d.width_pct != null ? d.width_pct : 25;
const h = d.height != null ? d.height : 100;
const bo = d.bg_opacity != null ? d.bg_opacity : 70;
- document.getElementById('chat-width-pct').value = wp; document.getElementById('chat-width-pct-val').textContent = wp + '%';
- document.getElementById('chat-height').value = h; document.getElementById('chat-height-val').textContent = h + '%';
- document.getElementById('chat-bg-opacity').value = bo; document.getElementById('chat-bg-opacity-val').textContent = bo + '%';
- document.getElementById('chat-status').textContent = d.enabled ? '● Aktiv' : '';
+ if (document.getElementById('yt-chat-width')) {
+ document.getElementById('yt-chat-width').value = wp;
+ document.getElementById('yt-chat-width-val').textContent = wp + '%';
+ }
+ if (document.getElementById('yt-chat-height')) {
+ document.getElementById('yt-chat-height').value = h;
+ document.getElementById('yt-chat-height-val').textContent = h + '%';
+ }
+ if (document.getElementById('yt-chat-bg-opacity')) {
+ document.getElementById('yt-chat-bg-opacity').value = bo;
+ document.getElementById('yt-chat-bg-opacity-val').textContent = bo + '%';
+ }
+ if (document.getElementById('yt-chat-status'))
+ document.getElementById('yt-chat-status').textContent = d.enabled ? '● Aktiv' : '';
- // Kiosk livechat
- const kw = d.kiosk_width_pct != null ? d.kiosk_width_pct : 30;
- const kh = d.kiosk_height != null ? d.kiosk_height : 80;
- document.getElementById('kiosk-chat-enabled').checked = !!d.kiosk_enabled;
- document.getElementById('kiosk-chat-position').value = d.kiosk_position || 'right';
- document.getElementById('kiosk-chat-width').value = kw; document.getElementById('kiosk-chat-width-val').textContent = kw + '%';
- document.getElementById('kiosk-chat-height').value = kh; document.getElementById('kiosk-chat-height-val').textContent = kh + '%';
- document.getElementById('kiosk-chat-status').textContent = d.kiosk_enabled ? '● Aktiv overlay' : '';
- // Chat theme button (auto/dark/light)
+ // Chat theme button
try {
const KEY = 'kiosk_chat_theme';
- const btn = document.getElementById('chat-theme-btn');
+ const btn = document.getElementById('yt-chat-theme-btn');
if (btn) {
const getTheme = () => localStorage.getItem(KEY) || 'auto';
const order = ['auto','dark','light'];
@@ -618,58 +694,43 @@ async function laddaChat() {
const t = getTheme();
btn.textContent = t === 'auto' ? 'Chatt: Auto' : (t === 'dark' ? 'Chatt: Mörk' : 'Chatt: Ljus');
};
- btn.addEventListener('click', () => {
- const cur = getTheme();
- const next = order[(order.indexOf(cur) + 1) % order.length];
+ btn.onclick = () => {
+ const next = order[(order.indexOf(getTheme()) + 1) % order.length];
localStorage.setItem(KEY, next);
updateBtn();
- // notify display pages
window.dispatchEvent(new Event('kiosk-chat-theme-changed'));
- });
- // reflect existing value
+ };
updateBtn();
- // react to external changes
window.addEventListener('storage', (e) => { if (e.key === KEY) updateBtn(); });
}
} catch (e) {}
}
-async function _sendChat(ytEnabled, kioskEnabled) {
- const res = await apiFetch('/api/chat', {
+async function _sendYtChat(enabled) {
+ const res = await apiFetch('/api/yt_chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- enabled: ytEnabled,
- position: document.getElementById('chat-position').value,
- width_pct: parseInt(document.getElementById('chat-width-pct').value, 10),
- height: parseInt(document.getElementById('chat-height').value, 10),
- bg_color: document.getElementById('chat-bg-color').value,
- bg_opacity: parseInt(document.getElementById('chat-bg-opacity').value, 10),
- kiosk_enabled: kioskEnabled,
- kiosk_position: document.getElementById('kiosk-chat-position').value,
- kiosk_width_pct: parseInt(document.getElementById('kiosk-chat-width').value, 10),
- kiosk_height: parseInt(document.getElementById('kiosk-chat-height').value, 10),
+ enabled,
+ position: document.getElementById('yt-chat-position') ? document.getElementById('yt-chat-position').value : 'right',
+ width_pct: document.getElementById('yt-chat-width') ? parseInt(document.getElementById('yt-chat-width').value, 10) : 25,
+ height: document.getElementById('yt-chat-height') ? parseInt(document.getElementById('yt-chat-height').value, 10) : 100,
+ bg_color: document.getElementById('yt-chat-bg-color') ? document.getElementById('yt-chat-bg-color').value : '#000000',
+ bg_opacity: document.getElementById('yt-chat-bg-opacity') ? parseInt(document.getElementById('yt-chat-bg-opacity').value, 10) : 70,
}),
});
if (!res) return;
- document.getElementById('chat-status').textContent = ytEnabled ? '● Aktiv' : '';
- document.getElementById('kiosk-chat-status').textContent = kioskEnabled ? '● Aktiv overlay' : '';
+ if (document.getElementById('yt-chat-status'))
+ document.getElementById('yt-chat-status').textContent = enabled ? '● Aktiv' : '';
}
-function _currentKioskEnabled() { return document.getElementById('kiosk-chat-enabled').checked; }
-function _currentYtEnabled() { return document.getElementById('chat-status').textContent.startsWith('●'); }
-
function aktiveraChatOverlay(which) {
- const yt = which === 'yt' ? true : _currentYtEnabled();
- const kiosk = which === 'kiosk' ? true : _currentKioskEnabled();
- document.getElementById('kiosk-chat-enabled').checked = kiosk;
- _sendChat(yt, kiosk);
+ if (which === 'yt') _sendYtChat(true);
+ if (which === 'kiosk') _sendKioskChat(true);
}
function avaktiveraChatOverlay(which) {
- const yt = which === 'yt' ? false : _currentYtEnabled();
- const kiosk = which === 'kiosk' ? false : _currentKioskEnabled();
- document.getElementById('kiosk-chat-enabled').checked = kiosk;
- _sendChat(yt, kiosk);
+ if (which === 'yt') _sendYtChat(false);
+ if (which === 'kiosk') _sendKioskChat(false);
}
// --- Ticker ---
@@ -1541,6 +1602,7 @@ async function init() {
await laddaVolym();
await laddaTicker();
await laddaChat();
+ await laddaYtChat();
await laddaClock();
await laddaMusik();
await laddaWifiStatus();
diff --git a/kiosk/static/display.js b/kiosk/static/display.js
index f282e0b..85ec562 100644
--- a/kiosk/static/display.js
+++ b/kiosk/static/display.js
@@ -206,37 +206,49 @@ function _tickerNextLine() {
_tickerShowLine();
}
-// --- Chat overlays (YouTube + Kiosk livechat) ---
+// --- Chat overlays (YouTube Live Chat + Kiosk livechat, separata) ---
+
+let _currentYtId = null;
+let _currentIsYtLive = false; // true = bekräftad live-ström via YT.Player
+let _currentType = null;
+let _ytSinglePlayer = null; // YT.Player för youtube-typ
+
+// YouTube Live Chat state
+let _ytChat = { enabled: false, position: 'right', width_pct: 25, height: 100,
+ bg_color: '#000000', bg_opacity: 70, jobEnabled: true };
+
+// Kiosk livechat overlay state
+let _kioskChat = { enabled: false, position: 'right', width_pct: 30, height: 80, jobEnabled: true };
+
+function applyYtChat(data) {
+ _ytChat.enabled = !!data.enabled;
+ _ytChat.position = data.position || 'right';
+ _ytChat.width_pct = data.width_pct != null ? data.width_pct : 25;
+ _ytChat.height = data.height != null ? data.height : 100;
+ _ytChat.bg_color = data.bg_color || '#000000';
+ _ytChat.bg_opacity = data.bg_opacity != null ? data.bg_opacity : 70;
+ _updateChatOverlay();
+}
-// jobEnabled: true = follow global _chat.enabled, false = force-hide chat for this job
-let _chat = { enabled: false, position: 'right', width_pct: 25, height: 100,
- bg_color: '#000000', bg_opacity: 70, jobEnabled: true,
- kiosk_enabled: false, kiosk_width_pct: 30, kiosk_height: 80,
- kiosk_position: 'right' };
-let _currentYtId = null;
-let _currentType = null;
+function applyKioskChat(data) {
+ _kioskChat.enabled = !!data.kiosk_enabled;
+ _kioskChat.position = data.kiosk_position || 'right';
+ _kioskChat.width_pct = data.kiosk_width_pct != null ? data.kiosk_width_pct : 30;
+ _kioskChat.height = data.kiosk_height != null ? data.kiosk_height : 80;
+ _updateKioskChatOverlay();
+}
+// Kept for backward-compat: old chat_settings events from livechat addon
function applyChat(data) {
- _chat.enabled = !!data.enabled;
- _chat.position = data.position || 'right';
- _chat.width_pct = data.width_pct != null ? data.width_pct : 25;
- _chat.height = data.height != null ? data.height : 100;
- _chat.bg_color = data.bg_color || '#000000';
- _chat.bg_opacity = data.bg_opacity != null ? data.bg_opacity : 70;
- _chat.kiosk_enabled = !!data.kiosk_enabled;
- _chat.kiosk_width_pct = data.kiosk_width_pct != null ? data.kiosk_width_pct : 30;
- _chat.kiosk_height = data.kiosk_height != null ? data.kiosk_height : 80;
- _chat.kiosk_position = data.kiosk_position || 'right';
- _updateChatOverlay();
- _updateKioskChatOverlay();
+ applyKioskChat(data);
}
-// YouTube chat overlay
+// YouTube Live Chat overlay (bara för live-strömmar)
function _updateChatOverlay() {
const overlay = document.getElementById('chat-overlay');
const frame = document.getElementById('chat-frame');
- if (!_chat.enabled || !_chat.jobEnabled || !_currentYtId) {
+ if (!_ytChat.enabled || !_ytChat.jobEnabled || !_currentYtId || !_currentIsYtLive) {
overlay.style.display = 'none';
return;
}
@@ -258,8 +270,8 @@ function _updateChatOverlay() {
if (frame.src !== src) frame.src = src;
}
- _positionOverlay(overlay, _chat.position, _chat.width_pct, _chat.height);
- overlay.style.background = hexToRgba(_chat.bg_color, _chat.bg_opacity);
+ _positionOverlay(overlay, _ytChat.position, _ytChat.width_pct, _ytChat.height);
+ overlay.style.background = hexToRgba(_ytChat.bg_color, _ytChat.bg_opacity);
overlay.style.display = 'block';
}
@@ -360,9 +372,9 @@ function _kcEsc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<')
function _updateKioskChatOverlay() {
const overlay = document.getElementById('kiosk-chat-overlay');
- // Visa inte om: inaktiverad, job-döljer, är kiosk_chat-vy, eller YT-chat tar samma plats
- const ytChatActive = _chat.enabled && _chat.jobEnabled && !!_currentYtId;
- const showAsOverlay = _chat.kiosk_enabled && _chat.jobEnabled
+ // Visa inte om: inaktiverad, job-döljer, är kiosk_chat-vy, eller YT-live-chat tar samma plats
+ const ytChatActive = _ytChat.enabled && _ytChat.jobEnabled && _currentIsYtLive;
+ const showAsOverlay = _kioskChat.enabled && _kioskChat.jobEnabled
&& _currentType !== 'kiosk_chat'
&& !ytChatActive;
@@ -371,7 +383,7 @@ function _updateKioskChatOverlay() {
return;
}
- _positionOverlay(overlay, _chat.kiosk_position, _chat.kiosk_width_pct, _chat.kiosk_height);
+ _positionOverlay(overlay, _kioskChat.position, _kioskChat.width_pct, _kioskChat.height);
overlay.style.display = 'flex';
}
@@ -397,7 +409,12 @@ function _positionOverlay(el, position, widthPct, heightPct) {
}
socket.on('chat_settings', (data) => {
- applyChat(data);
+ // Kiosk livechat settings (from livechat addon)
+ applyKioskChat(data);
+});
+
+socket.on('yt_chat_settings', (data) => {
+ applyYtChat(data);
});
// --- Clock overlay ---
@@ -562,11 +579,20 @@ function _positionClock(el) {
socket.on('clock_settings', applyClockSettings);
socket.on('connect', () => {
+ // Server pushar nu all state via socket-events (ticker, chat_settings, etc.)
+ // Fetch-anrop som fallback om socket-events skulle missa något.
fetch('/api/ticker').then(r => r.json()).then(applyTicker).catch(() => {});
- fetch('/api/chat').then(r => r.json()).then(applyChat).catch(() => {});
+ fetch('/api/chat').then(r => r.json()).then(applyKioskChat).catch(() => {});
+ fetch('/api/yt_chat').then(r => r.json()).then(applyYtChat).catch(() => {});
fetch('/api/clock').then(r => r.json()).then(applyClockSettings).catch(() => {});
});
+socket.on('disconnect', () => {
+ // Nollställ live-state så att nästa connect börjar rent
+ _currentIsYtLive = false;
+ if (_ytSinglePlayer) { try { _ytSinglePlayer.destroy(); } catch {} _ytSinglePlayer = null; }
+});
+
function showLayer(name) {
['iframe', 'video', 'local', 'image', 'idle'].forEach(l => {
document.getElementById('layer-' + l).style.display = 'none';
@@ -575,8 +601,9 @@ function showLayer(name) {
}
socket.on('switch', (data) => {
- // Per-job chat override: false = force-hide, anything else = follow global setting
- _chat.jobEnabled = (data.show_chat !== false);
+ // Per-job chat override: show_chat=false döljer YT-livechat för jobbet
+ _ytChat.jobEnabled = (data.show_chat !== false);
+ _kioskChat.jobEnabled = true; // kiosk-chat styrs bara av sin globala toggle
// Track scheduled flag for clock schedule_mode
const prevScheduled = _isScheduled;
@@ -590,19 +617,34 @@ socket.on('switch', (data) => {
if (_vpl.player) { try { _vpl.player.stopVideo(); } catch {} }
}
+ // Stop single YT player when switching away
+ if (_currentType === 'youtube' && data.type !== 'youtube') {
+ if (_ytSinglePlayer) { try { _ytSinglePlayer.destroy(); } catch {} _ytSinglePlayer = null; }
+ }
+
// Track YouTube video ID and current type for overlays
const ytMatch = (data.source || '').match(/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/);
- _currentYtId = ytMatch ? ytMatch[1] : null;
- _currentType = data.type;
+ _currentYtId = ytMatch ? ytMatch[1] : null;
+ _currentIsYtLive = false; // reset — sätts om när YT.Player bekräftar live
+ _currentType = data.type;
if (data.type === 'kiosk_chat') {
document.getElementById('frame').src = window.location.origin + '/chat-display';
showLayer('iframe');
- } else if (data.type === 'url' || data.type === 'youtube') {
+ } else if (data.type === 'url') {
document.getElementById('frame').src = data.source;
showLayer('iframe');
+ } else if (data.type === 'youtube') {
+ if (_currentYtId) {
+ showLayer('iframe');
+ _playYoutubeVideo(_currentYtId);
+ } else {
+ document.getElementById('frame').src = data.source;
+ showLayer('iframe');
+ }
+
} else if (data.type === 'video') {
const v = document.getElementById('video');
v.src = data.source;
@@ -615,6 +657,7 @@ socket.on('switch', (data) => {
showLayer('image');
} else if (data.type === 'youtube_playlist') {
+ _vpl.shuffle = !!data.shuffle;
_startVideoPlaylist(data.source);
} else if (data.type === 'emulator') {
@@ -717,6 +760,38 @@ function _vplStart(ids) {
});
}
+// --- Enskild YouTube-video via YT.Player (för live-detektering) ---
+
+function _playYoutubeVideo(videoId) {
+ if (!window.YT || !window.YT.Player) {
+ setTimeout(() => _playYoutubeVideo(videoId), 500);
+ return;
+ }
+ if (_ytSinglePlayer) {
+ try { _ytSinglePlayer.destroy(); } catch {}
+ _ytSinglePlayer = null;
+ }
+ const layerFrame = document.getElementById('layer-iframe');
+ const oldFrame = document.getElementById('frame');
+ if (oldFrame) oldFrame.remove();
+ const div = document.createElement('div');
+ div.id = 'frame';
+ layerFrame.appendChild(div);
+
+ _ytSinglePlayer = new YT.Player('frame', {
+ videoId,
+ playerVars: { autoplay: 1, controls: 1, rel: 0, origin: window.location.origin },
+ events: {
+ onReady(e) {
+ e.target.playVideo();
+ // getDuration() === 0 innebär att videon är en live-ström
+ _currentIsYtLive = (e.target.getDuration() === 0);
+ _updateChatOverlay();
+ },
+ },
+ });
+}
+
async function _startVideoPlaylist(url) {
_vpl.url = url;
showLayer('iframe');
diff --git a/kiosk/sway-apps.sh b/kiosk/sway-apps.sh
index 9442c2e..ced412b 100755
--- a/kiosk/sway-apps.sh
+++ b/kiosk/sway-apps.sh
@@ -27,7 +27,10 @@ fi
CHROMIUM_FLAGS="--kiosk --ozone-platform=wayland --noerr --no-first-run \
--disable-infobars --disable-session-crashed-bubble \
--disable-features=Translate --disable-web-security \
- --autoplay-policy=no-user-gesture-required --no-sandbox"
+ --autoplay-policy=no-user-gesture-required --no-sandbox \
+ --disable-background-timer-throttling \
+ --disable-renderer-backgrounding \
+ --disable-backgrounding-occluded-windows"
# Vänta tills kiosk-servern svarar
until curl -sf -o /dev/null "$KIOSK_URL" 2>/dev/null; do sleep 1; done
diff --git a/kiosk/templates/addons/livechat/panel.html b/kiosk/templates/addons/livechat/panel.html
index 19479e6..e72bfc3 100644
--- a/kiosk/templates/addons/livechat/panel.html
+++ b/kiosk/templates/addons/livechat/panel.html
@@ -1,8 +1,6 @@
- <!-- Sektion: Livechat -->
+ <!-- Sektion: Kiosk Livechat -->
<section>
- <h2>Livechat</h2>
-
- <h3>Kiosk Livechat</h3>
+ <h2>Kiosk Livechat</h2>
<p style="font-size:.85rem;color:#555;margin-bottom:.75rem">Inbyggd chat — besökare deltar via QR-kod på sin mobil. Kan visas som helskärm (schema-typ "Kiosk Livechat") eller som overlay ovanpå annat innehåll.</p>
<div class="prop-grid">
@@ -38,49 +36,4 @@
<button class="secondary" onclick="avaktiveraChatOverlay('kiosk')">Stäng av overlay</button>
<span id="kiosk-chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
</div>
-
- <h3>YouTube Livechat</h3>
- <p style="font-size:.85rem;color:#555;margin-bottom:1rem">Visas automatiskt som en overlay när ett YouTube-liveflöde spelas. Döljs vid annat innehåll.</p>
-
- <div class="prop-grid">
- <label>Position</label>
- <select id="chat-position">
- <option value="right">Höger sida</option>
- <option value="left">Vänster sida</option>
- </select>
-
- <label>Bredd</label>
- <div class="row">
- <input type="range" id="chat-width-pct" min="5" max="60" step="1" value="25"
- oninput="document.getElementById('chat-width-pct-val').textContent=this.value+'%'">
- <span id="chat-width-pct-val" style="color:#aaa;min-width:3rem">25%</span>
- </div>
-
- <label>Höjd</label>
- <div class="row">
- <input type="range" id="chat-height" min="30" max="100" step="5" value="100"
- oninput="document.getElementById('chat-height-val').textContent=this.value+'%'">
- <span id="chat-height-val" style="color:#aaa;min-width:3rem">100%</span>
- </div>
- </div>
-
- <h3>Bakgrund</h3>
- <div class="prop-grid">
- <label>Färg</label>
- <input type="color" id="chat-bg-color" value="#000000">
-
- <label>Transparens</label>
- <div class="row">
- <input type="range" id="chat-bg-opacity" min="0" max="100" step="5" value="70"
- oninput="document.getElementById('chat-bg-opacity-val').textContent=this.value+'%'">
- <span id="chat-bg-opacity-val" style="color:#aaa;min-width:3rem">70%</span>
- </div>
- </div>
-
- <div class="row">
- <button onclick="aktiveraChatOverlay('yt')">Aktivera</button>
- <button class="secondary" onclick="avaktiveraChatOverlay('yt')">Stäng av</button>
- <button id="chat-theme-btn" class="secondary">Chatt: Auto</button>
- <span id="chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
- </div>
</section>
diff --git a/kiosk/templates/addons/settings/panel.html b/kiosk/templates/addons/settings/panel.html
index a4c70df..f95aaa6 100644
--- a/kiosk/templates/addons/settings/panel.html
+++ b/kiosk/templates/addons/settings/panel.html
@@ -45,4 +45,20 @@
<button class="secondary" onclick="applyDisplayNow()">Tillämpa upplösning nu</button>
<span id="settings-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
</div>
+
+ <h3>Admin-konto</h3>
+ <div class="prop-grid">
+ <label>Nuvarande lösenord</label>
+ <input type="password" id="cred-current" placeholder="Nuvarande lösenord" autocomplete="current-password">
+
+ <label>Nytt användarnamn</label>
+ <input type="text" id="cred-user" placeholder="Användarnamn" autocomplete="username">
+
+ <label>Nytt lösenord</label>
+ <input type="password" id="cred-pass" placeholder="Minst 4 tecken" autocomplete="new-password">
+ </div>
+ <div class="row" style="margin-top:.5rem">
+ <button onclick="bytaLösenord()">Byt inloggning</button>
+ <span id="cred-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
</section>
diff --git a/kiosk/templates/addons/youtube_livechat/panel.html b/kiosk/templates/addons/youtube_livechat/panel.html
new file mode 100644
index 0000000..8b6db2d
--- /dev/null
+++ b/kiosk/templates/addons/youtube_livechat/panel.html
@@ -0,0 +1,47 @@
+ <!-- Sektion: YouTube Live Chat -->
+ <section>
+ <h2>YouTube Live Chat</h2>
+ <p style="font-size:.85rem;color:#555;margin-bottom:.75rem">Visas automatiskt som overlay när en YouTube-liveström spelas upp. Döljs automatiskt för vanliga (inspelade) YouTube-videor.</p>
+
+ <div class="prop-grid">
+ <label>Position</label>
+ <select id="yt-chat-position">
+ <option value="right">Höger sida</option>
+ <option value="left">Vänster sida</option>
+ </select>
+
+ <label>Bredd</label>
+ <div class="row">
+ <input type="range" id="yt-chat-width" min="5" max="60" step="1" value="25"
+ oninput="document.getElementById('yt-chat-width-val').textContent=this.value+'%'">
+ <span id="yt-chat-width-val" style="color:#aaa;min-width:3rem">25%</span>
+ </div>
+
+ <label>Höjd</label>
+ <div class="row">
+ <input type="range" id="yt-chat-height" min="30" max="100" step="5" value="100"
+ oninput="document.getElementById('yt-chat-height-val').textContent=this.value+'%'">
+ <span id="yt-chat-height-val" style="color:#aaa;min-width:3rem">100%</span>
+ </div>
+ </div>
+
+ <h3>Bakgrund</h3>
+ <div class="prop-grid">
+ <label>Färg</label>
+ <input type="color" id="yt-chat-bg-color" value="#000000">
+
+ <label>Transparens</label>
+ <div class="row">
+ <input type="range" id="yt-chat-bg-opacity" min="0" max="100" step="5" value="70"
+ oninput="document.getElementById('yt-chat-bg-opacity-val').textContent=this.value+'%'">
+ <span id="yt-chat-bg-opacity-val" style="color:#aaa;min-width:3rem">70%</span>
+ </div>
+ </div>
+
+ <div class="row">
+ <button onclick="aktiveraChatOverlay('yt')">Aktivera</button>
+ <button class="secondary" onclick="avaktiveraChatOverlay('yt')">Stäng av</button>
+ <button id="yt-chat-theme-btn" class="secondary">Chatt: Auto</button>
+ <span id="yt-chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
+ </section>
diff --git a/kiosk/templates/admin.html b/kiosk/templates/admin.html
index e7c4d9d..aa05fb5 100644
--- a/kiosk/templates/admin.html
+++ b/kiosk/templates/admin.html
@@ -8,7 +8,10 @@
</head>
<body>
<div class="container">
- <h1>Kiosk Admin</h1>
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.5rem">
+ <h1 style="margin:0">Kiosk Admin</h1>
+ <a href="/logout" style="font-size:.85rem;color:#94a3b8;text-decoration:none;padding:.35rem .75rem;border:1px solid #334155;border-radius:5px" onmouseover="this.style.color='#f87171'" onmouseout="this.style.color='#94a3b8'">Logga ut</a>
+ </div>
<!-- Föreslagna uppspelningar från chatten -->
<div id="play-suggestions" style="display:none; margin-bottom:.75rem; flex-direction:column; gap:.4rem;"></div>
@@ -89,6 +92,11 @@
<button type="button" class="secondary" onclick="extractJobPlaylist('job-form')">Extrahera spellista</button>
<span id="job-extract-status" style="font-size:.75rem;color:#444"></span>
</div>
+ <div id="job-shuffle-wrap" style="display:none;grid-column:1/-1">
+ <label style="display:flex;align-items:center;gap:.5rem;color:#aaa;font-size:.9rem">
+ <input type="checkbox" name="shuffle"> Slumpa ordning
+ </label>
+ </div>
<div id="job-emu-wrap" style="display:none;grid-column:1/-1;flex-direction:column;gap:.25rem">
<button type="button" class="secondary" onclick="openEmuBrowser('job-form')">Välj ROM-fil</button>
<span id="job-emu-name" style="font-size:.8rem;color:#94a3b8;font-family:monospace"></span>
@@ -128,6 +136,11 @@
<button type="button" class="secondary" onclick="extractJobPlaylist('edit-form')">Extrahera spellista</button>
<span id="edit-extract-status" style="font-size:.75rem;color:#444"></span>
</div>
+ <div id="edit-shuffle-wrap" style="display:none;grid-column:1/-1">
+ <label style="display:flex;align-items:center;gap:.5rem;color:#aaa;font-size:.9rem">
+ <input type="checkbox" name="shuffle"> Slumpa ordning
+ </label>
+ </div>
<div id="edit-emu-wrap" style="display:none;grid-column:1/-1;flex-direction:column;gap:.25rem">
<button type="button" class="secondary" onclick="openEmuBrowser('edit-form')">Välj ROM-fil</button>
<span id="edit-emu-name" style="font-size:.8rem;color:#94a3b8;font-family:monospace"></span>
diff --git a/kiosk/templates/emulator.html b/kiosk/templates/emulator.html
index 6f5f72e..447daba 100644
--- a/kiosk/templates/emulator.html
+++ b/kiosk/templates/emulator.html
@@ -29,6 +29,7 @@
EJS_pathtodata = '{{ ejs_path }}';
EJS_language = 'sv-SE';
EJS_color = '#8b5cf6';
+ EJS_startOnLoaded = true;
EJS_Buttons = {
playPause: true,