commit c486aebafe5fd4bf40e7f7e1ea1d927fd3898130
Author: Jens Kristoffersson <jens.se@icloud.com>
AuthorDate: Sat Apr 4 09:32:36 2026 +0200
Commit: Jens Kristoffersson <jens.se@icloud.com>
CommitDate: Sat Apr 4 09:32:36 2026 +0200
Initial commit — kiosk display system
Flask+Sway kiosk med schema, ticker, klocka overlay, livechat,
WiFi-hantering och sovläge. Styrs via webbaserad adminpanel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
.gitignore | 30 +
install.sh | 100 +++
kiosk-admin-spec.md | 258 +++++++
kiosk-display.service | 28 +
kiosk-server.service | 17 +
kiosk/.gitignore | 17 +
kiosk/README.md | 254 ++++++
kiosk/START.md | 28 +
kiosk/app.py | 1527 +++++++++++++++++++++++++++++++++++++
kiosk/db.py | 158 ++++
kiosk/hdmi-monitor.sh | 14 +
kiosk/hdmi-wait.sh | 21 +
kiosk/kiosk-display.service | 23 +
kiosk/kiosk-launcher.sh | 72 ++
kiosk/plans.md | 0
kiosk/scheduler.py | 85 +++
kiosk/start.sh | 121 +++
kiosk/static/admin.js | 1108 +++++++++++++++++++++++++++
kiosk/static/display.js | 599 +++++++++++++++
kiosk/static/qrcode.min.js | 1 +
kiosk/static/style.css | 359 +++++++++
kiosk/sway-apps.sh | 65 ++
kiosk/sway.conf | 37 +
kiosk/templates/admin.html | 587 ++++++++++++++
kiosk/templates/chat_display.html | 258 +++++++
kiosk/templates/chat_mobile.html | 667 ++++++++++++++++
kiosk/templates/display.html | 197 +++++
kiosk/templates/login.html | 36 +
start.sh | 78 ++
sudoers-kiosk | 1 +
30 files changed, 6746 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c21d941
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,30 @@
+# Databas och körningsstate
+kiosk/kiosk.db
+kiosk/kiosk.db-shm
+kiosk/kiosk.db-wal
+
+# Maskinkonfiguration (skärm, ljud — maskinspecifik)
+kiosk/kiosk.conf
+kiosk.conf
+
+# Chattanvändare (personuppgifter)
+kiosk/chat_users.json
+
+# Uppladdade mediefiler
+kiosk/uploads/
+
+# Chat-avatarer
+kiosk/static/chat_avatars/
+
+# Socket.IO-bibliotek (laddas ned av install.sh)
+kiosk/static/socket.io.min.js
+
+# Python
+kiosk/__pycache__/
+*.pyc
+*.pyo
+
+# Temporära filer
+*.swp
+*.swo
+.DS_Store
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..d434623
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,100 @@
+#!/bin/bash
+# install.sh — Installerar och konfigurerar kiosken på Debian 13 (Wayland/Sway)
+set -e
+
+KIOSK_USER="${KIOSK_USER:-mrfox}"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+echo "=== Kiosk — installation ==="
+echo "Användare: $KIOSK_USER"
+echo "Katalog: $SCRIPT_DIR"
+echo ""
+
+# ── 1. Systempaket ────────────────────────────────────────────────────────────
+echo "[1/7] Installerar systempaket..."
+apt-get update -q
+apt-get install -y \
+ sway chromium curl \
+ iw wpasupplicant \
+ python3 python3-flask python3-flask-socketio \
+ python3-apscheduler python3-psutil python3-eventlet \
+ pipewire pipewire-audio
+
+# ── 2. Socket.IO-klientbibliotek ──────────────────────────────────────────────
+echo "[2/7] Kontrollerar socket.io.min.js..."
+SOCK_JS="$SCRIPT_DIR/kiosk/static/socket.io.min.js"
+if [ ! -f "$SOCK_JS" ]; then
+ echo " Laddar ner socket.io v4.7.5..."
+ curl -fsSL \
+ "https://cdn.jsdelivr.net/npm/socket.io@4.7.5/client-dist/socket.io.min.js" \
+ -o "$SOCK_JS"
+ echo " Sparad till $SOCK_JS"
+else
+ echo " Redan nedladdad."
+fi
+
+# ── 3. Katalog och rättigheter ────────────────────────────────────────────────
+echo "[3/7] Skapar kataloger och sätter rättigheter..."
+mkdir -p "$SCRIPT_DIR/kiosk/uploads"
+chmod +x \
+ "$SCRIPT_DIR/kiosk/start.sh" \
+ "$SCRIPT_DIR/kiosk/kiosk-launcher.sh" \
+ "$SCRIPT_DIR/kiosk/sway-apps.sh" \
+ "$SCRIPT_DIR/kiosk/hdmi-monitor.sh" \
+ "$SCRIPT_DIR/kiosk/hdmi-wait.sh"
+
+# ── 4. Wi-Fi sudoers ──────────────────────────────────────────────────────────
+echo "[4/7] Konfigurerar sudoers för Wi-Fi (wpa_cli)..."
+SUDOERS_FILE="/etc/sudoers.d/kiosk-wifi"
+if [ ! -f "$SUDOERS_FILE" ]; then
+ echo "$KIOSK_USER ALL=(ALL) NOPASSWD: /usr/sbin/wpa_cli" > "$SUDOERS_FILE"
+ chmod 440 "$SUDOERS_FILE"
+ echo " Skapad: $SUDOERS_FILE"
+else
+ echo " Redan konfigurerad."
+fi
+
+# ── 5. Autoinloggning på tty1 ─────────────────────────────────────────────────
+echo "[5/7] Konfigurerar autoinloggning på tty1 för $KIOSK_USER..."
+GETTY_OVERRIDE="/etc/systemd/system/getty@tty1.service.d/autologin.conf"
+mkdir -p "$(dirname "$GETTY_OVERRIDE")"
+if [ ! -f "$GETTY_OVERRIDE" ]; then
+ cat > "$GETTY_OVERRIDE" <<EOF
+[Service]
+ExecStart=
+ExecStart=-/sbin/agetty --autologin $KIOSK_USER --noclear %I \$TERM
+EOF
+ systemctl daemon-reload
+ echo " Autoinloggning aktiverad."
+else
+ echo " Redan konfigurerad."
+fi
+
+# ── 6. .bash_profile — starta kiosken vid tty1-inloggning ────────────────────
+echo "[6/7] Konfigurerar .bash_profile..."
+BASHPROFILE="/home/$KIOSK_USER/.bash_profile"
+LAUNCHER_LINE="[[ -z \$DISPLAY && \$(tty) == /dev/tty1 ]] && exec bash $SCRIPT_DIR/kiosk/kiosk-launcher.sh"
+if ! grep -qF "kiosk-launcher.sh" "$BASHPROFILE" 2>/dev/null; then
+ echo "" >> "$BASHPROFILE"
+ echo "$LAUNCHER_LINE" >> "$BASHPROFILE"
+ chown "$KIOSK_USER:$KIOSK_USER" "$BASHPROFILE"
+ echo " Lagt till i $BASHPROFILE"
+else
+ echo " Redan konfigurerad."
+fi
+
+# ── 7. Klar ───────────────────────────────────────────────────────────────────
+echo ""
+echo "[7/7] Installation klar!"
+echo ""
+echo "Nästa steg:"
+echo " 1. Redigera $SCRIPT_DIR/kiosk/start.sh och sätt KIOSK_USER / KIOSK_PASS"
+echo " 2. Anslut HDMI-skärm"
+echo " 3. Starta om datorn — kiosken startar automatiskt:"
+echo " sudo reboot"
+echo ""
+echo "Manuell start (utan omstart):"
+echo " bash $SCRIPT_DIR/kiosk/kiosk-launcher.sh"
+echo ""
+echo "Admin-panel:"
+echo " http://<IP>:5000/admin"
diff --git a/kiosk-admin-spec.md b/kiosk-admin-spec.md
new file mode 100644
index 0000000..d02b791
--- /dev/null
+++ b/kiosk-admin-spec.md
@@ -0,0 +1,258 @@
+# Kiosk Admin Panel — Specifikation & Nuläge
+
+## Systembeskrivning
+
+Lokalt hostad kiosk på x86 Debian 13-maskin.
+Chromium kör fullskärm via Cage (Wayland) och visar en lokal Flask-app.
+Admin-panel nås från valfri enhet på lokalt nätverk.
+Innehållsbyte sker via WebSocket — ingen omladdning, inget flicker.
+
+---
+
+## Maskin & miljö
+
+| Egenskap | Värde |
+|----------|-------|
+| OS | Debian 13 (trixie), x86 |
+| Användare | `mrfox` (uid=1000) |
+| Projektkatalog | `/home/mrfox/codes/display/` |
+| Compositor | Cage (Wayland), `-m last` (väljer sist ansluten skärm) |
+| Skärmar | `eDP-1` (inbyggd), `HDMI-A-1` (extern, används) |
+| IP (LAN) | `192.168.0.83` |
+| Port | `5000` |
+
+---
+
+## Projektstruktur
+
+```
+display/
+├── start.sh ← Huvudskript. Startar Flask + Cage i loop.
+├── install.sh ← Installerar paket + systemd-tjänster
+├── kiosk-server.service ← systemd: Flask-server
+├── kiosk-display.service ← systemd: Cage + Chromium
+├── sudoers-kiosk ← Sudoers-regel för reboot utan lösenord
+└── kiosk/
+ ├── app.py ← Flask-app, routes, SocketIO, auth, system-API
+ ├── db.py ← SQLite CRUD (jobs + state)
+ ├── scheduler.py ← APScheduler, reload_jobs()
+ ├── kiosk.db ← SQLite-databas (auto-skapas)
+ ├── templates/
+ │ ├── display.html ← Fullskärmssida (laddas en gång av Chromium)
+ │ ├── admin.html ← Admin-panel (svenska)
+ │ └── login.html ← Inloggningsformulär
+ └── static/
+ ├── display.js ← WebSocket-klient för display
+ ├── admin.js ← Admin-logik (fetch + SocketIO)
+ └── style.css ← Delade stilar (mörkt tema)
+```
+
+---
+
+## Starta systemet
+
+```bash
+bash /home/mrfox/codes/display/start.sh
+```
+
+`start.sh` kör i en loop: om Cage eller Flask kraschar startar den om automatiskt.
+PIDs sparas till `/tmp/kiosk.pids` (format: `FLASK_PID CAGE_PID`) — används av `/api/restart`.
+
+### Autologin + autostart vid boot
+
+```bash
+# Getty autologin på tty1
+sudo mkdir -p /etc/systemd/system/getty@tty1.service.d/
+sudo tee /etc/systemd/system/getty@tty1.service.d/autologin.conf << 'EOF'
+[Service]
+ExecStart=
+ExecStart=-/sbin/agetty --autologin mrfox --noclear %I $TERM
+EOF
+
+# Autostart via .bash_profile
+echo '[[ -z $DISPLAY && $(tty) == /dev/tty1 ]] && exec bash /home/mrfox/codes/display/start.sh' >> ~/.bash_profile
+```
+
+---
+
+## Miljövariabler (start.sh + kiosk-server.service)
+
+| Variabel | Värde |
+|----------|-------|
+| `KIOSK_USER` | `mrfox` |
+| `KIOSK_PASS` | `qweasdzxc` |
+| `SECRET_KEY` | `kiosk-hemlig-nyckel-byt-garna` |
+
+**VIKTIGT:** `SECRET_KEY` måste vara fast och identisk i alla instanser. Annars ogiltigförklaras sessioner vid omstart.
+
+---
+
+## Autentisering
+
+Ersatte HTTP Basic Auth med sessionsbaserat inloggningsformulär (fungerar i alla webbläsare utan HTTPS).
+
+- `GET /login` — visar formulär
+- `POST /login` — validerar mot `KIOSK_USER`/`KIOSK_PASS`, sätter `session['logged_in']`
+- `GET /logout` — rensar session
+- `login_required`-decorator skyddar `/admin` och alla `/api/*`-routes
+- `/display` kräver ingen auth
+
+---
+
+## Flask Routes
+
+| Method | Route | Auth | Beskrivning |
+|--------|-------|------|-------------|
+| GET | `/display` | Nej | Fullskärmssida för Chromium |
+| GET/POST | `/login` | Nej | Inloggning |
+| GET | `/logout` | Nej | Logga ut |
+| GET | `/admin` | Ja | Admin-panel |
+| GET | `/api/sysinfo` | Ja | CPU, RAM, disk, uptime, aktiv källa |
+| GET | `/api/jobs` | Ja | Lista alla schemalagda jobb |
+| POST | `/api/jobs` | Ja | Lägg till jobb |
+| DELETE | `/api/jobs/<id>` | Ja | Ta bort jobb |
+| PATCH | `/api/jobs/<id>` | Ja | Toggla aktivt/inaktivt |
+| POST | `/api/restart` | Ja | Starta om kiosk-programmet |
+| POST | `/api/reboot` | Ja | Starta om hela datorn (`sudo reboot`) |
+
+---
+
+## WebSocket
+
+### Server → Display
+
+```python
+socketio.emit('switch', { 'type': 'url'|'video'|'local', 'source': '...' })
+```
+
+### Admin → Server
+
+```python
+# Event: admin_switch
+# data = { type, source }
+# Sparar till DB + forwardar till display
+```
+
+### Reconnect-hantering
+
+Vid WebSocket-reconnect (t.ex. efter Chromium-reload) re-emittar servern senaste state från DB automatiskt via `on_connect`.
+
+---
+
+## Display-lager
+
+Tre lager i `display.html`, bara ett synligt åt gången:
+
+| Lager | ID | Användes för |
+|-------|----|--------------|
+| iframe | `layer-iframe` | URL-visning |
+| video | `layer-video` | Videofiler |
+| local | `layer-local` | Lokal HTML-injektion |
+
+Muspekaren är dold via `cursor: none !important` på alla element.
+
+---
+
+## Chromium-flaggor (start.sh)
+
+```bash
+cage -m last -- chromium \
+ --kiosk \
+ --no-sandbox \
+ --disable-gpu \
+ --disable-web-security \
+ --user-data-dir=/tmp/chromium-kiosk \
+ --disable-infobars \
+ --disable-session-crashed-bubble \
+ --disable-translate \
+ --no-first-run \
+ --ozone-platform=wayland \
+ http://localhost:5000/display
+```
+
+- `--disable-web-security` krävs för att visa sidor som blockerar iframe-inbäddning (X-Frame-Options), t.ex. Västtrafik avgångstavla
+- `--disable-gpu` + `--no-sandbox` krävs för att Chromium ska starta i kiosk-miljön
+- `-m last` i cage väljer sist ansluten skärm (HDMI när inkopplad)
+
+---
+
+## Admin-panel — sektioner
+
+1. **Visa nu** — URL-input, video-input, Töm skärm, Ladda om display
+2. **Schema** — tabell med jobb, formulär för att lägga till
+3. **System** — "Starta om programmet" (dödar cage, start.sh loopar), "Starta om datorn"
+4. **Systeminfo** — CPU/RAM/Disk/Uptime/Aktiv källa, auto-refresh var 10s
+
+### Felhantering i admin.js
+
+Alla `fetch()`-anrop går via `apiFetch()` som:
+- Detekterar redirect till `/login` (ogiltig session) → omdirigerar automatiskt
+- Kastar fel vid `!res.ok`
+
+---
+
+## SQLite-schema
+
+```sql
+CREATE TABLE jobs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ label TEXT NOT NULL,
+ time TEXT NOT NULL, -- "HH:MM" 24h
+ type TEXT NOT NULL, -- "url", "video", "local"
+ source TEXT NOT NULL,
+ enabled INTEGER DEFAULT 1
+);
+
+CREATE TABLE state (
+ key TEXT PRIMARY KEY,
+ value TEXT
+ -- Keys: current_type, current_source
+);
+```
+
+---
+
+## Installerade Python-paket (via apt)
+
+```
+python3-flask
+python3-flask-socketio
+python3-flask-httpauth (installerat men ej längre använt — kan tas bort)
+python3-apscheduler
+python3-psutil
+python3-eventlet
+```
+
+`pip` saknas på systemet — använd `apt-get install python3-<paket>`.
+
+---
+
+## Sudoers — reboot utan lösenord
+
+```bash
+sudo cp /home/mrfox/codes/display/sudoers-kiosk /etc/sudoers.d/kiosk
+sudo chmod 440 /etc/sudoers.d/kiosk
+# Innehåll: mrfox ALL=(ALL) NOPASSWD: /sbin/reboot, /usr/sbin/reboot
+```
+
+---
+
+## Kända problem / TODO
+
+- `flask-httpauth` är installerat men används inte längre (ersatt av sessionsauth) — kan rensas bort
+- `import signal` finns i app.py men används inte
+- Iframe fungerar inte för sidor med `X-Frame-Options` utan `--disable-web-security` i Chromium
+- Systemd-tjänsterna (`kiosk-server`, `kiosk-display`) är installerade men **ska vara inaktiverade** — `start.sh` via `.bash_profile` används istället för att undvika dubbla Flask-instanser
+ ```bash
+ sudo systemctl disable kiosk-server kiosk-display
+ sudo systemctl stop kiosk-server kiosk-display
+ ```
+- `/api/restart` dödar cage (PID från `/tmp/kiosk.pids`) — fungerar bara när start.sh kör (inte via systemd)
+
+---
+
+## socket.io
+
+Serveras lokalt av Flask-SocketIO på `/socket.io/socket.io.js`.
+Används i både `display.html` och `admin.html`.
+**Inte** CDN — fungerar offline.
diff --git a/kiosk-display.service b/kiosk-display.service
new file mode 100644
index 0000000..07175b8
--- /dev/null
+++ b/kiosk-display.service
@@ -0,0 +1,28 @@
+[Unit]
+Description=Kiosk Display (Cage + Chromium)
+After=kiosk-server.service
+Requires=kiosk-server.service
+
+[Service]
+User=mrfox
+Group=mrfox
+PAMName=login
+Environment=XDG_RUNTIME_DIR=/run/user/1000
+TTYPath=/dev/tty1
+StandardInput=tty
+ExecStartPre=/bin/bash -c "until curl -sf -o /dev/null http://127.0.0.1:5000/display; do sleep 1; done"
+ExecStart=/usr/bin/cage -- \
+ /usr/bin/chromium \
+ --kiosk \
+ --noerr \
+ --disable-infobars \
+ --disable-session-crashed-bubble \
+ --disable-features=Translate \
+ --no-first-run \
+ --ozone-platform=wayland \
+ http://localhost:5000/display
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/kiosk-server.service b/kiosk-server.service
new file mode 100644
index 0000000..3530a8a
--- /dev/null
+++ b/kiosk-server.service
@@ -0,0 +1,17 @@
+[Unit]
+Description=Kiosk Flask Server
+After=network.target
+
+[Service]
+User=mrfox
+WorkingDirectory=/home/mrfox/codes/display/kiosk
+ExecStart=/usr/bin/python3 app.py
+Restart=on-failure
+RestartSec=3
+Environment=KIOSK_USER=mrfox
+Environment=KIOSK_PASS=qweasdzxc
+Environment=SECRET_KEY=kiosk-hemlig-nyckel-byt-garna
+Environment=XDG_RUNTIME_DIR=/run/user/1000
+
+[Install]
+WantedBy=multi-user.target
diff --git a/kiosk/.gitignore b/kiosk/.gitignore
new file mode 100644
index 0000000..84afa8c
--- /dev/null
+++ b/kiosk/.gitignore
@@ -0,0 +1,17 @@
+__pycache__/
+*.py[cod]
+.venv/
+venv/
+env/
+*.sqlite3
+kiosk.db
+kiosk.conf
+uploads/
+node_modules/
+.DS_Store
+.env
+*.log
+*.pid
+.vscode/
+/.cache/
+*.pyc
\ No newline at end of file
diff --git a/kiosk/README.md b/kiosk/README.md
new file mode 100644
index 0000000..67c4cf7
--- /dev/null
+++ b/kiosk/README.md
@@ -0,0 +1,254 @@
+# Kiosk
+
+Digitalt skyltfönster / displayhanterare för Linux (Debian 13). Visar webbsidor, YouTube, bilder, video och lokala HTML-filer i helskärm via Chromium under Sway (Wayland). Styrs via en webbaserad adminpanel.
+
+---
+
+## Installation
+
+```bash
+sudo bash install.sh
+sudo reboot
+```
+
+Kiosken startar automatiskt vid omstart via autoinloggning på tty1.
+
+### Krav
+
+| Paket | Syfte |
+|---|---|
+| `sway` | Wayland-compositor |
+| `chromium` | Kiosk-webbläsare |
+| `python3-flask`, `python3-flask-socketio` | Webbserver |
+| `python3-apscheduler` | Schemaläggare |
+| `python3-psutil`, `python3-eventlet` | Systeminfo, async |
+| `iw`, `wpasupplicant` | Wi-Fi-hantering |
+| `curl` | Nätverkstest vid uppstart |
+
+---
+
+## Adminpanel
+
+```
+http://<maskinens-IP>:5000/admin
+```
+
+Standardinloggning — ändra i `kiosk/start.sh`:
+
+| Variabel | Standardvärde |
+|---|---|
+| `KIOSK_USER` | `mrfox` |
+| `KIOSK_PASS` | `qweasdzxc` |
+| `SECRET_KEY` | `kiosk-hemlig-nyckel-byt-garna` |
+
+---
+
+## Funktioner
+
+### Visa nu
+Visa något direkt utan att det läggs till i schemat:
+- **URL** — valfri webbadress, YouTube-länk konverteras automatiskt till embed
+- **Ladda upp fil** — video, bild, HTML, PDF/presentation
+- **Lokal fil** — bläddra bland filer på servern
+- **Töm skärm** — visar idle-skärm med IP-adress
+
+### Schema
+Jobb roterar automatiskt i ordning. Varje jobb har en varaktighet i minuter (decimaler OK, ex. `0.5` = 30 sek).
+
+| Typ | Beskrivning |
+|---|---|
+| `url` | Valfri webbadress |
+| `youtube` | YouTube-liveflöde eller video |
+| `video` | Videofil (mp4, webm, avi, mkv, mov) |
+| `image` | Bildfil (jpg, png, gif, webp, bmp) |
+| `pdf` | PDF eller presentation (ppt, pptx, odp) |
+| `kiosk_chat` | Inbyggd livechat i helskärm |
+
+Videolängd detekteras automatiskt vid filval.
+
+### Löptext (ticker)
+Rullande textrad som overlay. Aktiveras/stängs av utan omstart.
+
+- **Källa** — manuell text eller RSS-flöde (uppdateras automatiskt)
+- **Scrollläge** — en rad i taget eller löpande (kontinuerligt)
+- **Position** — nederkant eller överkant
+- **Hastighet**, **textfärg**, **storlek**, **typsnitt**, **bakgrundsfärg**, **opacitet**
+- **Separator** mellan rader i löpande läge
+
+**BBCode:**
+
+| Tagg | Effekt |
+|---|---|
+| `[b]...[/b]` | Fet |
+| `[i]...[/i]` | Kursiv |
+| `[u]...[/u]` | Understruken |
+| `[s]...[/s]` | Genomstruken |
+| `[color=#ff0000]...[/color]` | Textfärg |
+| `[size=64]...[/size]` | Storlek i px |
+| `[shadow]...[/shadow]` | Skugga |
+| `[glow=#00ff00]...[/glow]` | Glödeffekt |
+
+### Livechat
+- **Kiosk Livechat** — inbyggd chat, besökare ansluter via QR-kod på mobilen. Kan visas som helskärm (schema-typ `kiosk_chat`) eller som overlay ovanpå annat innehåll.
+- **YouTube Livechat** — overlay som visas automatiskt vid YouTube-liveflöde.
+
+Inställningar per chat: position (höger/vänster), bredd, höjd, bakgrundsfärg, opacitet.
+
+### Klocka
+Klock-overlay ovanpå allt innehåll.
+
+- **Stil** — digital (HH:MM:SS) eller analog (canvas med sekundvisare)
+- **Datum** — valfritt datum under klockan (sv: Mån 7 apr)
+- **Position** — övre vänster/höger, nedre vänster/höger, centrerad
+- **Storlek**, **färg**, **opacitet**
+- **Schema** — visa alltid, eller bara under schemalagda aktiviteter
+- Positioneras automatiskt bort från ticker-listen
+
+### Sovläge
+Täcker displayen med svart skärm under angiven tid (ex. 22:00–07:00).
+
+### Wi-Fi
+Hantera trådlöst nätverk direkt från adminpanelen.
+
+- **Status** — aktuell SSID, IP-adress, signalstyrka
+- **Skanna** — listar tillgängliga nätverk med signal och säkerhetstyp
+- **Anslut** — lösenordsmodal (öppna nätverk kräver inget lösenord)
+- **Sparade nätverk** — lista och ta bort
+
+> **Krav:** `wpa_cli` utan lösenord via sudoers. Sätts upp automatiskt av `install.sh`.
+
+### Inställningar
+- **Volym** — systemvolym via PipeWire (`wpctl`)
+- **Ljudprofil** — välj PipeWire-kortprofil (t.ex. HDMI Stereo, Analog Stereo)
+- **Intern display (eDP-1)** — aktivera/inaktivera den inbyggda skärmen med adminpanel
+- **HDMI-utgång** — välj vilken utgång som används (`HDMI-A-1` etc.)
+- **Upplösning** — välj bland tillgängliga lägen eller skriv in manuellt
+
+Om intern display är inaktiverad men **ingen Wi-Fi-anslutning** finns vid uppstart — aktiveras den automatiskt så att adminpanelen alltid är tillgänglig.
+
+### System
+Starta om programmet, starta om datorn, stäng av datorn.
+
+---
+
+## Uppstartflöde
+
+```
+Dator startar
+ └─ Getty autologin → tty1 → .bash_profile
+ └─ kiosk-launcher.sh
+ ├─ Väntar på HDMI-A-1
+ ├─ Startar kiosk/start.sh (Flask) i bakgrunden
+ ├─ Väntar på Flask (127.0.0.1:5000)
+ ├─ Väntar på Wi-Fi / IP-adress (max 40 s)
+ ├─ Genererar /tmp/kiosk-sway-outputs.conf
+ └─ Startar sway
+ └─ sway-apps.sh
+ ├─ Väntar på Wi-Fi / IP (max 40 s)
+ ├─ Pre-seedar Chromium-prefs (translate av)
+ ├─ Workspace 1 (HDMI): kiosk-display → http://<IP>:5000/display
+ └─ Workspace 2 (eDP-1): kiosk-admin → http://<IP>:5000/admin
+```
+
+---
+
+## Filstruktur
+
+```
+display/
+├── install.sh # Installationsscript
+├── start.sh # Gammal cage-baserad start (ej använd)
+└── kiosk/
+ ├── start.sh # Startar Flask (sätter env-vars, exec python3 app.py)
+ ├── kiosk-launcher.sh # Startar Flask + sway, genererar output-config
+ ├── sway.conf # Sway-konfiguration (hide_cursor, fullscreen etc.)
+ ├── sway-apps.sh # Startar Chromium på rätt workspace
+ ├── hdmi-monitor.sh # Övervakar HDMI-anslutning
+ ├── hdmi-wait.sh # Väntar på HDMI vid start
+ ├── kiosk.conf # Genereras av admin (ljud/skärm-inställningar)
+ ├── kiosk.db # SQLite-databas
+ ├── app.py # Flask-app, API-endpoints, WebSocket
+ ├── db.py # Databashjälpfunktioner
+ ├── scheduler.py # Schemarotation (APScheduler)
+ ├── uploads/ # Uppladdade mediefiler
+ ├── static/
+ │ ├── admin.js
+ │ ├── display.js
+ │ ├── style.css
+ │ └── socket.io.min.js # Laddas ner av install.sh
+ └── templates/
+ ├── admin.html
+ ├── display.html
+ ├── login.html
+ ├── chat_display.html
+ └── chat_mobile.html
+```
+
+---
+
+## Konfiguration
+
+### kiosk.conf
+Genereras automatiskt av adminpanelen. Kan redigeras manuellt:
+
+```bash
+KIOSK_DISPLAY_MODE=1920x1080@60Hz
+KIOSK_DISPLAY_OUTPUT=HDMI-A-1
+KIOSK_INTERNAL_DISPLAY=off
+KIOSK_AUDIO_PROFILE_INDEX=1
+```
+
+### Miljövariabler (kiosk/start.sh)
+```bash
+export KIOSK_USER=admin
+export KIOSK_PASS=mittlösenord
+export SECRET_KEY=en-lång-hemlig-sträng
+```
+
+---
+
+## Teknikstack
+
+| Komponent | Teknologi |
+|---|---|
+| Backend | Python 3, Flask, Flask-SocketIO, eventlet |
+| Schemaläggare | APScheduler |
+| Databas | SQLite |
+| Frontend | Vanilla JS, Socket.IO |
+| Compositor | Sway (Wayland) |
+| Webbläsare | Chromium (kiosk-läge, ozone-wayland) |
+| Ljud | PipeWire (`wpctl`, `pw-cli`) |
+| Wi-Fi | wpa_supplicant + wpa_cli |
+
+---
+
+## Felsökning
+
+**Kiosken startar inte:**
+```bash
+bash /home/mrfox/codes/display/kiosk/kiosk-launcher.sh
+```
+
+**Flask svarar inte:**
+```bash
+cd /home/mrfox/codes/display/kiosk && bash start.sh
+```
+
+**Sway startar inte / svart skärm:**
+```bash
+# Kontrollera att HDMI är anslutet, sedan:
+SWAYSOCK=$(ls /run/user/$(id -u)/sway-ipc.*.sock 2>/dev/null | head -1)
+swaymsg -s "$SWAYSOCK" exit
+```
+
+**Wi-Fi-sektionen i admin visar "sudo saknas":**
+```bash
+echo 'mrfox ALL=(ALL) NOPASSWD: /usr/sbin/wpa_cli' | sudo tee /etc/sudoers.d/kiosk-wifi
+sudo chmod 440 /etc/sudoers.d/kiosk-wifi
+```
+
+**Markör syns på displayen:**
+Kontrollera att `seat seat0 hide_cursor 3000` finns i `sway.conf` (läggs dit av install.sh).
+
+**Translate-popup i Chromium:**
+`sway-apps.sh` pre-seedar `Default/Preferences` med `"translate": {"enabled": false}` innan Chromium startar. Om det ändå visas: ta bort `/tmp/kiosk-display` och starta om.
diff --git a/kiosk/START.md b/kiosk/START.md
new file mode 100644
index 0000000..b94c8b9
--- /dev/null
+++ b/kiosk/START.md
@@ -0,0 +1,28 @@
+Startup script
+----------------
+
+This repository includes `start.sh` which will, if present, read `kiosk.conf` from the project root and attempt to apply the `KIOSK_DISPLAY_MODE` (e.g. `1920x1080`) using available system tools (`xrandr`, `swaymsg`, `wlr-randr`) before launching the app.
+
+Quick usage:
+
+1. Make the script executable:
+
+```bash
+chmod +x start.sh
+```
+
+2. Set `KIOSK_DISPLAY_MODE` in `kiosk.conf` (example):
+
+```
+KIOSK_DISPLAY_MODE=1920x1080
+```
+
+3. Start the kiosk:
+
+```bash
+./start.sh
+```
+
+Notes:
+- The display mode application is best-effort and depends on tools and compositor support on the target machine.
+- If immediate mode application fails, consider applying the mode in the system startup or using compositor-specific configuration.
diff --git a/kiosk/app.py b/kiosk/app.py
new file mode 100644
index 0000000..5499f33
--- /dev/null
+++ b/kiosk/app.py
@@ -0,0 +1,1527 @@
+import eventlet
+eventlet.monkey_patch()
+
+import os
+import json as _json
+import uuid as _uuid
+import signal
+import socket as _socket
+import psutil
+import subprocess
+import re
+import shutil
+from pathlib import Path
+from werkzeug.utils import secure_filename
+from werkzeug.security import generate_password_hash, check_password_hash
+from flask import Flask, render_template, jsonify, request, abort, session, redirect, url_for, send_file
+from flask_socketio import SocketIO, emit, Namespace
+import db
+import scheduler as sched
+
+# --- Chat user store ---
+CHAT_USERS_FILE = Path(__file__).parent / 'chat_users.json'
+
+def _load_chat_users():
+ try:
+ if CHAT_USERS_FILE.exists():
+ return _json.loads(CHAT_USERS_FILE.read_text())
+ except Exception:
+ pass
+ return {}
+
+def _save_chat_users(users):
+ CHAT_USERS_FILE.write_text(_json.dumps(users, indent=2))
+
+_chat_tokens = {} # token -> {username, emoji, color}
+
+_CHAT_COLORS = [
+ '#f87171','#fb923c','#fbbf24','#a3e635',
+ '#34d399','#38bdf8','#818cf8','#e879f9',
+ '#f472b6','#94a3b8',
+]
+
+CHAT_AVATARS_DIR = Path(__file__).parent / 'static' / 'chat_avatars'
+CHAT_AVATARS_DIR.mkdir(exist_ok=True)
+_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.webp']
+
+def _get_avatar_url(username):
+ safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
+ for ext in _AVATAR_EXTS:
+ if (CHAT_AVATARS_DIR / (safe + ext)).exists():
+ return f'/api/chat/avatar/{username}'
+ return None
+
+
+# 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'
+ if not conf.exists():
+ return
+ try:
+ vals = {}
+ for line in conf.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith('#') or '=' not in line:
+ continue
+ k, v = line.split('=', 1)
+ vals[k.strip()] = v.strip().strip('"').strip("'")
+ if 'KIOSK_DISPLAY_MODE' in vals:
+ db.set_state('display_mode', vals['KIOSK_DISPLAY_MODE'])
+ if 'KIOSK_DISPLAY_OUTPUT' in vals:
+ db.set_state('display_output', vals['KIOSK_DISPLAY_OUTPUT'])
+ if 'KIOSK_AUDIO_PROFILE_INDEX' in vals:
+ db.set_state('audio_profile_index', vals['KIOSK_AUDIO_PROFILE_INDEX'])
+ if 'KIOSK_INTERNAL_DISPLAY' in vals:
+ db.set_state('internal_display', vals['KIOSK_INTERNAL_DISPLAY'])
+ except Exception:
+ pass
+
+
+# load at import time
+_load_kiosk_conf()
+
+app = Flask(__name__)
+app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24))
+socketio = SocketIO(app, async_mode='eventlet', cors_allowed_origins='*')
+
+KIOSK_USER = os.environ.get('KIOSK_USER', 'admin')
+KIOSK_PASS = os.environ.get('KIOSK_PASS', 'changeme')
+
+ALLOWED_ROOTS = [Path('/home/mrfox'), Path('/media'), Path('/mnt')]
+MEDIA_EXTENSIONS = {
+ '.mp4', '.webm', '.avi', '.mkv', '.mov',
+ '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp',
+ '.html', '.htm',
+ '.pdf', '.ppt', '.pptx', '.odp',
+}
+UPLOAD_DIR = Path('/home/mrfox/codes/display/kiosk/uploads')
+
+
+def login_required(f):
+ from functools import wraps
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ if not session.get('logged_in'):
+ # For API/XHR requests, return 401 JSON instead of redirecting to login
+ is_xhr = request.headers.get('X-Requested-With') == 'XMLHttpRequest'
+ wants_json = request.is_json or 'application/json' in request.headers.get('Accept', '')
+ if is_xhr or wants_json or request.path.startswith('/api/'):
+ return jsonify({'error': 'login_required'}), 401
+ return redirect(url_for('login', next=request.path))
+ return f(*args, **kwargs)
+ return decorated
+
+
+# --- Login ---
+
+@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:
+ session['logged_in'] = True
+ return redirect(request.args.get('next') or url_for('admin'))
+ error = 'Fel användarnamn eller lösenord'
+ return render_template('login.html', error=error)
+
+
+@app.route('/logout')
+def logout():
+ session.clear()
+ return redirect(url_for('login'))
+
+
+# --- Display page (no auth) ---
+
+def _local_ip():
+ try:
+ s = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
+ s.connect(('8.8.8.8', 80))
+ ip = s.getsockname()[0]
+ s.close()
+ return ip
+ except Exception:
+ return '127.0.0.1'
+
+
+@app.route('/display')
+def display():
+ return render_template('display.html', local_ip=_local_ip())
+
+
+# --- Admin page ---
+
+@app.route('/admin')
+@login_required
+def admin():
+ return render_template('admin.html')
+
+
+# --- API: sysinfo ---
+
+@app.route('/api/sysinfo')
+@login_required
+def sysinfo():
+ uptime = subprocess.check_output('uptime -p', shell=True).decode().strip()
+ state = db.get_state()
+ return jsonify({
+ 'cpu': psutil.cpu_percent(interval=1),
+ 'ram': psutil.virtual_memory().percent,
+ 'disk': psutil.disk_usage('/').percent,
+ 'uptime': uptime,
+ 'active_type': state.get('current_type', '—'),
+ 'active_source': state.get('current_source', '—'),
+ })
+
+
+# --- API: jobs ---
+
+@app.route('/api/jobs', methods=['GET'])
+@login_required
+def get_jobs():
+ return jsonify(db.get_all_jobs())
+
+
+@app.route('/api/jobs', methods=['POST'])
+@login_required
+def add_job():
+ data = request.get_json()
+ if not data:
+ abort(400)
+ 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'])
+ sched.reload_jobs()
+ return jsonify({'id': job_id}), 201
+
+
+@app.route('/api/jobs/<int:job_id>', methods=['DELETE'])
+@login_required
+def delete_job(job_id):
+ db.delete_job(job_id)
+ sched.reload_jobs()
+ return '', 204
+
+
+@app.route('/api/jobs/<int:job_id>', methods=['PATCH'])
+@login_required
+def toggle_job(job_id):
+ # Allow partial update (edit fields) if JSON body provided, otherwise toggle enabled
+ data = request.get_json(silent=True)
+ if data:
+ allowed = {}
+ for k in ('label', 'duration', 'type', 'source', 'enabled'):
+ if k in data:
+ allowed[k] = data[k]
+ if 'duration' in allowed:
+ allowed['duration'] = float(allowed['duration'])
+ db.update_job(job_id, **allowed)
+ sched.reload_jobs()
+ return '', 204
+ else:
+ result = db.toggle_job(job_id)
+ if result is None:
+ abort(404)
+ sched.reload_jobs()
+ return jsonify(result)
+
+
+@app.route('/api/jobs/reorder', methods=['POST'])
+@login_required
+def reorder_jobs():
+ data = request.get_json()
+ if not data or 'order' not in data or not isinstance(data['order'], list):
+ abort(400)
+ ids = [int(x) for x in data['order']]
+ db.reorder_jobs(ids)
+ sched.reload_jobs()
+ return '', 204
+
+
+# --- Kiosk Livechat routes ---
+
+@app.route('/chat-display')
+def chat_display():
+ return render_template('chat_display.html', local_ip=_local_ip())
+
+
+@app.route('/chat-mobile')
+def chat_mobile():
+ return render_template('chat_mobile.html')
+
+
+@app.route('/api/chat-qr')
+def chat_qr():
+ try:
+ import qrcode as _qr
+ import io as _io
+ url = f'http://{_local_ip()}:5000/chat-mobile'
+ qr = _qr.QRCode(box_size=20, border=2)
+ qr.add_data(url)
+ qr.make(fit=True)
+ img = qr.make_image()
+ buf = _io.BytesIO()
+ img.save(buf, format='PNG')
+ buf.seek(0)
+ return send_file(buf, mimetype='image/png', max_age=30)
+ except ImportError:
+ abort(501)
+
+
+@app.route('/api/chat/register', methods=['POST'])
+def chat_register():
+ data = request.get_json(silent=True) or {}
+ username = str(data.get('username', '')).strip()[:32]
+ password = str(data.get('password', '')).strip()
+ emoji = str(data.get('emoji', '😀')).strip()[:4]
+ if not username or not password:
+ return jsonify({'error': 'Användarnamn och lösenord krävs'}), 400
+ users = _load_chat_users()
+ if username.lower() in {k.lower() for k in users}:
+ return jsonify({'error': 'Användarnamnet är redan taget'}), 409
+ color = _CHAT_COLORS[len(users) % len(_CHAT_COLORS)]
+ users[username] = {
+ 'password_hash': generate_password_hash(password),
+ 'emoji': emoji,
+ 'color': color,
+ }
+ _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})
+
+
+@app.route('/api/chat/login', methods=['POST'])
+def chat_login():
+ data = request.get_json(silent=True) or {}
+ username = str(data.get('username', '')).strip()
+ password = str(data.get('password', '')).strip()
+ if not username or not password:
+ return jsonify({'error': 'Fyll i användarnamn och lösenord'}), 400
+ users = _load_chat_users()
+ key = next((k for k in users if k.lower() == username.lower()), None)
+ if not key or not check_password_hash(users[key]['password_hash'], password):
+ return jsonify({'error': 'Fel användarnamn eller lösenord'}), 401
+ u = users[key]
+ avatar_url = _get_avatar_url(key)
+ token = str(_uuid.uuid4())
+ _chat_tokens[token] = {'username': key, 'emoji': u['emoji'], 'color': u['color'], 'avatar_url': avatar_url}
+ return jsonify({'token': token, 'username': key, 'emoji': u['emoji'], 'color': u['color'], 'avatar_url': avatar_url})
+
+
+@app.route('/api/chat/validate', methods=['POST'])
+def chat_validate():
+ data = request.get_json(silent=True) or {}
+ token = str(data.get('token', ''))
+ user = _chat_tokens.get(token)
+ if not user:
+ return jsonify({'error': 'Ogiltig token'}), 401
+ return jsonify({'username': user['username'], 'emoji': user['emoji'], 'color': user['color'], 'avatar_url': user.get('avatar_url')})
+
+
+@app.route('/api/chat/avatar/<username>')
+def serve_chat_avatar(username):
+ safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
+ for ext in _AVATAR_EXTS:
+ p = CHAT_AVATARS_DIR / (safe + ext)
+ if p.exists():
+ return send_file(p)
+ abort(404)
+
+
+@app.route('/api/chat/avatar', methods=['POST'])
+def upload_chat_avatar():
+ token = request.form.get('token', '')
+ user_info = _chat_tokens.get(token)
+ if not user_info:
+ return jsonify({'error': 'Ogiltig token'}), 401
+ f = request.files.get('avatar')
+ if not f or not f.filename:
+ return jsonify({'error': 'Ingen fil'}), 400
+ ext = Path(secure_filename(f.filename)).suffix.lower()
+ if ext not in _AVATAR_EXTS:
+ return jsonify({'error': 'Använd jpg, png eller webp'}), 400
+ username = user_info['username']
+ safe = re.sub(r'[^a-zA-Z0-9_\-]', '_', username)
+ for old_ext in _AVATAR_EXTS:
+ old = CHAT_AVATARS_DIR / (safe + old_ext)
+ if old.exists():
+ old.unlink()
+ dest = CHAT_AVATARS_DIR / (safe + ext)
+ f.save(str(dest))
+ avatar_url = f'/api/chat/avatar/{username}'
+ user_info['avatar_url'] = avatar_url
+ users = _load_chat_users()
+ if username in users:
+ users[username]['avatar_url'] = avatar_url
+ _save_chat_users(users)
+ return jsonify({'avatar_url': avatar_url})
+
+
+# --- Kiosk Livechat SocketIO namespace ---
+
+class ChatNamespace(Namespace):
+ _users = {} # sid -> {name, color, emoji}
+ _messages = [] # recent message history (last 50)
+
+ def on_connect(self):
+ sid = request.sid
+ if self._messages:
+ socketio.emit('chat_history', self._messages, to=sid, namespace='/chat')
+ socketio.emit('viewer_count', {'count': len(self._users)}, to=sid, namespace='/chat')
+
+ def on_join(self, data):
+ token = str(data.get('token', '')).strip()
+ if not token:
+ return
+ user_info = _chat_tokens.get(token)
+ if not user_info:
+ self.emit('auth_error', {'error': 'Ogiltig token, logga in igen'})
+ return
+ if request.sid in self._users:
+ return
+ username = user_info['username']
+ emoji = user_info['emoji']
+ color = user_info['color']
+ avatar_url = user_info.get('avatar_url')
+ self._users[request.sid] = {'name': username, 'color': color, 'emoji': emoji, 'avatar_url': avatar_url}
+ # Re-send history to authenticated client so chat-mobile gets it after login
+ if self._messages:
+ socketio.emit('chat_history', self._messages, to=request.sid, namespace='/chat')
+ socketio.emit('system_message', {'text': f'{username} gick med'}, namespace='/chat')
+ socketio.emit('viewer_count', {'count': len(self._users)}, namespace='/chat')
+
+ def on_chat_message(self, data):
+ user = self._users.get(request.sid)
+ if not user:
+ return
+ text = str(data.get('text', '')).strip()[:300]
+ if not text:
+ return
+ msg = {
+ 'name': user['name'],
+ 'color': user['color'],
+ 'emoji': user.get('emoji'),
+ 'avatar_url': user.get('avatar_url'),
+ 'text': text,
+ }
+ self._messages.append(msg)
+ if len(self._messages) > 50:
+ self._messages.pop(0)
+ socketio.emit('chat_message', msg, namespace='/chat')
+
+ def on_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.on_namespace(ChatNamespace('/chat'))
+
+
+# --- WebSocket ---
+
+def _is_sleep_active(start, end):
+ try:
+ now = __import__('datetime').datetime.now()
+ cur = now.hour * 60 + now.minute
+ sh, sm = map(int, start.split(':'))
+ eh, em = map(int, end.split(':'))
+ s, e = sh * 60 + sm, eh * 60 + em
+ return (cur >= s or cur < e) if s > e else (s <= cur < e)
+ except Exception:
+ return False
+
+
+@socketio.on('connect')
+def on_connect():
+ state = db.get_state()
+ if state.get('current_type'):
+ emit('switch', {
+ 'type': state['current_type'],
+ 'source': state.get('current_source', ''),
+ 'scheduled': False,
+ })
+ if state.get('sleep_enabled') == '1':
+ active = _is_sleep_active(
+ state.get('sleep_start', '22:00'),
+ state.get('sleep_end', '07:00'),
+ )
+ emit('sleep_toggle', {'active': active})
+ emit('clock_settings', {
+ 'enabled': state.get('clock_enabled', '0') == '1',
+ 'style': state.get('clock_style', 'digital'),
+ 'show_date': state.get('clock_show_date', '0') == '1',
+ 'position': state.get('clock_position', 'top-right'),
+ 'size': float(state.get('clock_size', '2')),
+ 'color': state.get('clock_color', '#ffffff'),
+ 'opacity': int(state.get('clock_opacity', '90')),
+ 'schedule_mode': state.get('clock_schedule_mode', 'always'),
+ })
+
+
+@socketio.on('admin_switch')
+def handle_switch(data):
+ type_ = data.get('type')
+ source = data.get('source', '')
+ db.set_current(type_, source)
+ socketio.emit('switch', {'type': type_, 'source': source, 'scheduled': False})
+
+
+# --- File browser ---
+
+@app.route('/files')
+def serve_file():
+ path = request.args.get('path', '')
+ if not path:
+ abort(400)
+ p = Path(path).resolve()
+ if not any(p.is_relative_to(r) for r in ALLOWED_ROOTS):
+ abort(403)
+ if not p.is_file():
+ abort(404)
+ return send_file(p)
+
+
+@app.route('/api/upload', methods=['POST'])
+@login_required
+def upload():
+ if 'file' not in request.files:
+ abort(400)
+ f = request.files['file']
+ if not f.filename:
+ abort(400)
+ name = secure_filename(f.filename)
+ ext = Path(name).suffix.lower()
+ if ext not in MEDIA_EXTENSIONS:
+ abort(415)
+ UPLOAD_DIR.mkdir(exist_ok=True)
+ dest = UPLOAD_DIR / name
+ if dest.exists():
+ stem = Path(name).stem
+ i = 1
+ while dest.exists():
+ dest = UPLOAD_DIR / f'{stem}_{i}{ext}'
+ i += 1
+ f.save(dest)
+
+ # Konvertera presentationer till PDF med LibreOffice om tillgängligt
+ if ext in {'.ppt', '.pptx', '.odp'} and shutil.which('libreoffice'):
+ try:
+ subprocess.run(
+ ['libreoffice', '--headless', '--convert-to', 'pdf', str(dest), '--outdir', str(UPLOAD_DIR)],
+ timeout=120, check=True,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ )
+ pdf_path = UPLOAD_DIR / (dest.stem + '.pdf')
+ if pdf_path.exists():
+ dest.unlink()
+ dest = pdf_path
+ except Exception:
+ pass
+
+ file_url = '/files?path=' + str(dest)
+ return jsonify({'path': str(dest), 'url': file_url})
+
+
+@app.route('/api/browse')
+@login_required
+def browse():
+ req_path = request.args.get('path', str(Path('/home/mrfox')))
+ p = Path(req_path).resolve()
+ if not any(p.is_relative_to(r) for r in ALLOWED_ROOTS):
+ abort(403)
+ if not p.is_dir():
+ abort(404)
+ entries = []
+ try:
+ for entry in sorted(p.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
+ if entry.name.startswith('.'):
+ continue
+ if entry.is_dir():
+ entries.append({'name': entry.name, 'path': str(entry), 'type': 'dir'})
+ elif entry.suffix.lower() in MEDIA_EXTENSIONS:
+ entries.append({'name': entry.name, 'path': str(entry), 'type': 'file', 'ext': entry.suffix.lower()})
+ except PermissionError:
+ pass
+ parent = str(p.parent)
+ if not any(Path(parent).is_relative_to(r) for r in ALLOWED_ROOTS):
+ parent = None
+ return jsonify({'path': str(p), 'parent': parent, 'entries': entries})
+
+
+# --- Settings ---
+
+def _audio_device_id():
+ """Return the PipeWire interface-id of the first Audio/Device."""
+ import json as _json
+ try:
+ out = subprocess.check_output(
+ ['pw-dump'], timeout=5,
+ env={**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+ ).decode()
+ for node in _json.loads(out):
+ if node.get('type') != 'PipeWire:Interface:Device':
+ continue
+ if node.get('info', {}).get('props', {}).get('media.class') == 'Audio/Device':
+ return node.get('id')
+ except Exception:
+ pass
+ return None
+
+
+def _parse_profiles(text):
+ """Parse pw-cli enum-params EnumProfile output into a list of dicts."""
+ profiles, cur, last_key = [], {}, None
+ for line in text.splitlines():
+ s = line.strip()
+ if s.startswith('Object:') and 'Profile' in s:
+ if 'index' in cur:
+ profiles.append(cur)
+ cur, last_key = {}, None
+ elif 'Profile:index' in s: last_key = 'index'
+ elif 'Profile:description' in s: last_key = 'description'
+ elif 'Profile:name' in s and 'description' not in s: last_key = 'name'
+ elif 'Profile:available' in s: last_key = 'available'
+ elif last_key == 'index' and s.startswith('Int '):
+ cur['index'] = int(s.split()[1]); last_key = None
+ elif last_key in ('name', 'description') and s.startswith('String '):
+ m = re.match(r'String "(.+)"', s)
+ if m: cur[last_key] = m.group(1)
+ last_key = None
+ elif last_key == 'available' and 'Availability' in s:
+ cur['available'] = 'yes' in s; last_key = None
+ if 'index' in cur:
+ profiles.append(cur)
+ skip = {'off', 'pro-audio'}
+ # Include all non-trivial profiles regardless of availability so user can always switch
+ return [p for p in profiles
+ if p.get('name') not in skip
+ and not p.get('name', '').startswith('input:')]
+
+
+def _apply_saved_audio():
+ """Apply the saved audio profile from DB state. Best-effort, non-fatal."""
+ try:
+ state = db.get_state()
+ idx = int(state.get('audio_profile_index', 1))
+ dev_id = _audio_device_id()
+ pw_env = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+ if dev_id is not None:
+ subprocess.run(
+ ['pw-cli', 's', str(dev_id), 'Profile', f'{{ index: {idx} }}'],
+ timeout=5, env=pw_env
+ )
+ except Exception:
+ pass
+
+
+def _apply_display_mode(mode_str, target_output=None):
+ """Best-effort apply display resolution given a string like '1920x1080'.
+ Tries xrandr, swaymsg, or wlr-randr when available. Non-fatal on failure."""
+ m = re.search(r"(\d+)x(\d+)", mode_str)
+ if not m:
+ return False
+ width, height = m.group(1), m.group(2)
+
+ # find first connected output from /sys/class/drm unless a target_output is given
+ out_name = None
+ if target_output:
+ out_name = target_output
+ else:
+ try:
+ for conn in sorted(Path('/sys/class/drm').iterdir()):
+ status_file = conn / 'status'
+ if not status_file.exists():
+ continue
+ if status_file.read_text().strip() == 'connected':
+ parts = conn.name.split('-', 1)
+ if len(parts) == 2:
+ out_name = parts[1]
+ break
+ except Exception:
+ out_name = None
+
+ # Auto-detect WAYLAND_DISPLAY if not set (e.g. when running as a systemd service)
+ env = os.environ.copy()
+ if not env.get('WAYLAND_DISPLAY'):
+ xdg_runtime = env.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
+ try:
+ for sock in sorted(Path(xdg_runtime).glob('wayland-[0-9]*')):
+ if sock.suffix != '.lock':
+ env['WAYLAND_DISPLAY'] = sock.name
+ break
+ except Exception:
+ pass
+
+ # Prefer Wayland wlroots tool first (wlr-randr)
+ if shutil.which('wlr-randr') and out_name:
+ try:
+ # if mode_str contains refresh token like '1920x1080@60.000000', strip @Hz for wlr-randr
+ token = mode_str.split('@', 1)[0]
+ subprocess.run(['wlr-randr', '--output', out_name, '--mode', token], timeout=5, check=True, env=env)
+ return True
+ except Exception:
+ pass
+
+ # Try swaymsg next (Wayland sway)
+ if shutil.which('swaymsg') and out_name:
+ try:
+ # swaymsg expects: output <name> mode <width> <height>
+ subprocess.run(['swaymsg', 'output', out_name, 'mode', width, height], timeout=5, check=True, env=env)
+ return True
+ except Exception:
+ pass
+
+ # Finally try xrandr (X11). Skip xrandr when running in Wayland session.
+ if os.environ.get('XDG_SESSION_TYPE', '').lower() == 'wayland' or os.environ.get('WAYLAND_DISPLAY'):
+ # running under Wayland — avoid xrandr
+ return False
+ if shutil.which('xrandr') and out_name:
+ # try common DISPLAY fallbacks so this works when daemonized
+ for disp in (None, ':0', ':0.0', ':1'):
+ try:
+ env = os.environ.copy()
+ if disp:
+ env['DISPLAY'] = disp
+ subprocess.run(['xrandr', '--output', out_name, '--mode', f'{width}x{height}'], env=env, timeout=5, check=True)
+ return True
+ except Exception:
+ continue
+
+ return False
+
+
+@app.route('/api/settings/apply_display', methods=['POST'])
+@login_required
+def apply_display_now():
+ # Apply display_mode from posted JSON or from saved state
+ data = request.get_json(silent=True) or {}
+ mode = data.get('display_mode')
+ target = data.get('display_output') or None
+ if not mode:
+ state = db.get_state()
+ mode = state.get('display_mode', '')
+ if not mode:
+ return jsonify({'ok': False, 'error': 'no_mode'}), 400
+ try:
+ ok = _apply_display_mode(str(mode), target_output=target)
+ # Re-apply saved audio profile — display changes can reset PipeWire routing
+ _apply_saved_audio()
+ return jsonify({'ok': bool(ok)})
+ except Exception as e:
+ return jsonify({'ok': False, 'error': str(e)}), 500
+
+
+@app.route('/api/audio/profiles')
+@login_required
+def audio_profiles():
+ dev_id = _audio_device_id()
+ if dev_id is None:
+ return jsonify([])
+ try:
+ pw_env = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+ out = subprocess.check_output(
+ ['pw-cli', 'enum-params', str(dev_id), 'EnumProfile'],
+ timeout=5, env=pw_env
+ ).decode()
+ return jsonify(_parse_profiles(out))
+ except Exception:
+ return jsonify([])
+
+
+@app.route('/api/displays')
+@login_required
+def list_displays():
+ outputs = []
+ try:
+ for conn in sorted(Path('/sys/class/drm').iterdir()):
+ status_file = conn / 'status'
+ if not status_file.exists():
+ continue
+ parts = conn.name.split('-', 1)
+ if len(parts) != 2:
+ continue
+ connector = parts[1]
+ connected = status_file.read_text().strip() == 'connected'
+ outputs.append({'name': connector, 'connected': connected})
+ except Exception:
+ pass
+
+ # Best-effort: if xrandr is available, append available modes per output
+ try:
+ if shutil.which('xrandr'):
+ out = ''
+ # try without DISPLAY and common DISPLAY fallbacks
+ for disp in (None, ':0', ':0.0', ':1'):
+ try:
+ env = os.environ.copy()
+ if disp:
+ env['DISPLAY'] = disp
+ out = subprocess.check_output(['xrandr', '--query'], env=env, timeout=5).decode(errors='ignore')
+ if out and ' connected' in out:
+ break
+ except Exception:
+ out = ''
+ lines = out.splitlines() if out else []
+ cur_out = None
+ modes_map = {}
+ for line in lines:
+ m = re.match(r"^([A-Za-z0-9-]+) connected", line)
+ if m:
+ cur_out = m.group(1)
+ modes_map[cur_out] = []
+ modes_current = None
+ continue
+ if cur_out:
+ m2 = re.match(r"^\s+(\d+)x(\d+)\s+([0-9\.]+)\s*Hz?(.*)$", line)
+ if m2:
+ w = m2.group(1)
+ h = m2.group(2)
+ hz = m2.group(3)
+ token = f"{w}x{h}@{hz}"
+ if token not in modes_map[cur_out]:
+ modes_map[cur_out].append(token)
+ rest = m2.group(4) or ''
+ if '*' in rest or 'current' in rest:
+ modes_map.setdefault('__current__', {})
+ modes_map['__current__'][cur_out] = token
+ elif line.strip() == '':
+ cur_out = None
+ if modes_map:
+ for o in outputs:
+ if o['name'] in modes_map and modes_map[o['name']]:
+ o['modes'] = modes_map[o['name']]
+ # extract current modes if any
+ curmap = modes_map.get('__current__', {})
+ if curmap:
+ for o in outputs:
+ if o['name'] in curmap:
+ o['current_mode'] = curmap[o['name']]
+ except Exception:
+ pass
+
+ # If no modes found yet and wlr-randr is available (Wayland wlroots), try parsing it
+ try:
+ if not any(o.get('modes') for o in outputs) and shutil.which('wlr-randr'):
+ env = os.environ.copy()
+ if not env.get('WAYLAND_DISPLAY'):
+ xdg_runtime = env.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
+ try:
+ for sock in sorted(Path(xdg_runtime).glob('wayland-[0-9]*')):
+ if sock.suffix != '.lock':
+ env['WAYLAND_DISPLAY'] = sock.name
+ break
+ except Exception:
+ pass
+ out = subprocess.check_output(['wlr-randr'], timeout=5, env=env).decode(errors='ignore')
+ lines = out.splitlines()
+ cur_out = None
+ modes_map = {}
+ for line in lines:
+ if line.strip() == '':
+ continue
+ # output lines usually start without indent
+ if line[0] != ' ' and line[0] != '\t':
+ cur_out = line.strip().split()[0]
+ modes_map[cur_out] = []
+ continue
+ # mode lines are indented and often contain WxH and optional (current)
+ m = re.search(r"(\d+)x(\d+)\s+px,\s+([0-9\.]+)\s+Hz(?:.*(current))?", line)
+ if cur_out and m:
+ w = m.group(1)
+ h = m.group(2)
+ hz = m.group(3)
+ token = f"{w}x{h}@{hz}"
+ if token not in modes_map[cur_out]:
+ modes_map[cur_out].append(token)
+ if 'current' in line:
+ modes_map.setdefault('__current__', {})
+ modes_map['__current__'][cur_out] = token
+ current_map = modes_map.pop('__current__', {})
+ if modes_map:
+ for o in outputs:
+ if o['name'] in modes_map and modes_map[o['name']]:
+ o['modes'] = modes_map[o['name']]
+ if o['name'] in current_map:
+ o['current_mode'] = current_map[o['name']]
+ except Exception:
+ pass
+
+ return jsonify(outputs)
+
+
+@app.route('/api/volume', methods=['GET'])
+@login_required
+def get_volume():
+ try:
+ out = subprocess.check_output(['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@'], timeout=5).decode()
+ # Format: "Volume: 1.00"
+ vol = round(float(out.split()[1]) * 100)
+ return jsonify({'volume': vol})
+ except Exception:
+ return jsonify({'volume': 100})
+
+
+@app.route('/api/volume', methods=['POST'])
+@login_required
+def set_volume():
+ data = request.get_json()
+ vol = max(0, min(100, int(data.get('volume', 100))))
+ subprocess.run(['wpctl', 'set-volume', '@DEFAULT_AUDIO_SINK@', f'{vol/100:.2f}'], timeout=5)
+ return '', 204
+
+
+@app.route('/api/settings', methods=['GET'])
+@login_required
+def get_settings():
+ state = db.get_state()
+ return jsonify({
+ 'audio_profile_index': int(state.get('audio_profile_index', 1)),
+ 'display_output': state.get('display_output', 'last'),
+ 'display_mode': state.get('display_mode', ''),
+ 'internal_display': state.get('internal_display', 'on'),
+ })
+
+
+@app.route('/api/settings', methods=['POST'])
+@login_required
+def save_settings():
+ data = request.get_json()
+ if not data:
+ abort(400)
+ profile_index = int(data.get('audio_profile_index', 1))
+ display = data.get('display_output', 'last')
+ display_mode = str(data.get('display_mode', '')).strip()
+ internal_display = 'on' if data.get('internal_display', True) else 'off'
+ db.set_state('audio_profile_index', str(profile_index))
+ db.set_state('display_output', display)
+ db.set_state('internal_display', internal_display)
+ if display_mode:
+ db.set_state('display_mode', display_mode)
+ conf = Path(os.path.dirname(__file__)) / 'kiosk.conf'
+ conf.write_text(
+ '# Genererad av kiosk-admin — ändra ej manuellt\n'
+ f'KIOSK_AUDIO_PROFILE_INDEX={profile_index}\n'
+ f'KIOSK_DISPLAY_OUTPUT={display}\n'
+ f'KIOSK_INTERNAL_DISPLAY={internal_display}\n'
+ + (f'KIOSK_DISPLAY_MODE={display_mode}\n' if display_mode else '')
+ )
+ # Tillämpa profilen direkt utan omstart
+ dev_id = _audio_device_id()
+ if dev_id is not None:
+ try:
+ pw_env = {**os.environ, 'XDG_RUNTIME_DIR': f'/run/user/{os.getuid()}'}
+ subprocess.run(
+ ['pw-cli', 's', str(dev_id), 'Profile', f'{{ index: {profile_index} }}'],
+ timeout=5, check=True, env=pw_env
+ )
+ except Exception:
+ pass
+ return '', 204
+
+
+# --- Söv-läge ---
+
+@app.route('/api/sleep', methods=['GET'])
+@login_required
+def get_sleep():
+ state = db.get_state()
+ return jsonify({
+ 'enabled': state.get('sleep_enabled', '0') == '1',
+ 'start': state.get('sleep_start', '22:00'),
+ 'end': state.get('sleep_end', '07:00'),
+ })
+
+
+@app.route('/api/sleep', methods=['POST'])
+@login_required
+def set_sleep():
+ data = request.get_json()
+ if not data:
+ abort(400)
+ enabled = bool(data.get('enabled', False))
+ start = str(data.get('start', '22:00'))
+ end = str(data.get('end', '07:00'))
+ # Validera HH:MM
+ import re as _re2
+ if not _re2.match(r'^\d{2}:\d{2}$', start): start = '22:00'
+ if not _re2.match(r'^\d{2}:\d{2}$', end): end = '07:00'
+ db.set_state('sleep_enabled', '1' if enabled else '0')
+ db.set_state('sleep_start', start)
+ db.set_state('sleep_end', end)
+ sched.reload_sleep_jobs()
+ # Skicka omedelbart om aktiv
+ active = enabled and _is_sleep_active(start, end)
+ socketio.emit('sleep_toggle', {'active': active})
+ return '', 204
+
+
+# --- Ticker ---
+
+import re as _re
+import urllib.request as _urlreq
+import xml.etree.ElementTree as _ET
+from datetime import datetime as _dt, timedelta as _td
+
+_HEX_COLOR = _re.compile(r'^#[0-9a-fA-F]{6}$')
+_rss_cache = {'url': None, 'items': [], 'fetched_at': None}
+
+
+def _fetch_rss(url, max_items=30):
+ global _rss_cache
+ now = _dt.now()
+ if (_rss_cache['url'] == url and _rss_cache['fetched_at']
+ and now - _rss_cache['fetched_at'] < _td(minutes=2)):
+ return _rss_cache['items']
+ try:
+ req = _urlreq.Request(url, headers={'User-Agent': 'KioskRSS/1.0'})
+ with _urlreq.urlopen(req, timeout=10) as r:
+ raw = r.read()
+ root = _ET.fromstring(raw)
+ items = []
+ # RSS 2.0
+ for item in root.findall('.//item'):
+ t = (item.findtext('title') or '').strip()
+ if t:
+ items.append(t)
+ # Atom
+ if not items:
+ ns = {'a': 'http://www.w3.org/2005/Atom'}
+ for entry in root.findall('.//a:entry', ns):
+ t = (entry.findtext('a:title', namespaces=ns) or '').strip()
+ if t:
+ items.append(t)
+ _rss_cache = {'url': url, 'items': items[:max_items], 'fetched_at': now}
+ except Exception:
+ pass # keep stale cache on error
+ return _rss_cache['items']
+
+
+@app.route('/api/ticker/rss')
+def get_ticker_rss():
+ state = db.get_state()
+ url = state.get('ticker_rss_url', '').strip()
+ if not url:
+ return jsonify({'items': [], 'error': 'Ingen RSS-URL konfigurerad'})
+ items = _fetch_rss(url)
+ return jsonify({'items': items})
+
+
+@app.route('/api/ticker', methods=['GET'])
+def get_ticker():
+ state = db.get_state()
+ return jsonify({
+ 'enabled': state.get('ticker_enabled', '0') == '1',
+ 'source': state.get('ticker_source', 'text'),
+ 'text': state.get('ticker_text', ''),
+ 'rss_url': state.get('ticker_rss_url', ''),
+ 'rss_interval': int(state.get('ticker_rss_interval', '10')),
+ 'pps': int(state.get('ticker_pps', '150')),
+ 'mode': state.get('ticker_mode', 'sequential'),
+ 'separator': state.get('ticker_separator', '◆'),
+ 'position': state.get('ticker_position', 'bottom'),
+ 'text_color': state.get('ticker_text_color', '#ffffff'),
+ 'font_size': float(state.get('ticker_font_size', '2.0')),
+ 'font_family': state.get('ticker_font_family', 'sans-serif'),
+ 'bar_color': state.get('ticker_bar_color', '#000000'),
+ 'bar_opacity': int(state.get('ticker_bar_opacity', '72')),
+ 'bar_padding': float(state.get('ticker_bar_padding', '0.45')),
+ })
+
+
+@app.route('/api/ticker', methods=['POST'])
+@login_required
+def set_ticker():
+ data = request.get_json()
+ if not data:
+ abort(400)
+ enabled = bool(data.get('enabled', False))
+ source = 'rss' if data.get('source') == 'rss' else 'text'
+ text = str(data.get('text', ''))
+ rss_url = str(data.get('rss_url', ''))
+ rss_interval = max(1, min(1440, int(data.get('rss_interval', 10))))
+ pps = max(30, min(800, int(data.get('pps', 150))))
+ mode = 'continuous' if data.get('mode') == 'continuous' else 'sequential'
+ separator = str(data.get('separator', '◆'))[:20]
+ position = 'top' if data.get('position') == 'top' else 'bottom'
+ text_color = str(data.get('text_color', '#ffffff'))
+ font_size = max(0.5, min(6.0, float(data.get('font_size', 2.0))))
+ font_family = str(data.get('font_family', 'sans-serif'))
+ bar_color = str(data.get('bar_color', '#000000'))
+ bar_opacity = max(0, min(100, int(data.get('bar_opacity', 72))))
+ bar_padding = max(0.1, min(2.0, float(data.get('bar_padding', 0.45))))
+ if not _HEX_COLOR.match(text_color): text_color = '#ffffff'
+ if not _HEX_COLOR.match(bar_color): bar_color = '#000000'
+ db.set_state('ticker_enabled', '1' if enabled else '0')
+ db.set_state('ticker_source', source)
+ db.set_state('ticker_text', text)
+ db.set_state('ticker_rss_url', rss_url)
+ db.set_state('ticker_rss_interval', str(rss_interval))
+ db.set_state('ticker_pps', str(pps))
+ db.set_state('ticker_mode', mode)
+ db.set_state('ticker_separator', separator)
+ db.set_state('ticker_position', position)
+ db.set_state('ticker_text_color', text_color)
+ db.set_state('ticker_font_size', str(font_size))
+ db.set_state('ticker_font_family', font_family)
+ db.set_state('ticker_bar_color', bar_color)
+ db.set_state('ticker_bar_opacity', str(bar_opacity))
+ db.set_state('ticker_bar_padding', str(bar_padding))
+ # Invalidate RSS cache when URL changes
+ if source == 'rss':
+ _rss_cache['fetched_at'] = None
+ payload = {
+ 'enabled': enabled, 'source': source,
+ 'text': text, 'rss_url': rss_url, 'rss_interval': rss_interval,
+ 'pps': pps, 'mode': mode, 'separator': separator, 'position': position,
+ 'text_color': text_color, 'font_size': font_size, 'font_family': font_family,
+ 'bar_color': bar_color, 'bar_opacity': bar_opacity, 'bar_padding': bar_padding,
+ }
+ socketio.emit('ticker', payload)
+ return '', 204
+
+
+# --- Chat overlay ---
+
+@app.route('/api/chat', methods=['GET'])
+def get_chat():
+ state = db.get_state()
+ return jsonify({
+ 'enabled': state.get('chat_enabled', '0') == '1',
+ 'position': state.get('chat_position', 'right'),
+ 'width_pct': int(state.get('chat_width_pct', '25')),
+ 'height': int(state.get('chat_height', '100')),
+ 'bg_color': state.get('chat_bg_color', '#000000'),
+ 'bg_opacity': int(state.get('chat_bg_opacity', '70')),
+ 'text_color': state.get('chat_text_color', '#ffffff'),
+ 'font_size': int(state.get('chat_font_size', '14')),
+ 'kiosk_enabled': state.get('kiosk_chat_enabled', '0') == '1',
+ 'kiosk_position': state.get('kiosk_chat_position', 'right'),
+ 'kiosk_width_pct': int(state.get('kiosk_chat_width', '30')),
+ 'kiosk_height': int(state.get('kiosk_chat_height', '80')),
+ })
+
+
+@app.route('/api/chat', methods=['POST'])
+@login_required
+def set_chat():
+ data = request.get_json()
+ if not data:
+ abort(400)
+ enabled = bool(data.get('enabled', False))
+ position = 'left' if data.get('position') == 'left' else 'right'
+ width_pct = max(5, min(60, int(data.get('width_pct', 25))))
+ height = max(30, min(100, int(data.get('height', 100))))
+ bg_color = str(data.get('bg_color', '#000000'))
+ bg_opacity = max(0, min(100, int(data.get('bg_opacity', 70))))
+ text_color = str(data.get('text_color', '#ffffff'))
+ font_size = max(8, min(48, int(data.get('font_size', 14))))
+ if not _HEX_COLOR.match(bg_color): bg_color = '#000000'
+ if not _HEX_COLOR.match(text_color): text_color = '#ffffff'
+ kiosk_enabled = bool(data.get('kiosk_enabled', False))
+ kiosk_position = 'left' if data.get('kiosk_position') == 'left' else 'right'
+ kiosk_width_pct = max(10, min(60, int(data.get('kiosk_width_pct', 30))))
+ kiosk_height = max(30, min(100, int(data.get('kiosk_height', 80))))
+ db.set_state('chat_enabled', '1' if enabled else '0')
+ db.set_state('chat_position', position)
+ db.set_state('chat_width_pct', str(width_pct))
+ db.set_state('chat_height', str(height))
+ db.set_state('chat_bg_color', bg_color)
+ db.set_state('chat_bg_opacity', str(bg_opacity))
+ db.set_state('chat_text_color', text_color)
+ db.set_state('chat_font_size', str(font_size))
+ db.set_state('kiosk_chat_enabled', '1' if kiosk_enabled else '0')
+ db.set_state('kiosk_chat_position', kiosk_position)
+ db.set_state('kiosk_chat_width', str(kiosk_width_pct))
+ db.set_state('kiosk_chat_height', str(kiosk_height))
+ payload = {
+ 'enabled': enabled, 'position': position,
+ 'width_pct': width_pct, 'height': height,
+ 'bg_color': bg_color, 'bg_opacity': bg_opacity,
+ 'text_color': text_color, 'font_size': font_size,
+ 'kiosk_enabled': kiosk_enabled, 'kiosk_position': kiosk_position,
+ 'kiosk_width_pct': kiosk_width_pct, 'kiosk_height': kiosk_height,
+ }
+ socketio.emit('chat_settings', payload)
+ return '', 204
+
+
+# --- Clock overlay ---
+
+@app.route('/api/clock', methods=['GET'])
+def get_clock():
+ state = db.get_state()
+ return jsonify({
+ 'enabled': state.get('clock_enabled', '0') == '1',
+ 'style': state.get('clock_style', 'digital'),
+ 'show_date': state.get('clock_show_date', '0') == '1',
+ 'position': state.get('clock_position', 'top-right'),
+ 'size': float(state.get('clock_size', '2')),
+ 'color': state.get('clock_color', '#ffffff'),
+ 'opacity': int(state.get('clock_opacity', '90')),
+ 'schedule_mode': state.get('clock_schedule_mode', 'always'),
+ })
+
+
+@app.route('/api/clock', methods=['POST'])
+@login_required
+def set_clock():
+ data = request.get_json()
+ if not data:
+ abort(400)
+ enabled = bool(data.get('enabled', False))
+ style = 'analog' if data.get('style') == 'analog' else 'digital'
+ show_date = bool(data.get('show_date', False))
+ position = data.get('position', 'top-right')
+ if position not in {'top-left', 'top-right', 'bottom-left', 'bottom-right', 'center'}:
+ position = 'top-right'
+ size = max(1.0, min(5.0, float(data.get('size', 2))))
+ color = str(data.get('color', '#ffffff'))
+ if not _HEX_COLOR.match(color):
+ color = '#ffffff'
+ opacity = max(0, min(100, int(data.get('opacity', 90))))
+ schedule_mode = 'scheduled_only' if data.get('schedule_mode') == 'scheduled_only' else 'always'
+
+ db.set_state('clock_enabled', '1' if enabled else '0')
+ db.set_state('clock_style', style)
+ db.set_state('clock_show_date', '1' if show_date else '0')
+ db.set_state('clock_position', position)
+ db.set_state('clock_size', str(size))
+ db.set_state('clock_color', color)
+ db.set_state('clock_opacity', str(opacity))
+ db.set_state('clock_schedule_mode', schedule_mode)
+
+ payload = {
+ 'enabled': enabled, 'style': style, 'show_date': show_date,
+ 'position': position, 'size': size, 'color': color,
+ 'opacity': opacity, 'schedule_mode': schedule_mode,
+ }
+ socketio.emit('clock_settings', payload)
+ return '', 204
+
+
+# --- Wi-Fi ---
+
+def _wifi_iface():
+ try:
+ with open('/proc/net/wireless') as _f:
+ for _line in _f:
+ if ':' in _line:
+ _i = _line.split(':')[0].strip()
+ if _i:
+ return _i
+ except Exception:
+ pass
+ return 'wlo1'
+
+_WIFI_IFACE = _wifi_iface()
+
+
+def _wpa(*args):
+ cmd = ['sudo', '-n', '/usr/sbin/wpa_cli', '-i', _WIFI_IFACE] + list(args)
+ try:
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
+ return r.returncode, r.stdout.strip()
+ except Exception as e:
+ return 1, str(e)
+
+
+def _wifi_status():
+ rc, out = _wpa('status')
+ d = {}
+ if rc == 0:
+ for line in out.splitlines():
+ if '=' in line:
+ k, v = line.split('=', 1)
+ d[k.strip()] = v.strip()
+ connected = d.get('wpa_state') == 'COMPLETED'
+ ip = ''
+ try:
+ r = subprocess.run(['ip', '-4', 'addr', 'show', _WIFI_IFACE],
+ capture_output=True, text=True, timeout=5)
+ m = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', r.stdout)
+ if m:
+ ip = m.group(1)
+ except Exception:
+ pass
+ return {
+ 'connected': connected,
+ 'ssid': d.get('ssid', ''),
+ 'ip': ip,
+ 'signal': int(d.get('signal_level', 0) or 0),
+ 'iface': _WIFI_IFACE,
+ 'sudo_ok': rc != 1 or 'sudo' not in out,
+ }
+
+
+def _wifi_scan():
+ _wpa('scan')
+ import time; time.sleep(2.5)
+ rc, out = _wpa('scan_results')
+ networks = []
+ seen = set()
+ for line in out.splitlines():
+ if not line or line.startswith('bssid'):
+ continue
+ parts = line.split('\t')
+ if len(parts) < 5:
+ continue
+ ssid = parts[4].strip()
+ if not ssid or ssid in seen:
+ continue
+ seen.add(ssid)
+ try:
+ dbm = int(parts[2])
+ except (ValueError, IndexError):
+ dbm = -90
+ flags = parts[3] if len(parts) > 3 else ''
+ if 'WPA2' in flags:
+ sec = 'WPA2'
+ elif 'WPA' in flags:
+ sec = 'WPA'
+ elif 'WEP' in flags:
+ sec = 'WEP'
+ else:
+ sec = 'open'
+ networks.append({
+ 'ssid': ssid,
+ 'signal': max(0, min(100, 2 * (dbm + 100))),
+ 'security': sec,
+ })
+ networks.sort(key=lambda x: -x['signal'])
+ return networks
+
+
+def _wifi_saved():
+ rc, out = _wpa('list_networks')
+ saved = []
+ for line in out.splitlines():
+ parts = line.split('\t')
+ if len(parts) >= 2 and parts[0].strip().isdigit():
+ ssid = parts[1].strip()
+ if ssid:
+ flags = parts[3].strip() if len(parts) > 3 else ''
+ saved.append({
+ 'id': parts[0].strip(),
+ 'ssid': ssid,
+ 'current': '[CURRENT]' in flags,
+ })
+ return saved
+
+
+def _wifi_connect(ssid, password):
+ for net in _wifi_saved():
+ if net['ssid'] == ssid:
+ _wpa('remove_network', net['id'])
+ rc, net_id = _wpa('add_network')
+ net_id = net_id.strip()
+ if not net_id.isdigit():
+ return False, 'Kunde inte lägga till nätverk'
+ _wpa('set_network', net_id, 'ssid', f'"{ssid}"')
+ if password:
+ _wpa('set_network', net_id, 'psk', f'"{password}"')
+ else:
+ _wpa('set_network', net_id, 'key_mgmt', 'NONE')
+ _wpa('select_network', net_id)
+ import time
+ for _ in range(20):
+ time.sleep(1)
+ s = _wifi_status()
+ if s['connected'] and s['ssid'] == ssid:
+ _wpa('save_config')
+ return True, s.get('ip', '')
+ _wpa('remove_network', net_id)
+ return False, 'Anslutning misslyckades (fel lösenord?)'
+
+
+@app.route('/api/wifi/status')
+@login_required
+def api_wifi_status():
+ return jsonify(_wifi_status())
+
+
+@app.route('/api/wifi/networks')
+@login_required
+def api_wifi_networks():
+ try:
+ nets = _wifi_scan()
+ known = {n['ssid'] for n in _wifi_saved()}
+ for n in nets:
+ n['known'] = n['ssid'] in known
+ return jsonify({'networks': nets, 'iface': _WIFI_IFACE})
+ except Exception as e:
+ return jsonify({'networks': [], 'error': str(e)})
+
+
+@app.route('/api/wifi/connect', methods=['POST'])
+@login_required
+def api_wifi_connect():
+ data = request.get_json()
+ if not data or not data.get('ssid'):
+ abort(400)
+ ssid = str(data['ssid'])[:64]
+ password = str(data.get('password', ''))[:64]
+ if '"' in ssid or '"' in password:
+ return jsonify({'ok': False, 'message': 'Ogiltiga tecken i SSID/lösenord'}), 400
+ ok, msg = _wifi_connect(ssid, password)
+ return jsonify({'ok': ok, 'message': msg if not ok else '', 'ip': msg if ok else ''})
+
+
+@app.route('/api/wifi/disconnect', methods=['POST'])
+@login_required
+def api_wifi_disconnect():
+ _wpa('disconnect')
+ return '', 204
+
+
+@app.route('/api/wifi/saved')
+@login_required
+def api_wifi_saved():
+ return jsonify({'saved': _wifi_saved()})
+
+
+@app.route('/api/wifi/saved/<net_id>', methods=['DELETE'])
+@login_required
+def api_wifi_remove(net_id):
+ if not net_id.isdigit():
+ abort(400)
+ _wpa('remove_network', net_id)
+ _wpa('save_config')
+ return '', 204
+
+
+# --- System ---
+
+@app.route('/api/reboot', methods=['POST'])
+@login_required
+def reboot():
+ subprocess.Popen(['sudo', 'reboot'])
+ return '', 204
+
+
+@app.route('/api/shutdown', methods=['POST'])
+@login_required
+def shutdown():
+ subprocess.Popen(['sudo', 'poweroff'])
+ return '', 204
+
+
+def _sway_env():
+ """Returnerar env-dict med SWAYSOCK satt, eller None om sway inte körs."""
+ uid = os.getuid()
+ xdg_runtime = os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{uid}')
+ sockets = sorted(Path(xdg_runtime).glob(f'sway-ipc.{uid}.*.sock'))
+ if not sockets:
+ return None
+ env = os.environ.copy()
+ env['SWAYSOCK'] = str(sockets[0])
+ return env
+
+
+@app.route('/api/restart', methods=['POST'])
+@login_required
+def restart():
+ # Avsluta sway — getty loopar och startar om via .bash_profile → kiosk-launcher.sh
+ try:
+ env = _sway_env()
+ if env:
+ subprocess.Popen(['swaymsg', 'exit'], env=env)
+ except Exception:
+ pass
+ return '', 204
+
+
+def _apply_internal_display(enabled, env):
+ """Aktivera eller stäng av eDP-1 (intern display) via sway."""
+ try:
+ if enabled:
+ subprocess.run(['swaymsg', 'output', 'eDP-1', 'enable'], env=env, timeout=5)
+ eventlet.sleep(0.5)
+ local_ip = _local_ip()
+ flags = (
+ '--kiosk --ozone-platform=wayland --noerr --no-first-run '
+ '--disable-infobars --disable-session-crashed-bubble --disable-translate '
+ '--disable-web-security --autoplay-policy=no-user-gesture-required '
+ '--no-sandbox --user-data-dir=/tmp/kiosk-admin'
+ )
+ subprocess.run(['swaymsg', 'workspace 2'], env=env, timeout=5)
+ eventlet.sleep(0.3)
+ subprocess.run(['swaymsg', 'exec', f'chromium {flags} http://{local_ip}:5000/admin'],
+ env=env, timeout=5)
+ eventlet.sleep(1)
+ subprocess.run(['swaymsg', 'workspace 1'], env=env, timeout=5)
+ else:
+ subprocess.run(['pkill', '-f', 'user-data-dir=/tmp/kiosk-admin'], timeout=5)
+ eventlet.sleep(0.3)
+ subprocess.run(['swaymsg', 'output', 'eDP-1', 'disable'], env=env, timeout=5)
+ except Exception:
+ pass
+
+
+@app.route('/api/internal_display', methods=['POST'])
+@login_required
+def toggle_internal_display():
+ data = request.get_json()
+ if data is None:
+ abort(400)
+ enabled = bool(data.get('enabled', True))
+ val = 'on' if enabled else 'off'
+
+ # Spara till DB och kiosk.conf
+ db.set_state('internal_display', val)
+ state = db.get_state()
+ conf = Path(os.path.dirname(__file__)) / 'kiosk.conf'
+ conf.write_text(
+ '# Genererad av kiosk-admin — ändra ej manuellt\n'
+ f'KIOSK_AUDIO_PROFILE_INDEX={state.get("audio_profile_index", 1)}\n'
+ f'KIOSK_DISPLAY_OUTPUT={state.get("display_output", "HDMI-A-1")}\n'
+ f'KIOSK_INTERNAL_DISPLAY={val}\n'
+ + (f'KIOSK_DISPLAY_MODE={state["display_mode"]}\n' if state.get("display_mode") else '')
+ )
+
+ env = _sway_env()
+ if not env:
+ return '', 204
+ eventlet.spawn(_apply_internal_display, enabled, env)
+ return '', 204
+
+
+# --- Startup ---
+
+def _apply_settings_after_cage():
+ """Background task: wait for sway's Wayland socket, then apply saved display + audio."""
+ xdg_runtime = os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')
+ # Wait up to 30 s for any wayland socket to appear (sway may not be running yet)
+ for _ in range(30):
+ eventlet.sleep(1)
+ socks = [p for p in Path(xdg_runtime).glob('wayland-[0-9]*') if p.suffix != '.lock']
+ if socks:
+ break
+ state = db.get_state()
+ saved_mode = state.get('display_mode', '')
+ if saved_mode:
+ _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
+ env = _sway_env()
+ if env:
+ internal_pref = state.get('internal_display', 'off')
+ wifi = _wifi_status()
+ should_enable = (not wifi['connected']) or (internal_pref == 'on')
+ if should_enable:
+ _apply_internal_display(True, env)
+
+
+if __name__ == '__main__':
+ db.init_db()
+ # Apply audio immediately (PipeWire is independent of the Wayland compositor)
+ _apply_saved_audio()
+ # Apply display settings in background after sway/Wayland is up
+ eventlet.spawn(_apply_settings_after_cage)
+ sched.init_scheduler(socketio)
+ socketio.run(app, host='0.0.0.0', port=5000)
diff --git a/kiosk/db.py b/kiosk/db.py
new file mode 100644
index 0000000..36e0480
--- /dev/null
+++ b/kiosk/db.py
@@ -0,0 +1,158 @@
+import sqlite3
+import os
+
+DB_PATH = os.path.join(os.path.dirname(__file__), 'kiosk.db')
+
+
+def get_conn():
+ conn = sqlite3.connect(DB_PATH)
+ conn.row_factory = sqlite3.Row
+ return conn
+
+
+def init_db():
+ with get_conn() as conn:
+ conn.executescript("""
+ CREATE TABLE IF NOT EXISTS jobs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ label TEXT NOT NULL,
+ duration REAL NOT NULL,
+ type TEXT NOT NULL,
+ source TEXT NOT NULL,
+ enabled INTEGER DEFAULT 1,
+ position INTEGER
+ );
+
+ CREATE TABLE IF NOT EXISTS state (
+ key TEXT PRIMARY KEY,
+ value TEXT
+ );
+ """)
+ # Migrering: time → duration
+ cols = {r[1]: r[2] for r in conn.execute("PRAGMA table_info(jobs)").fetchall()}
+ if 'time' in cols and 'duration' not in cols:
+ conn.executescript("""
+ ALTER TABLE jobs ADD COLUMN duration REAL NOT NULL DEFAULT 5;
+ UPDATE jobs SET duration = 5;
+ """)
+ # Migrering: add position column if missing
+ if 'position' not in cols:
+ conn.executescript("""
+ ALTER TABLE jobs RENAME TO jobs_old;
+ CREATE TABLE jobs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ label TEXT NOT NULL,
+ duration REAL NOT NULL,
+ type TEXT NOT NULL,
+ source TEXT NOT NULL,
+ enabled INTEGER DEFAULT 1,
+ position INTEGER
+ );
+ INSERT INTO jobs (id, label, duration, type, source, enabled) SELECT id, label, duration, type, source, enabled FROM jobs_old;
+ DROP TABLE jobs_old;
+ """)
+ # initialize positions to id
+ conn.execute("UPDATE jobs SET position = id;")
+ # Migrering: INTEGER → REAL
+ elif cols.get('duration') == 'INTEGER':
+ conn.executescript("""
+ ALTER TABLE jobs RENAME TO jobs_old;
+ CREATE TABLE jobs (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ label TEXT NOT NULL,
+ duration REAL NOT NULL,
+ type TEXT NOT NULL,
+ source TEXT NOT NULL,
+ enabled INTEGER DEFAULT 1
+ );
+ INSERT INTO jobs SELECT * FROM jobs_old;
+ DROP TABLE jobs_old;
+ """)
+
+
+# --- Jobs ---
+
+def get_all_jobs():
+ with get_conn() as conn:
+ return [dict(r) for r in conn.execute("SELECT * FROM jobs ORDER BY COALESCE(position, id), id").fetchall()]
+
+
+def get_enabled_jobs():
+ with get_conn() as conn:
+ return [dict(r) for r in conn.execute(
+ "SELECT * FROM jobs WHERE enabled = 1 ORDER BY COALESCE(position, id), id"
+ ).fetchall()]
+
+
+def add_job(label, duration, type_, source):
+ 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)
+ )
+ return cur.lastrowid
+
+
+def delete_job(job_id):
+ with get_conn() as conn:
+ conn.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
+
+
+def toggle_job(job_id):
+ with get_conn() as conn:
+ conn.execute(
+ "UPDATE jobs SET enabled = CASE WHEN enabled = 1 THEN 0 ELSE 1 END WHERE id = ?",
+ (job_id,)
+ )
+ row = conn.execute("SELECT enabled FROM jobs WHERE id = ?", (job_id,)).fetchone()
+ return dict(row) if row else None
+
+
+def update_job(job_id, **fields):
+ if not fields:
+ return
+ keys = []
+ vals = []
+ for k, v in fields.items():
+ if k in ('label', 'duration', 'type', 'source', 'enabled'):
+ keys.append(f"{k} = ?")
+ vals.append(v)
+ if not keys:
+ return
+ vals.append(job_id)
+ with get_conn() as conn:
+ conn.execute(f"UPDATE jobs SET {', '.join(keys)} WHERE id = ?", tuple(vals))
+
+
+def reorder_jobs(id_list):
+ with get_conn() as conn:
+ for idx, jid in enumerate(id_list, start=1):
+ conn.execute("UPDATE jobs SET position = ? WHERE id = ?", (idx, jid))
+
+
+# --- State ---
+
+def set_state(key, value):
+ with get_conn() as conn:
+ conn.execute(
+ "INSERT OR REPLACE INTO state (key, value) VALUES (?, ?)", (key, value)
+ )
+
+
+def set_current(type_, source):
+ with get_conn() as conn:
+ conn.execute(
+ "INSERT OR REPLACE INTO state (key, value) VALUES ('current_type', ?)", (type_,)
+ )
+ conn.execute(
+ "INSERT OR REPLACE INTO state (key, value) VALUES ('current_source', ?)", (source,)
+ )
+
+
+def get_state():
+ with get_conn() as conn:
+ rows = conn.execute("SELECT key, value FROM state").fetchall()
+ return {r['key']: r['value'] for r in rows}
diff --git a/kiosk/hdmi-monitor.sh b/kiosk/hdmi-monitor.sh
new file mode 100755
index 0000000..f67324f
--- /dev/null
+++ b/kiosk/hdmi-monitor.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+# hdmi-monitor.sh — körs i bakgrunden under sway-sessionen.
+# Avslutar sway om HDMI-A-1 kopplas ur så att kiosk-display.service
+# startar om och väntar på att kabeln kopplas in igen.
+
+STATUS="/sys/class/drm/card0-HDMI-A-1/status"
+
+while sleep 4; do
+ if [ "$(cat "$STATUS" 2>/dev/null)" != "connected" ]; then
+ echo "HDMI-A-1 frånkopplad — avslutar sway" >&2
+ swaymsg exit
+ break
+ fi
+done
diff --git a/kiosk/hdmi-wait.sh b/kiosk/hdmi-wait.sh
new file mode 100755
index 0000000..f5f62fe
--- /dev/null
+++ b/kiosk/hdmi-wait.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# hdmi-wait.sh — väntar tills HDMI-A-1 är ansluten, skriver meddelande till tty
+
+STATUS="/sys/class/drm/card0-HDMI-A-1/status"
+
+if [ "$(cat "$STATUS" 2>/dev/null)" = "connected" ]; then
+ exit 0
+fi
+
+echo ""
+echo " ╔══════════════════════════════════════════════════════╗"
+echo " ║ Anslut HDMI-kabeln till utgång HDMI-A-1 ║"
+echo " ║ Kiosken startar automatiskt när kabeln är klar. ║"
+echo " ╚══════════════════════════════════════════════════════╝"
+echo ""
+
+while [ "$(cat "$STATUS" 2>/dev/null)" != "connected" ]; do
+ sleep 2
+done
+
+echo " HDMI-A-1 ansluten — startar kiosk..."
diff --git a/kiosk/kiosk-display.service b/kiosk/kiosk-display.service
new file mode 100644
index 0000000..1b8a47c
--- /dev/null
+++ b/kiosk/kiosk-display.service
@@ -0,0 +1,23 @@
+[Unit]
+Description=Kiosk Display (Sway + Chromium)
+After=kiosk-server.service
+Requires=kiosk-server.service
+StartLimitIntervalSec=0
+
+[Service]
+User=mrfox
+Group=mrfox
+PAMName=login
+Environment=XDG_RUNTIME_DIR=/run/user/1000
+Environment=HOME=/home/mrfox
+TTYPath=/dev/tty1
+StandardInput=tty
+StandardOutput=tty
+StandardError=journal
+TimeoutStartSec=infinity
+ExecStart=/home/mrfox/codes/display/kiosk/kiosk-launcher.sh
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/kiosk/kiosk-launcher.sh b/kiosk/kiosk-launcher.sh
new file mode 100755
index 0000000..10595fa
--- /dev/null
+++ b/kiosk/kiosk-launcher.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+# kiosk-launcher.sh — läser kiosk.conf, genererar sway output-config och startar sway.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CONF="$SCRIPT_DIR/kiosk.conf"
+SWAY_CONF="$SCRIPT_DIR/sway.conf"
+OUTPUT_CONF="/tmp/kiosk-sway-outputs.conf"
+HDMI_STATUS="/sys/class/drm/card0-HDMI-A-1/status"
+
+# Vänta på HDMI-A-1 här — vi är nu inuti TTY-kontexten och kan skriva till tty1
+while [ "$(cat "$HDMI_STATUS" 2>/dev/null)" != "connected" ]; do
+ echo " Anslut HDMI-kabeln (HDMI-A-1) — kiosken startar automatiskt..."
+ sleep 3
+done
+echo " HDMI-A-1 ansluten — startar..."
+
+# Starta Flask om den inte redan svarar
+if ! curl -sf --max-time 2 -o /dev/null http://127.0.0.1:5000/display 2>/dev/null; then
+ bash "$SCRIPT_DIR/start.sh" &
+fi
+
+# Vänta tills kiosk-servern svarar
+until curl -sf -o /dev/null http://127.0.0.1:5000/display 2>/dev/null; do sleep 1; done
+
+# Vänta tills WiFi har en giltig IP (max 40 s) — annars visar idle-skärmen fel adress
+for _i in $(seq 1 40); do
+ _ip=$(ip route get 8.8.8.8 2>/dev/null \
+ | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}')
+ [ -n "$_ip" ] && [ "$_ip" != "127.0.0.1" ] && break
+ sleep 1
+done
+unset _i _ip
+
+# Standardvärden
+KIOSK_DISPLAY_OUTPUT="HDMI-A-1"
+KIOSK_DISPLAY_MODE="1920x1080@60Hz"
+KIOSK_INTERNAL_DISPLAY="on"
+
+if [ -f "$CONF" ]; then
+ # shellcheck disable=SC1090
+ . "$CONF"
+fi
+
+# Extrahera WxH och Hz (t.ex. "1280x720@60.000000" → WH="1280x720" HZ="60")
+RAW_MODE="${KIOSK_DISPLAY_MODE:-1920x1080}"
+WH="${RAW_MODE%%@*}"
+HZ_RAW="${RAW_MODE##*@}"
+HZ="${HZ_RAW%%.*}"
+[ -z "$HZ" ] && HZ="60"
+
+KIOSK_OUT="${KIOSK_DISPLAY_OUTPUT:-HDMI-A-1}"
+KIOSK_W="${WH%%x*}"
+
+# Generera output-config för sway
+if [ "$KIOSK_INTERNAL_DISPLAY" = "on" ]; then
+ cat > "$OUTPUT_CONF" <<EOF
+output $KIOSK_OUT mode ${WH}@${HZ}Hz pos 0 0 enable
+output eDP-1 mode 1920x1080@60Hz pos ${KIOSK_W},0 enable
+
+workspace 1 output $KIOSK_OUT
+workspace 2 output eDP-1
+EOF
+else
+ cat > "$OUTPUT_CONF" <<EOF
+output $KIOSK_OUT mode ${WH}@${HZ}Hz pos 0 0 enable
+output eDP-1 disable
+
+workspace 1 output $KIOSK_OUT
+EOF
+fi
+
+exec /usr/bin/sway -c "$SWAY_CONF"
diff --git a/kiosk/plans.md b/kiosk/plans.md
new file mode 100644
index 0000000..e69de29
diff --git a/kiosk/scheduler.py b/kiosk/scheduler.py
new file mode 100644
index 0000000..44731cf
--- /dev/null
+++ b/kiosk/scheduler.py
@@ -0,0 +1,85 @@
+from datetime import datetime, timedelta
+from apscheduler.schedulers.background import BackgroundScheduler
+import db
+
+scheduler = BackgroundScheduler()
+_socketio = None
+_current_index = 0
+
+
+def init_scheduler(socketio):
+ global _socketio
+ _socketio = socketio
+ scheduler.start()
+ reload_jobs()
+ reload_sleep_jobs()
+
+
+def reload_jobs():
+ global _current_index
+ _current_index = 0
+ try:
+ scheduler.remove_job('rotation')
+ except Exception:
+ pass
+ jobs = db.get_enabled_jobs()
+ if not jobs:
+ return
+ _emit_and_schedule(jobs[0])
+
+
+def _advance():
+ global _current_index
+ jobs = db.get_enabled_jobs()
+ if not jobs:
+ return
+ _current_index = (_current_index + 1) % len(jobs)
+ _emit_and_schedule(jobs[_current_index])
+
+
+def _emit_and_schedule(job):
+ if _socketio:
+ _socketio.emit('switch', {'type': job['type'], 'source': job['source'], 'scheduled': True})
+ db.set_current(job['type'], job['source'])
+ scheduler.add_job(
+ func=_advance,
+ trigger='date',
+ run_date=datetime.now() + timedelta(minutes=job['duration']),
+ id='rotation',
+ replace_existing=True
+ )
+
+
+# --- Söv-läge ---
+
+def _sleep_on():
+ if _socketio:
+ _socketio.emit('sleep_toggle', {'active': True})
+
+
+def _sleep_off():
+ if _socketio:
+ _socketio.emit('sleep_toggle', {'active': False})
+
+
+def reload_sleep_jobs():
+ for jid in ('sleep_on', 'sleep_off'):
+ try:
+ scheduler.remove_job(jid)
+ except Exception:
+ pass
+
+ state = db.get_state()
+ if state.get('sleep_enabled') != '1':
+ return
+
+ start = state.get('sleep_start', '22:00')
+ end = state.get('sleep_end', '07:00')
+ try:
+ sh, sm = map(int, start.split(':'))
+ eh, em = map(int, end.split(':'))
+ except Exception:
+ return
+
+ scheduler.add_job(_sleep_on, 'cron', hour=sh, minute=sm, id='sleep_on', replace_existing=True)
+ scheduler.add_job(_sleep_off, 'cron', hour=eh, minute=em, id='sleep_off', replace_existing=True)
diff --git a/kiosk/start.sh b/kiosk/start.sh
new file mode 100755
index 0000000..2408297
--- /dev/null
+++ b/kiosk/start.sh
@@ -0,0 +1,121 @@
+#!/usr/bin/env bash
+# start.sh — apply KIOSK_DISPLAY_MODE (best-effort) then launch the kiosk app
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CONF="$SCRIPT_DIR/kiosk.conf"
+
+if [ -f "$CONF" ]; then
+ # shellcheck disable=SC1090
+ . "$CONF"
+fi
+
+MODE="${KIOSK_DISPLAY_MODE:-}"
+
+apply_mode() {
+ local mode="$1"
+ # Accept WxH or WxH@Hz (e.g. 1280x720 or 1280x720@60.000000)
+ if [[ ! "$mode" =~ ^([0-9]+)x([0-9]+) ]]; then
+ echo "[start.sh] No valid mode specified: '$mode'"
+ return 1
+ fi
+ local W=${BASH_REMATCH[1]}
+ local H=${BASH_REMATCH[2]}
+ # Strip @Hz suffix for tools that don't accept it
+ local WH="${W}x${H}"
+ echo "[start.sh] Applying display mode $mode (best-effort)"
+
+ # Try X11 xrandr
+ if command -v xrandr >/dev/null 2>&1; then
+ OUT=$(xrandr --query 2>/dev/null | awk '/ connected/{print $1; exit}')
+ if [ -n "$OUT" ]; then
+ echo "[start.sh] Using xrandr on output $OUT"
+ if xrandr --output "$OUT" --mode "$WH" 2>/dev/null; then
+ echo "[start.sh] xrandr succeeded"
+ return 0
+ else
+ echo "[start.sh] xrandr failed to set mode"
+ fi
+ fi
+ fi
+
+ # Try sway (Wayland)
+ if command -v swaymsg >/dev/null 2>&1; then
+ echo "[start.sh] Trying swaymsg"
+ if swaymsg "output * mode ${W} ${H}" >/dev/null 2>&1; then
+ echo "[start.sh] swaymsg succeeded"
+ return 0
+ else
+ echo "[start.sh] swaymsg failed"
+ fi
+ fi
+
+ # Try wlr-randr
+ if command -v wlr-randr >/dev/null 2>&1; then
+ OUT=$(wlr-randr 2>/dev/null | awk 'NR==1{print $1}')
+ if [ -n "$OUT" ]; then
+ echo "[start.sh] Using wlr-randr on output $OUT"
+ if wlr-randr --output "$OUT" --mode "$WH" >/dev/null 2>&1; then
+ echo "[start.sh] wlr-randr succeeded"
+ return 0
+ else
+ echo "[start.sh] wlr-randr failed"
+ fi
+ fi
+ fi
+
+ echo "[start.sh] No supported display tool succeeded (this is best-effort)."
+ return 1
+}
+
+apply_audio() {
+ local index="$1"
+ if [[ ! "$index" =~ ^[0-9]+$ ]]; then
+ return 1
+ fi
+ if ! command -v pw-cli >/dev/null 2>&1; then
+ return 1
+ fi
+ echo "[start.sh] Applying audio profile index $index (best-effort)"
+ local dev_id
+ dev_id=$(pw-dump 2>/dev/null | python3 -c "
+import sys, json
+try:
+ for n in json.load(sys.stdin):
+ if n.get('type') == 'PipeWire:Interface:Device':
+ props = n.get('info', {}).get('props', {})
+ if props.get('media.class') == 'Audio/Device':
+ print(n['id'])
+ break
+except Exception:
+ pass
+" 2>/dev/null)
+ if [ -n "$dev_id" ]; then
+ if pw-cli s "$dev_id" Profile "{ index: $index }" >/dev/null 2>&1; then
+ echo "[start.sh] Audio profile applied (device $dev_id, index $index)"
+ return 0
+ else
+ echo "[start.sh] pw-cli failed to set audio profile"
+ fi
+ else
+ echo "[start.sh] No audio device found via pw-dump"
+ fi
+ return 1
+}
+
+if [ -n "$MODE" ]; then
+ apply_mode "$MODE" || echo "[start.sh] apply_mode failed, continuing to start app"
+else
+ echo "[start.sh] No KIOSK_DISPLAY_MODE set in $CONF"
+fi
+
+AUDIO_INDEX="${KIOSK_AUDIO_PROFILE_INDEX:-}"
+if [ -n "$AUDIO_INDEX" ]; then
+ apply_audio "$AUDIO_INDEX" || echo "[start.sh] apply_audio failed, continuing to start app"
+else
+ echo "[start.sh] No KIOSK_AUDIO_PROFILE_INDEX set in $CONF"
+fi
+
+cd "$SCRIPT_DIR"
+
+echo "[start.sh] Launching kiosk app"
+exec python3 app.py
diff --git a/kiosk/static/admin.js b/kiosk/static/admin.js
new file mode 100644
index 0000000..fc5ed01
--- /dev/null
+++ b/kiosk/static/admin.js
@@ -0,0 +1,1108 @@
+const socket = io();
+
+// --- Fetch-hjälpare med felhantering ---
+
+async function apiFetch(url, options = {}) {
+ const res = await fetch(url, options);
+ if (res.redirected && res.url.includes('/login')) {
+ window.location.href = '/login';
+ return null;
+ }
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res;
+}
+
+// --- Visa nu ---
+
+function visaAdress() {
+ let src = document.getElementById('url-input').value.trim();
+ if (!src) return;
+ if (!/^https?:\/\//i.test(src)) src = 'https://' + src;
+ const embed = youtubeEmbedUrl(src);
+ if (embed) {
+ socket.emit('admin_switch', { type: 'url', source: embed });
+ } else {
+ socket.emit('admin_switch', { type: 'url', source: src });
+ }
+}
+
+function extractYouTubeId(url) {
+ const patterns = [
+ /[?&]v=([a-zA-Z0-9_-]{11})/,
+ /youtu\.be\/([a-zA-Z0-9_-]{11})/,
+ /embed\/([a-zA-Z0-9_-]{11})/,
+ ];
+ for (const p of patterns) {
+ const m = url.match(p);
+ if (m) return m[1];
+ }
+ return null;
+}
+
+function youtubeEmbedUrl(url) {
+ const id = extractYouTubeId(url);
+ if (!id) return null;
+ return `https://www.youtube.com/embed/${id}?autoplay=1`;
+}
+
+
+function visaLokalFil(path, type) {
+ const src = '/files?path=' + encodeURIComponent(path);
+ socket.emit('admin_switch', { type, source: src });
+}
+
+async function setSchemaCalla(path, type) {
+ const form = document.getElementById('job-form');
+ const url = '/files?path=' + encodeURIComponent(path);
+ form.source.value = url;
+ form.type.value = type;
+ if (type === 'video') {
+ const mins = await getVideoDuration(url);
+ if (mins !== null) form.duration.value = mins;
+ }
+}
+
+function getVideoDuration(url) {
+ return new Promise((resolve) => {
+ const v = document.createElement('video');
+ v.preload = 'metadata';
+ v.onloadedmetadata = () => {
+ const mins = Math.ceil(v.duration / 60 * 100) / 100;
+ v.src = '';
+ resolve(mins);
+ };
+ v.onerror = () => resolve(null);
+ v.src = url;
+ });
+}
+
+function laddaUpp() {
+ const input = document.getElementById('upload-input');
+ const file = input.files[0];
+ if (!file) return;
+
+ const fill = document.getElementById('progress-fill');
+ const status = document.getElementById('upload-status');
+ const bar = document.getElementById('upload-progress');
+
+ fill.style.width = '0%';
+ status.textContent = 'Laddar upp...';
+ bar.style.display = 'flex';
+
+ const xhr = new XMLHttpRequest();
+
+ xhr.upload.onprogress = (e) => {
+ if (e.lengthComputable) {
+ fill.style.width = Math.round(e.loaded / e.total * 100) + '%';
+ }
+ };
+
+ xhr.onload = () => {
+ input.value = '';
+ if (xhr.status === 200) {
+ const data = JSON.parse(xhr.responseText);
+ fill.style.width = '100%';
+ status.textContent = '✓ ' + data.path.split('/').pop();
+ socket.emit('admin_switch', { type: extType(data.path), source: data.url });
+ setTimeout(() => { bar.style.display = 'none'; }, 3000);
+ } else {
+ status.textContent = 'Fel ' + xhr.status;
+ }
+ };
+
+ xhr.onerror = () => { status.textContent = 'Nätverksfel'; };
+ const fd = new FormData();
+ fd.append('file', file);
+ xhr.open('POST', '/api/upload');
+ xhr.send(fd);
+}
+
+async function laddaOmDisplay() {
+ const res = await apiFetch('/api/sysinfo');
+ if (!res) return;
+ const d = await res.json();
+ if (d.active_type && d.active_type !== '—') {
+ socket.emit('admin_switch', { type: d.active_type, source: d.active_source });
+ }
+}
+
+function tomSkarm() {
+ socket.emit('admin_switch', { type: 'local', source: '', html: '' });
+}
+
+// --- System ---
+
+async function startaOmProgram() {
+ if (!confirm('Starta om kiosken?')) return;
+ await apiFetch('/api/restart', { method: 'POST' });
+}
+
+async function startaOmSystemet() {
+ if (!confirm('Starta om hela datorn?')) return;
+ await apiFetch('/api/reboot', { method: 'POST' });
+}
+
+async function stangAvDatorn() {
+ if (!confirm('Stäng av datorn?')) return;
+ await apiFetch('/api/shutdown', { method: 'POST' });
+}
+
+// --- Schema ---
+
+async function laddaJobb() {
+ const res = await apiFetch('/api/jobs');
+ if (!res) return;
+ const jobs = await res.json();
+ const tbody = document.getElementById('jobs-body');
+
+ if (jobs.length === 0) {
+ tbody.innerHTML = '<tr><td colspan="7" class="empty">Inga schemalagda jobb</td></tr>';
+ return;
+ }
+
+ tbody.innerHTML = jobs.map(j => `
+ <tr id="job-${j.id}" data-id="${j.id}">
+ <td>${escHtml(j.label)}</td>
+ <td>${fmtDuration(j.duration)}</td>
+ <td>${escHtml(j.type)}</td>
+ <td class="source-cell">${escHtml(j.source)}</td>
+ <td>${j.show_chat ? '✓' : '—'}</td>
+ <td>
+ <button class="toggle ${j.enabled ? 'on' : 'off'}" onclick="toggleJobb(${j.id})">
+ ${j.enabled ? 'Ja' : 'Nej'}
+ </button>
+ </td>
+ <td>
+ <button class="secondary" onclick="openEdit(${j.id})">Redigera</button>
+ <button class="secondary" onclick="moveJob(${j.id}, 'up')">▲</button>
+ <button class="secondary" onclick="moveJob(${j.id}, 'down')">▼</button>
+ <button class="danger" onclick="taBortJobb(${j.id})">X</button>
+ </td>
+ </tr>
+ `).join('');
+ // enable drag-and-drop ordering
+ makeRowsDraggable(tbody);
+}
+
+function makeRowsDraggable(tbody) {
+ let dragSrc = null;
+ const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
+ rows.forEach(row => {
+ row.draggable = true;
+ row.addEventListener('dragstart', (e) => {
+ dragSrc = row;
+ e.dataTransfer.effectAllowed = 'move';
+ });
+ row.addEventListener('dragover', (e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ });
+ row.addEventListener('drop', (e) => {
+ e.preventDefault();
+ if (!dragSrc || dragSrc === row) return;
+ const nodes = Array.from(tbody.querySelectorAll('tr[data-id]'));
+ const srcIdx = nodes.indexOf(dragSrc);
+ const dstIdx = nodes.indexOf(row);
+ if (srcIdx < dstIdx) tbody.insertBefore(dragSrc, row.nextSibling);
+ else tbody.insertBefore(dragSrc, row);
+ sendReorder(tbody);
+ });
+ row.addEventListener('dragend', () => { dragSrc = null; });
+ });
+}
+
+function sendReorder(tbody) {
+ const newOrder = Array.from(tbody.querySelectorAll('tr[data-id]')).map(r => parseInt(r.dataset.id, 10));
+ fetch('/api/jobs/reorder', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: newOrder })
+ }).then(r => { if (r.ok) laddaJobb(); });
+}
+
+async function laggTillJobb(e) {
+ e.preventDefault();
+ const form = e.target;
+ let type = form.type.value;
+ let source = form.source.value;
+
+ if (type === 'youtube') {
+ const embed = youtubeEmbedUrl(source);
+ if (!embed) { alert('Ogiltig YouTube-URL'); return; }
+ type = 'url';
+ source = embed;
+ } else if (type === 'kiosk_chat') {
+ source = '/chat-display';
+ }
+
+ const data = {
+ label: form.label.value,
+ duration: form.duration.value,
+ type,
+ source,
+ show_chat: form.show_chat.checked,
+ };
+ const res = await apiFetch('/api/jobs', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ });
+ if (!res) return;
+ form.reset();
+ laddaJobb();
+}
+
+// --- Edit job ---
+function openEdit(id) {
+ const row = document.getElementById('job-' + id);
+ if (!row) return;
+ // fetch job data from server (or parse from row)
+ fetch('/api/jobs').then(r => r.json()).then(jobs => {
+ const job = jobs.find(x => x.id === id);
+ if (!job) return;
+ const modal = document.getElementById('edit-modal');
+ const form = document.getElementById('edit-form');
+ form.id.value = job.id;
+ form.label.value = job.label;
+ form.duration.value = job.duration;
+ form.type.value = job.type;
+ form.source.value = job.source;
+ modal.style.display = 'flex';
+ });
+}
+
+function closeEditModal() {
+ document.getElementById('edit-modal').style.display = 'none';
+}
+
+function editModalClick(e) {
+ if (e.target === document.getElementById('edit-modal')) closeEditModal();
+}
+
+async function saveEdit(e) {
+ e.preventDefault();
+ const form = e.target;
+ const id = parseInt(form.id.value, 10);
+ const payload = {
+ label: form.label.value,
+ duration: parseFloat(form.duration.value),
+ type: form.type.value,
+ source: form.source.value
+ };
+ const res = await apiFetch('/api/jobs/' + id, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+ if (!res) return;
+ closeEditModal();
+ laddaJobb();
+}
+
+// --- Reorder ---
+function moveJob(id, dir) {
+ const tbody = document.getElementById('jobs-body');
+ const rows = Array.from(tbody.querySelectorAll('tr[data-id]'));
+ const idx = rows.findIndex(r => String(r.dataset.id) === String(id));
+ if (idx === -1) return;
+ let newIdx = dir === 'up' ? idx - 1 : idx + 1;
+ if (newIdx < 0 || newIdx >= rows.length) return;
+ // swap nodes
+ const row = rows[idx];
+ const ref = rows[newIdx];
+ if (dir === 'up') tbody.insertBefore(row, ref);
+ else tbody.insertBefore(ref, row);
+ // send new order to server
+ const newOrder = Array.from(tbody.querySelectorAll('tr[data-id]')).map(r => parseInt(r.dataset.id, 10));
+ fetch('/api/jobs/reorder', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order: newOrder })
+ }).then(r => { if (r.ok) laddaJobb(); });
+}
+
+async function taBortJobb(id) {
+ const res = await apiFetch('/api/jobs/' + id, { method: 'DELETE' });
+ if (!res) return;
+ laddaJobb();
+}
+
+async function toggleJobb(id) {
+ const res = await apiFetch('/api/jobs/' + id, { method: 'PATCH' });
+ if (!res) return;
+ laddaJobb();
+}
+
+// --- Inställningar ---
+
+async function laddaVolym() {
+ const res = await apiFetch('/api/volume');
+ if (!res) return;
+ const d = await res.json();
+ const slider = document.getElementById('setting-volume');
+ slider.value = d.volume;
+ document.getElementById('volume-val').textContent = d.volume + '%';
+}
+
+async function setVolym() {
+ const vol = parseInt(document.getElementById('setting-volume').value, 10);
+ await apiFetch('/api/volume', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ volume: vol }),
+ });
+}
+
+async function laddaInstallningar() {
+ const [setRes, audioRes, dispRes] = await Promise.all([
+ apiFetch('/api/settings'),
+ apiFetch('/api/audio/profiles'),
+ apiFetch('/api/displays'),
+ ]);
+ if (!setRes || !audioRes || !dispRes) return;
+
+ const settings = await setRes.json();
+ const profiles = await audioRes.json();
+ const displays = await dispRes.json();
+
+ const audioSel = document.getElementById('setting-audio');
+ audioSel.innerHTML = profiles.map(p => {
+ const label = escHtml(p.description || p.name) + (p.available === false ? ' (frånkopplad)' : '');
+ return `<option value="${p.index}"${p.index === settings.audio_profile_index ? ' selected' : ''}>${label}</option>`;
+ }).join('');
+
+ document.getElementById('setting-internal-display').checked = settings.internal_display !== 'off';
+
+ // populate display target select with available outputs + special choices
+ const dispSel = document.getElementById('setting-display');
+ const outOptions = [{val:'last',label:'Sista anslutna (standard)'},{val:'first',label:'Första anslutna'}];
+ displays.forEach(d => outOptions.push({val:d.name,label:d.name + (d.connected ? ' (ansluten)' : ' (ej ansluten)')}));
+ dispSel.innerHTML = outOptions.map(o => `<option value="${o.val}"${o.val===settings.display_output? ' selected':''}>${escHtml(o.label)}</option>`).join('');
+
+ // build a mapping name -> modes/current
+ const modesByOutput = {};
+ displays.forEach(d => { modesByOutput[d.name] = {modes: Array.isArray(d.modes)?d.modes:[], current: d.current_mode || ''}; });
+
+ const modeSel = document.getElementById('setting-display-mode');
+ const customInput = document.getElementById('setting-display-mode-custom');
+
+ function formatLabel(token) {
+ // token like '1920x1080@60.000000' -> '1920x1080 @ 60 Hz'
+ if (!token) return '';
+ if (token === '__custom__') return 'Ange manuellt...';
+ const parts = token.split('@');
+ const hz = parts[1] ? String(Math.round(parseFloat(parts[1])*1000)/1000).replace(/\.0+$/,'') : '';
+ return parts[0] + (hz ? (' @ ' + hz + ' Hz') : '');
+ }
+
+ function populateModesForOutput(outName) {
+ const info = modesByOutput[outName] || {modes:[], current:''};
+ const modes = info.modes.slice();
+ const currentMode = settings.display_mode || info.current || '';
+ if (currentMode && !modes.includes(currentMode)) modes.unshift(currentMode);
+ // sort by resolution descending (compare token prefix)
+ modes.sort((a,b)=>{const A=a.split('@')[0].split('x').map(Number),B=b.split('@')[0].split('x').map(Number);return B[0]-A[0]||B[1]-A[1];});
+ modeSel.innerHTML = '<option value="">(Ingen / lämna tomt)</option>' + modes.map(m => `<option value="${m}">${formatLabel(m)}${m===info.current? ' (aktuell)':''}</option>`).join('') + `<option value="__custom__">${formatLabel('__custom__')}</option>`;
+ // set selected
+ if (currentMode) {
+ if (modes.includes(currentMode)) { modeSel.value = currentMode; customInput.style.display='none'; }
+ else { modeSel.value='__custom__'; customInput.style.display=''; customInput.value = currentMode; }
+ } else { modeSel.value = ''; customInput.style.display='none'; }
+ }
+
+ // initial populate for selected display
+ const initialOut = dispSel.value && dispSel.value !== 'last' && dispSel.value !== 'first' ? dispSel.value : (displays.find(d=>d.connected)?.name || '');
+ if (initialOut) populateModesForOutput(initialOut);
+ dispSel.addEventListener('change', () => {
+ const v = dispSel.value;
+ if (v === 'last' || v === 'first') {
+ // choose connected first/last
+ const candidate = v === 'last' ? (displays.slice().reverse().find(d=>d.connected) || displays.find(d=>d.connected)) : (displays.find(d=>d.connected) || null);
+ populateModesForOutput(candidate ? candidate.name : '');
+ } else {
+ populateModesForOutput(v);
+ }
+ });
+ modeSel.addEventListener('change', () => {
+ if (modeSel.value === '__custom__') { customInput.style.display=''; customInput.focus(); } else { customInput.style.display='none'; }
+ });
+
+ const connected = displays.filter(d => d.connected).map(d => d.name).join(', ');
+ const disconnected = displays.filter(d => !d.connected).map(d => d.name).join(', ');
+ const parts = [];
+ if (connected) parts.push('Anslutna: ' + connected);
+ if (disconnected) parts.push('Ej anslutna: ' + disconnected);
+ document.getElementById('displays-info').textContent = parts.join(' | ');
+}
+
+async function setInternDisplay(enabled) {
+ const status = document.getElementById('settings-status');
+ status.textContent = enabled ? 'Aktiverar intern display...' : 'Inaktiverar intern display...';
+ const res = await apiFetch('/api/internal_display', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ enabled }),
+ });
+ if (!res) return;
+ status.textContent = enabled ? '✓ Intern display aktiverad' : '✓ Intern display inaktiverad';
+ setTimeout(() => { status.textContent = ''; }, 3000);
+}
+
+async function sparaInstallningar() {
+ const profileIndex = parseInt(document.getElementById('setting-audio').value, 10);
+ const display = document.getElementById('setting-display').value;
+ const modeSelEl = document.getElementById('setting-display-mode');
+ let displayMode = '';
+ if (modeSelEl.value === '__custom__') {
+ displayMode = document.getElementById('setting-display-mode-custom').value.trim();
+ } else {
+ displayMode = modeSelEl.value.trim();
+ }
+ const status = document.getElementById('settings-status');
+
+ status.textContent = 'Tillämpar...';
+ const res = await apiFetch('/api/settings', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ audio_profile_index: profileIndex, display_output: display, display_mode: displayMode, internal_display: document.getElementById('setting-internal-display').checked }),
+ });
+ if (!res) return;
+ status.textContent = '✓ Tillämpat';
+ setTimeout(() => { status.textContent = ''; }, 3000);
+}
+
+async function applyDisplayNow() {
+ const modeSelEl = document.getElementById('setting-display-mode');
+ let displayMode = '';
+ if (modeSelEl.value === '__custom__') {
+ displayMode = document.getElementById('setting-display-mode-custom').value.trim();
+ } else {
+ displayMode = modeSelEl.value.trim();
+ }
+ if (!displayMode) { alert('Ange en upplösning först.'); return; }
+ const res = await apiFetch('/api/settings/apply_display', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_mode: displayMode, display_output: document.getElementById('setting-display').value })
+ }).catch(() => null);
+ if (!res) { alert('Misslyckades att kontakta servern'); return; }
+ const j = await res.json();
+ if (j.ok) alert('Upplösning tillämpad (försök att ändra skärm).');
+ else alert('Kunde inte tillämpa upplösning: ' + (j.error || 'okänt fel'));
+}
+
+// --- Chat 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 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' : '';
+
+ // 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)
+ try {
+ const KEY = 'kiosk_chat_theme';
+ const btn = document.getElementById('chat-theme-btn');
+ if (btn) {
+ const getTheme = () => localStorage.getItem(KEY) || 'auto';
+ const order = ['auto','dark','light'];
+ const updateBtn = () => {
+ 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];
+ 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', {
+ 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),
+ }),
+ });
+ if (!res) return;
+ document.getElementById('chat-status').textContent = ytEnabled ? '● Aktiv' : '';
+ document.getElementById('kiosk-chat-status').textContent = kioskEnabled ? '● Aktiv overlay' : '';
+}
+
+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);
+}
+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);
+}
+
+// --- Ticker ---
+
+function tickerModeChanged() {
+ const continuous = document.getElementById('ticker-mode').value === 'continuous';
+ document.getElementById('ticker-sep-label').style.display = continuous ? '' : 'none';
+ document.getElementById('ticker-sep-wrap').style.display = continuous ? '' : 'none';
+}
+
+function tickerSourceChanged() {
+ const rss = document.getElementById('ticker-source').value === 'rss';
+ document.getElementById('ticker-text-label').style.display = rss ? 'none' : '';
+ document.getElementById('ticker-text-wrap').style.display = rss ? 'none' : '';
+ document.getElementById('ticker-rss-label').style.display = rss ? '' : 'none';
+ document.getElementById('ticker-rss-wrap').style.display = rss ? '' : 'none';
+ document.getElementById('ticker-rss-interval-label').style.display = rss ? '' : 'none';
+ document.getElementById('ticker-rss-interval').style.display = rss ? '' : 'none';
+}
+
+async function laddaTicker() {
+ const res = await apiFetch('/api/ticker');
+ if (!res) return;
+ const d = await res.json();
+
+ document.getElementById('ticker-source').value = d.source || 'text';
+ document.getElementById('ticker-text').value = d.text || '';
+ document.getElementById('ticker-rss-url').value = d.rss_url || '';
+ document.getElementById('ticker-rss-interval').value = String(d.rss_interval || 10);
+ document.getElementById('ticker-mode').value = d.mode || 'sequential';
+ document.getElementById('ticker-separator').value = d.separator || '◆';
+ document.getElementById('ticker-position').value = d.position || 'bottom';
+ document.getElementById('ticker-text-color').value = d.text_color || '#ffffff';
+ document.getElementById('ticker-font-family').value = d.font_family || 'sans-serif';
+ document.getElementById('ticker-bar-color').value = d.bar_color || '#000000';
+ tickerSourceChanged();
+ tickerModeChanged();
+
+ const pps = d.pps != null ? d.pps : 150;
+ const size = d.font_size != null ? d.font_size : 2;
+ const opacity = d.bar_opacity != null ? d.bar_opacity : 72;
+ const padding = d.bar_padding != null ? d.bar_padding : 0.45;
+
+ document.getElementById('ticker-pps').value = pps;
+ document.getElementById('ticker-pps-val').textContent = pps + ' px/s';
+ document.getElementById('ticker-font-size').value = size;
+ document.getElementById('ticker-font-size-val').textContent = parseFloat(size).toFixed(1) + 'rem';
+ document.getElementById('ticker-bar-opacity').value = opacity;
+ document.getElementById('ticker-bar-opacity-val').textContent = opacity + '%';
+ document.getElementById('ticker-bar-padding').value = padding;
+ document.getElementById('ticker-bar-padding-val').textContent = parseFloat(padding).toFixed(2) + 'rem';
+
+ document.getElementById('ticker-status').textContent = d.enabled ? '● Aktiv' : '';
+}
+
+async function _sendTicker(enabled) {
+ const res = await apiFetch('/api/ticker', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ enabled,
+ source: document.getElementById('ticker-source').value,
+ text: document.getElementById('ticker-text').value,
+ rss_url: document.getElementById('ticker-rss-url').value,
+ rss_interval: parseInt(document.getElementById('ticker-rss-interval').value, 10),
+ mode: document.getElementById('ticker-mode').value,
+ separator: document.getElementById('ticker-separator').value,
+ position: document.getElementById('ticker-position').value,
+ pps: parseInt(document.getElementById('ticker-pps').value, 10),
+ text_color: document.getElementById('ticker-text-color').value,
+ font_size: parseFloat(document.getElementById('ticker-font-size').value),
+ font_family: document.getElementById('ticker-font-family').value,
+ bar_color: document.getElementById('ticker-bar-color').value,
+ bar_opacity: parseInt(document.getElementById('ticker-bar-opacity').value, 10),
+ bar_padding: parseFloat(document.getElementById('ticker-bar-padding').value),
+ }),
+ });
+ if (!res) return;
+ document.getElementById('ticker-status').textContent = enabled ? '● Aktiv' : '';
+}
+
+function aktiveraTicker() { _sendTicker(true); }
+function avaktriveraTicker() { _sendTicker(false); }
+
+// BBCode insert helpers
+function insertBB(tag, openVal) {
+ const input = document.getElementById('ticker-text');
+ const start = input.selectionStart;
+ const end = input.selectionEnd;
+ const sel = input.value.slice(start, end);
+ const open = openVal ? `[${tag}=${openVal}]` : `[${tag}]`;
+ const close = `[/${tag}]`;
+ input.value = input.value.slice(0, start) + open + sel + close + input.value.slice(end);
+ input.focus();
+ const cur = start + open.length;
+ input.setSelectionRange(cur, cur + sel.length);
+}
+
+function insertBBColor() {
+ const col = document.getElementById('bb-color-val').value;
+ insertBB('color', col);
+}
+
+function insertBBSize() {
+ const px = parseInt(document.getElementById('bb-size-val').value, 10) || 48;
+ insertBB('size', px);
+}
+
+function insertBBGlow() {
+ const col = document.getElementById('bb-color-val').value;
+ insertBB('glow', col);
+}
+
+// --- Wi-Fi ---
+
+let _wifiConnectSsid = '';
+let _wifiConnectOpen = false;
+
+function _signalColor(pct) {
+ if (pct >= 70) return '#22c55e';
+ if (pct >= 40) return '#f59e0b';
+ return '#ef4444';
+}
+
+function _signalBarsHtml(pct) {
+ const c = _signalColor(pct);
+ const lit = pct >= 70 ? 4 : pct >= 50 ? 3 : pct >= 30 ? 2 : 1;
+ const bars = [4, 7, 11, 14].map((h, i) =>
+ `<span style="width:4px;border-radius:1px;height:${h}px;background:${i < lit ? c : '#333'}"></span>`
+ ).join('');
+ return `<div style="display:flex;align-items:flex-end;gap:2px;height:14px">${bars}</div>`;
+}
+
+async function laddaWifiStatus() {
+ try {
+ const res = await apiFetch('/api/wifi/status');
+ if (!res) return;
+ const d = await res.json();
+ const dot = document.getElementById('wifi-status-dot');
+ const txt = document.getElementById('wifi-status-text');
+ const ip = document.getElementById('wifi-ip');
+ const sig = document.getElementById('wifi-signal-bar');
+ const sigv = document.getElementById('wifi-signal-val');
+ if (d.connected) {
+ dot.style.background = '#22c55e';
+ txt.textContent = d.ssid || 'Ansluten';
+ } else {
+ dot.style.background = '#ef4444';
+ txt.textContent = 'Ej ansluten';
+ }
+ ip.textContent = d.ip || '—';
+ const pct = d.connected ? Math.max(0, Math.min(100, 2 * (d.signal + 100))) : 0;
+ sig.innerHTML = _signalBarsHtml(pct);
+ sigv.textContent = d.connected ? pct + '%' : '—';
+ if (d.sudo_ok === false) {
+ document.getElementById('wifi-scan-status').textContent =
+ '⚠ sudo saknas — se installation nedan';
+ }
+ } catch {}
+}
+
+async function laddaWifiSaved() {
+ try {
+ const res = await apiFetch('/api/wifi/saved');
+ if (!res) return;
+ const d = await res.json();
+ const list = document.getElementById('wifi-saved-list');
+ if (!d.saved || !d.saved.length) {
+ list.innerHTML = '<span style="color:#555;font-style:italic">Inga sparade nätverk</span>';
+ return;
+ }
+ list.innerHTML = d.saved.map(n => `
+ <div style="display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--surface);border-radius:6px">
+ <span style="flex:1">${escHtml(n.ssid)}${n.current ? ' <span style="color:#22c55e;font-size:.75rem">● aktiv</span>' : ''}</span>
+ <button class="secondary" style="padding:.2rem .6rem;font-size:.8rem" onclick="wifiRemove('${escHtml(n.id)}','${escHtml(n.ssid)}')">Ta bort</button>
+ </div>`).join('');
+ } catch {}
+}
+
+async function wifiScan() {
+ const btn = document.getElementById('wifi-scan-btn');
+ const status = document.getElementById('wifi-scan-status');
+ const wrap = document.getElementById('wifi-networks');
+ const list = document.getElementById('wifi-network-list');
+ btn.disabled = true;
+ status.textContent = 'Skannar...';
+ list.innerHTML = '<span style="color:#555;font-style:italic">Söker...</span>';
+ wrap.style.display = 'block';
+ try {
+ const res = await apiFetch('/api/wifi/networks');
+ if (!res) { btn.disabled = false; status.textContent = ''; return; }
+ const d = await res.json();
+ if (!d.networks || !d.networks.length) {
+ list.innerHTML = '<span style="color:#555;font-style:italic">Inga nätverk hittades</span>';
+ } else {
+ list.innerHTML = d.networks.map(n => {
+ const lock = n.security !== 'open'
+ ? `<span style="font-size:.75rem;color:#aaa">${escHtml(n.security)}</span>` : '';
+ return `
+ <div style="display:flex;align-items:center;gap:.5rem;padding:.35rem .5rem;background:var(--surface);border-radius:6px">
+ ${_signalBarsHtml(n.signal)}
+ <span style="flex:1">${escHtml(n.ssid)}${n.known ? ' <span style="color:#22c55e;font-size:.75rem">✓</span>' : ''}</span>
+ ${lock}
+ <button class="secondary" style="padding:.2rem .6rem;font-size:.8rem"
+ onclick="wifiOpenModal('${escHtml(n.ssid)}',${n.security === 'open'})">
+ Anslut
+ </button>
+ </div>`;
+ }).join('');
+ }
+ status.textContent = d.networks.length + ' nätverk';
+ } catch (e) {
+ status.textContent = 'Fel: ' + e.message;
+ list.innerHTML = '';
+ }
+ btn.disabled = false;
+}
+
+function wifiOpenModal(ssid, isOpen) {
+ _wifiConnectSsid = ssid;
+ _wifiConnectOpen = isOpen;
+ document.getElementById('wifi-pw-title').textContent = 'Anslut till ' + ssid;
+ document.getElementById('wifi-pw-input').value = '';
+ document.getElementById('wifi-pw-input').type = isOpen ? 'text' : 'password';
+ document.getElementById('wifi-pw-input').placeholder = isOpen ? 'Öppet nätverk — inget lösenord behövs' : 'Wi-Fi lösenord';
+ document.getElementById('wifi-connect-status').textContent = '';
+ document.getElementById('wifi-connect-btn').disabled = false;
+ document.getElementById('wifi-pw-modal').style.display = 'flex';
+ if (!isOpen) document.getElementById('wifi-pw-input').focus();
+}
+
+function closeWifiModal() {
+ document.getElementById('wifi-pw-modal').style.display = 'none';
+}
+function wifiModalClick(e) {
+ if (e.target === document.getElementById('wifi-pw-modal')) closeWifiModal();
+}
+
+async function wifiDoConnect() {
+ const btn = document.getElementById('wifi-connect-btn');
+ const status = document.getElementById('wifi-connect-status');
+ const pw = document.getElementById('wifi-pw-input').value;
+ btn.disabled = true;
+ status.textContent = 'Ansluter... (upp till 20 s)';
+ try {
+ const res = await apiFetch('/api/wifi/connect', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ssid: _wifiConnectSsid, password: pw }),
+ });
+ if (!res) { btn.disabled = false; return; }
+ const d = await res.json();
+ if (d.ok) {
+ status.textContent = '✓ Ansluten — IP: ' + (d.ip || '—');
+ setTimeout(() => { closeWifiModal(); laddaWifiStatus(); laddaWifiSaved(); }, 1500);
+ } else {
+ status.textContent = '✗ ' + (d.message || 'Misslyckades');
+ btn.disabled = false;
+ }
+ } catch (e) {
+ status.textContent = 'Fel: ' + e.message;
+ btn.disabled = false;
+ }
+}
+
+async function wifiDisconnect() {
+ const status = document.getElementById('wifi-scan-status');
+ status.textContent = 'Kopplar från...';
+ try {
+ await apiFetch('/api/wifi/disconnect', { method: 'POST' });
+ status.textContent = '';
+ setTimeout(laddaWifiStatus, 1000);
+ } catch {}
+}
+
+async function wifiRemove(id, ssid) {
+ if (!confirm(`Ta bort sparat nätverk "${ssid}"?`)) return;
+ try {
+ await apiFetch('/api/wifi/saved/' + encodeURIComponent(id), { method: 'DELETE' });
+ laddaWifiSaved();
+ } catch {}
+}
+
+// --- Klocka ---
+
+let _clockPos = 'top-right';
+
+function setClockPos(pos) {
+ _clockPos = pos;
+ document.querySelectorAll('.clock-pos-btn').forEach(b => b.classList.remove('active'));
+ const btn = document.getElementById('clkpos-' + pos);
+ if (btn) btn.classList.add('active');
+}
+
+async function laddaClock() {
+ const res = await apiFetch('/api/clock');
+ if (!res) return;
+ const d = await res.json();
+ document.getElementById('clock-style').value = d.style || 'digital';
+ document.getElementById('clock-show-date').checked = !!d.show_date;
+ document.getElementById('clock-size').value = d.size != null ? d.size : 2;
+ document.getElementById('clock-size-val').textContent = d.size != null ? d.size : 2;
+ document.getElementById('clock-color').value = d.color || '#ffffff';
+ document.getElementById('clock-opacity').value = d.opacity != null ? d.opacity : 90;
+ document.getElementById('clock-opacity-val').textContent = (d.opacity != null ? d.opacity : 90) + '%';
+ document.getElementById('clock-schedule-mode').value = d.schedule_mode || 'always';
+ setClockPos(d.position || 'top-right');
+ document.getElementById('clock-status').textContent = d.enabled ? '● Aktiv' : '';
+}
+
+async function _sendClock(enabled) {
+ const res = await apiFetch('/api/clock', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ enabled,
+ style: document.getElementById('clock-style').value,
+ show_date: document.getElementById('clock-show-date').checked,
+ position: _clockPos,
+ size: parseFloat(document.getElementById('clock-size').value),
+ color: document.getElementById('clock-color').value,
+ opacity: parseInt(document.getElementById('clock-opacity').value, 10),
+ schedule_mode: document.getElementById('clock-schedule-mode').value,
+ }),
+ });
+ if (!res) return;
+ document.getElementById('clock-status').textContent = enabled ? '● Aktiv' : '';
+}
+
+function aktiveraClock() { _sendClock(true); }
+function avaktiveraClock() { _sendClock(false); }
+
+// --- Sovläge ---
+
+async function laddaSovlage() {
+ const res = await apiFetch('/api/sleep');
+ if (!res) return;
+ const d = await res.json();
+ document.getElementById('sleep-enabled').checked = !!d.enabled;
+ document.getElementById('sleep-start').value = d.start || '22:00';
+ document.getElementById('sleep-end').value = d.end || '07:00';
+}
+
+async function sparaSovlage() {
+ const status = document.getElementById('sleep-status');
+ status.textContent = 'Sparar...';
+ const res = await apiFetch('/api/sleep', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ enabled: document.getElementById('sleep-enabled').checked,
+ start: document.getElementById('sleep-start').value,
+ end: document.getElementById('sleep-end').value,
+ }),
+ });
+ if (!res) return;
+ status.textContent = '✓ Sparat';
+ setTimeout(() => { status.textContent = ''; }, 3000);
+}
+
+// --- Systeminfo ---
+
+async function laddaSysinfo() {
+ const res = await apiFetch('/api/sysinfo');
+ if (!res) return;
+ const d = await res.json();
+ document.getElementById('cpu').textContent = d.cpu + '%';
+ document.getElementById('ram').textContent = d.ram + '%';
+ document.getElementById('disk').textContent = d.disk + '%';
+ document.getElementById('uptime').textContent = d.uptime;
+ document.getElementById('active-source').textContent =
+ d.active_type !== '—' ? `[${d.active_type}] ${d.active_source}` : '—';
+}
+
+// --- Filbläddrare ---
+
+let _browserCallback = null;
+
+const FILE_TYPES = {
+ video: ['mp4','webm','avi','mkv','mov'],
+ image: ['jpg','jpeg','png','gif','webp','bmp'],
+ pdf: ['pdf','ppt','pptx','odp'],
+ url: ['html','htm'],
+};
+
+function extType(filename) {
+ const ext = filename.split('.').pop().toLowerCase();
+ for (const [type, exts] of Object.entries(FILE_TYPES)) {
+ if (exts.includes(ext)) return type;
+ }
+ return 'url';
+}
+
+function extClass(filename) {
+ const t = extType(filename);
+ return t === 'url' ? 'file-html' : 'file-' + t;
+}
+
+function openBrowser(callback) {
+ _browserCallback = callback;
+ document.getElementById('browser-modal').style.display = 'flex';
+ browseTo('/home/mrfox');
+}
+
+function closeBrowser() {
+ document.getElementById('browser-modal').style.display = 'none';
+ _browserCallback = null;
+}
+
+function browserOverlayClick(e) {
+ if (e.target === document.getElementById('browser-modal')) closeBrowser();
+}
+
+async function browseTo(path) {
+ const res = await apiFetch('/api/browse?path=' + encodeURIComponent(path));
+ if (!res) return;
+ const data = await res.json();
+ document.getElementById('browser-path').textContent = data.path;
+
+ let html = '';
+ if (data.parent) {
+ html += `<div class="browser-entry dir" data-path="${escHtml(data.parent)}">.. (upp)</div>`;
+ }
+ for (const e of data.entries) {
+ if (e.type === 'dir') {
+ html += `<div class="browser-entry dir" data-path="${escHtml(e.path)}">${escHtml(e.name)}</div>`;
+ } else {
+ html += `<div class="browser-entry ${escHtml(extClass(e.name))}" data-path="${escHtml(e.path)}">${escHtml(e.name)}</div>`;
+ }
+ }
+ if (!html) html = '<div style="padding:.75rem 1rem;color:#555;font-style:italic">Inga filer hittades</div>';
+
+ const list = document.getElementById('browser-list');
+ list.innerHTML = html;
+ list.querySelectorAll('.browser-entry.dir').forEach(el =>
+ el.addEventListener('click', () => browseTo(el.dataset.path))
+ );
+ list.querySelectorAll('.browser-entry:not(.dir)').forEach(el =>
+ el.addEventListener('click', () => {
+ if (_browserCallback) _browserCallback(el.dataset.path, extType(el.dataset.path));
+ closeBrowser();
+ })
+ );
+}
+
+// --- Helpers ---
+
+function fmtDuration(mins) {
+ const totalSec = Math.round(mins * 60);
+ const m = Math.floor(totalSec / 60);
+ const s = totalSec % 60;
+ return m > 0 ? `${m} min ${s > 0 ? s + ' sek' : ''}`.trim() : `${s} sek`;
+}
+
+function escHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"');
+}
+
+// --- Init ---
+
+async function init() {
+ await laddaSysinfo();
+ await laddaJobb();
+ await laddaInstallningar();
+ await laddaVolym();
+ await laddaTicker();
+ await laddaChat();
+ await laddaClock();
+ await laddaWifiStatus();
+ await laddaWifiSaved();
+ await laddaSovlage();
+ setInterval(laddaSysinfo, 10000);
+
+ const res = await apiFetch('/api/sysinfo');
+ if (!res) return;
+ const d = await res.json();
+ if (d.active_type === 'url') {
+ document.getElementById('url-input').value = d.active_source;
+ }
+}
+
+init();
+
+// Theme toggle control for admin UI
+(function(){
+ const KEY = 'kiosk_theme';
+ function getTheme(){ return localStorage.getItem(KEY) || 'auto'; }
+ function applyTheme(pref){
+ if (pref === 'auto') {
+ delete document.documentElement.dataset.theme;
+ } else {
+ document.documentElement.dataset.theme = pref;
+ }
+ }
+ function cycleTheme(){
+ const order = ['auto','dark','light'];
+ const cur = getTheme();
+ const next = order[(order.indexOf(cur)+1) % order.length];
+ localStorage.setItem(KEY, next);
+ applyTheme(next);
+ updateBtn();
+ // notify other windows/tabs
+ window.dispatchEvent(new Event('kiosk-theme-changed'));
+ }
+ function updateBtn(){
+ const t = getTheme();
+ btn.textContent = t === 'auto' ? 'Tema: Auto' : (t === 'dark' ? 'Tema: Mörkt' : 'Tema: Ljust');
+ }
+ const container = document.querySelector('.container');
+ if (!container) return;
+ const btn = document.createElement('button');
+ btn.className = 'secondary';
+ btn.style.marginLeft = 'auto';
+ btn.style.marginBottom = '1rem';
+ btn.addEventListener('click', cycleTheme);
+ // Insert button under the H1
+ const h1 = container.querySelector('h1');
+ if (h1) h1.insertAdjacentElement('afterend', btn);
+ applyTheme(getTheme());
+ updateBtn();
+ // React to storage changes from other tabs
+ window.addEventListener('storage', (e) => {
+ if (e.key === KEY) { applyTheme(e.newValue || 'auto'); updateBtn(); }
+ });
+ // Also react to programmatic events
+ window.addEventListener('kiosk-theme-changed', () => { applyTheme(getTheme()); updateBtn(); });
+})();
diff --git a/kiosk/static/display.js b/kiosk/static/display.js
new file mode 100644
index 0000000..8569e02
--- /dev/null
+++ b/kiosk/static/display.js
@@ -0,0 +1,599 @@
+const socket = io({ reconnection: true, reconnectionDelay: 1000 });
+
+// Hide cursor — blank 1px GIF as custom cursor beats cursor:none in some Chromium builds
+(function(){
+ const BLANK = 'url("data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==") 0 0, none';
+ const s = document.createElement('style');
+ s.textContent = `*, *::before, *::after { cursor: ${BLANK} !important; }`;
+ document.head.appendChild(s);
+ // Belt-and-suspenders: reset on every mousemove in case something overrides it
+ document.addEventListener('mousemove', () => {
+ if (document.documentElement.style.cursor !== 'none')
+ document.documentElement.style.setProperty('cursor', 'none', 'important');
+ }, { passive: true });
+})();
+
+// Theme handling: read 'kiosk_theme' = 'auto'|'dark'|'light' from localStorage
+// Exposes window.kioskTheme.set/get for external toggles.
+(function(){
+ const KEY = 'kiosk_theme';
+ const mq = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
+ function apply(pref){
+ let dark;
+ if (pref === 'dark') dark = true;
+ else if (pref === 'light') dark = false;
+ else dark = !!(mq && mq.matches);
+ document.documentElement.classList.toggle('dark', dark);
+ }
+ const pref = localStorage.getItem(KEY) || 'auto';
+ apply(pref);
+ if (mq && mq.addEventListener) {
+ mq.addEventListener('change', () => {
+ // only react to system changes when in 'auto' mode
+ if ((localStorage.getItem(KEY) || 'auto') === 'auto') apply('auto');
+ });
+ }
+ window.kioskTheme = {
+ set: (p) => { localStorage.setItem(KEY, p); apply(p); window.dispatchEvent(new Event('kiosk-theme-changed')); },
+ get: () => localStorage.getItem(KEY) || 'auto'
+ };
+ // Sync theme when changed in other tabs
+ window.addEventListener('storage', (e) => {
+ if (e.key === KEY) apply(e.newValue || 'auto');
+ });
+})();
+
+function parseBBCode(raw) {
+ // HTML-escape first, then apply BBCode
+ let s = raw
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>');
+ s = s
+ .replace(/\[b\]([\s\S]*?)\[\/b\]/g, '<strong>$1</strong>')
+ .replace(/\[i\]([\s\S]*?)\[\/i\]/g, '<em>$1</em>')
+ .replace(/\[u\]([\s\S]*?)\[\/u\]/g, '<u>$1</u>')
+ .replace(/\[s\]([\s\S]*?)\[\/s\]/g, '<s>$1</s>')
+ .replace(/\[color=(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)\]([\s\S]*?)\[\/color\]/g,
+ '<span style="color:$1">$2</span>')
+ .replace(/\[size=([1-9][0-9]{0,2})\]([\s\S]*?)\[\/size\]/g,
+ '<span style="font-size:$1px">$2</span>')
+ .replace(/\[font=([a-zA-Z0-9 ,'\-]+)\]([\s\S]*?)\[\/font\]/g,
+ '<span style="font-family:$1">$2</span>')
+ .replace(/\[shadow\]([\s\S]*?)\[\/shadow\]/g,
+ '<span style="text-shadow:2px 2px 6px #000,0 0 12px #000">$1</span>')
+ .replace(/\[glow=(#[0-9a-fA-F]{3,6}|[a-zA-Z]+)\]([\s\S]*?)\[\/glow\]/g,
+ '<span style="text-shadow:0 0 8px $1,0 0 18px $1,0 0 30px $1">$2</span>');
+ return s;
+}
+
+function hexToRgba(hex, opacity) {
+ const r = parseInt(hex.slice(1, 3), 16);
+ const g = parseInt(hex.slice(3, 5), 16);
+ const b = parseInt(hex.slice(5, 7), 16);
+ return `rgba(${r},${g},${b},${opacity / 100})`;
+}
+
+let _ticker = { lines: [], idx: 0, data: null, rssTimer: null, rafId: null };
+
+// Duration in seconds for constant pixel-speed
+function _calcDur(spanEl, pps) {
+ return (window.innerWidth + spanEl.offsetWidth) / (pps || 150);
+}
+
+// Escape for use as literal separator (not BBCode)
+function _escSep(s) {
+ return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
+}
+
+async function _loadRssLines() {
+ try {
+ const r = await fetch('/api/ticker/rss');
+ if (!r.ok) return;
+ const d = await r.json();
+ if (d.items && d.items.length) {
+ const changed = JSON.stringify(d.items) !== JSON.stringify(_ticker.lines);
+ _ticker.lines = d.items;
+ // Restart continuous ticker when content changes
+ if (changed && _ticker.data && _ticker.data.mode === 'continuous') {
+ _ticker.idx = 0;
+ _tickerShowLine();
+ }
+ }
+ } catch {}
+}
+
+async function applyTicker(data) {
+ const el = document.getElementById('ticker');
+ const span = document.getElementById('ticker-text');
+
+ span.removeEventListener('animationend', _tickerNextLine);
+ if (_ticker.rssTimer) { clearInterval(_ticker.rssTimer); _ticker.rssTimer = null; }
+ if (_ticker.rafId) { cancelAnimationFrame(_ticker.rafId); _ticker.rafId = null; }
+
+ const hasContent = data.source === 'rss' || (data.text && data.text.trim());
+ if (!data.enabled || !hasContent) {
+ el.style.display = 'none';
+ return;
+ }
+
+ _ticker.idx = 0;
+ _ticker.data = data;
+
+ if (data.source === 'rss') {
+ _ticker.lines = []; // rensa innan hämtning så gamla rader inte läcker
+ await _loadRssLines();
+ if (!_ticker.lines.length) { el.style.display = 'none'; return; }
+ const ms = (data.rss_interval || 10) * 60 * 1000;
+ _ticker.rssTimer = setInterval(_loadRssLines, ms);
+ } else {
+ _ticker.lines = data.text.split(/\[br\]|\n/).map(l => l.trim()).filter(l => l);
+ if (!_ticker.lines.length) { el.style.display = 'none'; return; }
+ }
+
+ // Bar style
+ el.className = data.position === 'top' ? 'top' : 'bottom';
+ el.style.background = hexToRgba(data.bar_color || '#000000', (data.bar_opacity != null ? data.bar_opacity : 72));
+ el.style.padding = `${(data.bar_padding != null ? data.bar_padding : 0.45)}rem 0`;
+ el.style.display = 'block';
+
+ // Text style (global defaults, overridden per-span by BBCode)
+ span.style.color = data.text_color || '#ffffff';
+ span.style.fontSize = (data.font_size || 2) + 'rem';
+ span.style.fontFamily = data.font_family || 'sans-serif';
+
+ _tickerShowLine();
+ // Re-position overlays so they don't overlap the ticker bar
+ requestAnimationFrame(() => {
+ _updateChatOverlay();
+ _updateKioskChatOverlay();
+ const clockEl = document.getElementById('clock-overlay');
+ if (clockEl && clockEl.style.display !== 'none') _positionClock(clockEl);
+ });
+}
+
+function _tickerShowLine() {
+ const span = document.getElementById('ticker-text');
+ const { lines, idx, data } = _ticker;
+ const pps = data.pps || 150;
+ const continuous = data.mode === 'continuous';
+
+ const sep = `<span style="opacity:.5;padding:0 .5em">${_escSep(data.separator || '◆')}</span>`;
+
+ if (continuous) {
+ // Build one copy of the band, measure it, then duplicate for seamless looping
+ const band = lines.map(l => parseBBCode(l)).join(sep);
+ span.style.animation = 'none';
+ span.style.transform = '';
+ span.innerHTML = band + sep;
+ span.offsetHeight; // reflow to measure one copy
+ const copyW = span.offsetWidth;
+ // Duplicate so the loop point is seamless
+ span.innerHTML = band + sep + band + sep;
+
+ if (_ticker.rafId) { cancelAnimationFrame(_ticker.rafId); _ticker.rafId = null; }
+ let x = window.innerWidth; // start off-screen right
+ let last = null;
+ function tickContinuous(ts) {
+ if (!last) last = ts;
+ x -= pps * (ts - last) / 1000;
+ // When first copy has fully exited left, reset by one copy width (seamless)
+ if (x <= -copyW) x += copyW;
+ span.style.transform = `translateX(${x}px)`;
+ last = ts;
+ _ticker.rafId = requestAnimationFrame(tickContinuous);
+ }
+ _ticker.rafId = requestAnimationFrame(tickContinuous);
+ } else {
+ span.style.animation = 'none';
+ span.style.transform = '';
+ span.innerHTML = parseBBCode(lines[idx]);
+ span.offsetHeight;
+
+ if (lines.length > 1) {
+ const dur = _calcDur(span, pps).toFixed(2);
+ span.style.animation = `ticker-scroll ${dur}s linear 1`;
+ span.addEventListener('animationend', _tickerNextLine, { once: true });
+ } else {
+ const dur = _calcDur(span, pps).toFixed(2);
+ span.style.animation = `ticker-scroll ${dur}s linear infinite`;
+ }
+ }
+}
+
+function _tickerNextLine() {
+ _ticker.idx = (_ticker.idx + 1) % _ticker.lines.length;
+ _tickerShowLine();
+}
+
+// --- Chat overlays (YouTube + Kiosk livechat) ---
+
+// 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 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();
+}
+
+// YouTube chat overlay
+function _updateChatOverlay() {
+ const overlay = document.getElementById('chat-overlay');
+ const frame = document.getElementById('chat-frame');
+
+ if (!_chat.enabled || !_chat.jobEnabled || !_currentYtId) {
+ overlay.style.display = 'none';
+ return;
+ }
+
+ const src = `https://www.youtube.com/live_chat?is_popout=1&v=${_currentYtId}&embed_domain=${window.location.hostname}`;
+ try {
+ const CHAT_KEY = 'kiosk_chat_theme';
+ const chatPref = localStorage.getItem(CHAT_KEY) || 'auto';
+ let wantDark;
+ if (chatPref === 'dark') wantDark = true;
+ else if (chatPref === 'light') wantDark = false;
+ else {
+ const mq = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
+ wantDark = !!(mq && mq.matches);
+ }
+ const finalSrc = src + (wantDark ? '&dark_theme=1&theme=dark' : '');
+ if (frame.src !== finalSrc) frame.src = finalSrc;
+ } catch (e) {
+ 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);
+ overlay.style.display = 'block';
+}
+
+// Kiosk livechat overlay — inbyggd, ansluter direkt till /chat-namespacet
+const _kcSocket = io('/chat');
+const _kcMsgs = [];
+const KC_MAX = 30;
+
+_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));
+});
+_kcSocket.on('chat_message', (d) => {
+ _kcAddMsg(d.name, d.text, d.color, d.emoji, d.avatar_url);
+});
+_kcSocket.on('system_message', (d) => {
+ _kcAddSystem(d.text);
+});
+_kcSocket.on('viewer_count', (d) => {
+ const el = document.getElementById('kc-count');
+ if (el) el.textContent = d.count + ' aktiva';
+});
+
+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));
+}
+
+function _kcAddMsg(name, text, color, emoji, avatarUrl) {
+ const el = document.createElement('div');
+ el.className = 'kc-msg';
+ el.innerHTML = `
+ <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>`;
+ _kcAppend(el);
+}
+
+function _kcAddSystem(text) {
+ const el = document.createElement('div');
+ el.className = 'kc-system';
+ el.textContent = text;
+ _kcAppend(el);
+}
+
+function _kcAppend(el) {
+ const container = document.getElementById('kc-msgs');
+ if (!container) return;
+ container.appendChild(el);
+ _kcMsgs.push(el);
+ if (_kcMsgs.length > KC_MAX) {
+ const old = _kcMsgs.shift();
+ old.remove();
+ }
+}
+
+function _kcInitial(name) { return name.charAt(0).toUpperCase(); }
+function _kcEsc(s) { return String(s).replace(/&/g,'&').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
+ && _currentType !== 'kiosk_chat'
+ && !ytChatActive;
+
+ if (!showAsOverlay) {
+ overlay.style.display = 'none';
+ return;
+ }
+
+ _positionOverlay(overlay, _chat.kiosk_position, _chat.kiosk_width_pct, _chat.kiosk_height);
+ overlay.style.display = 'flex';
+}
+
+function _positionOverlay(el, position, widthPct, heightPct) {
+ const isRight = position !== 'left';
+ el.style.right = isRight ? '0' : 'auto';
+ el.style.left = isRight ? 'auto' : '0';
+ el.style.width = widthPct + '%';
+
+ // Offset for ticker bar if it is visible
+ const ticker = document.getElementById('ticker');
+ const tickerH = (ticker && ticker.offsetHeight > 0) ? ticker.offsetHeight : 0;
+ const topOff = (ticker && ticker.classList.contains('top')) ? tickerH : 0;
+ const bottomOff = (ticker && ticker.classList.contains('bottom')) ? tickerH : 0;
+
+ const availH = window.innerHeight - topOff - bottomOff;
+ const wantH = Math.round(availH * heightPct / 100);
+ const topPx = Math.round(topOff + (availH - wantH) / 2);
+
+ el.style.height = wantH + 'px';
+ el.style.top = topPx + 'px';
+ el.style.opacity = '1';
+}
+
+socket.on('chat_settings', (data) => {
+ applyChat(data);
+});
+
+// --- Clock overlay ---
+
+let _clock = { enabled: false, style: 'digital', show_date: false, position: 'top-right',
+ size: 2, color: '#ffffff', opacity: 90, schedule_mode: 'always',
+ _timer: null, _raf: null };
+let _isScheduled = false;
+
+function applyClockSettings(data) {
+ _clock.enabled = !!data.enabled;
+ _clock.style = data.style || 'digital';
+ _clock.show_date = !!data.show_date;
+ _clock.position = data.position || 'top-right';
+ _clock.size = data.size != null ? data.size : 2;
+ _clock.color = data.color || '#ffffff';
+ _clock.opacity = data.opacity != null ? data.opacity : 90;
+ _clock.schedule_mode = data.schedule_mode || 'always';
+ _refreshClock();
+}
+
+function _refreshClock() {
+ if (_clock._timer) { clearInterval(_clock._timer); _clock._timer = null; }
+ if (_clock._raf) { cancelAnimationFrame(_clock._raf); _clock._raf = null; }
+
+ const el = document.getElementById('clock-overlay');
+ const digital = document.getElementById('clock-digital');
+ const canvas = document.getElementById('clock-analog');
+ const dateEl = document.getElementById('clock-date');
+
+ const shouldShow = _clock.enabled &&
+ (_clock.schedule_mode !== 'scheduled_only' || _isScheduled);
+
+ if (!shouldShow) { el.style.display = 'none'; return; }
+
+ const sz = _clock.size;
+ const em = (sz * 2 + 2); // 4rem–12rem
+ const px = Math.round(sz * 60 + 60); // 120px–360px
+
+ digital.style.display = (_clock.style === 'digital') ? 'block' : 'none';
+ canvas.style.display = (_clock.style === 'analog') ? 'block' : 'none';
+ digital.style.fontSize = em + 'rem';
+ digital.style.fontFamily = 'monospace';
+ digital.style.color = _clock.color;
+ digital.style.textShadow = '0 2px 12px rgba(0,0,0,0.85)';
+ digital.style.lineHeight = '1';
+
+ canvas.width = px;
+ canvas.height = px;
+
+ const dateSz = Math.max(1.2, em * 0.28);
+ dateEl.style.display = _clock.show_date ? 'block' : 'none';
+ dateEl.style.fontSize = dateSz + 'rem';
+ dateEl.style.color = _clock.color;
+ dateEl.style.fontFamily = 'monospace';
+ dateEl.style.textShadow = '0 2px 8px rgba(0,0,0,0.85)';
+ dateEl.style.marginTop = '0.4rem';
+ dateEl.style.textAlign = 'center';
+
+ el.style.opacity = (_clock.opacity / 100).toString();
+ el.style.display = 'flex';
+ _positionClock(el);
+ _renderClock();
+
+ if (_clock.style === 'analog') {
+ (function loop() { _renderClock(); _clock._raf = requestAnimationFrame(loop); })();
+ } else {
+ _clock._timer = setInterval(_renderClock, 1000);
+ }
+}
+
+function _renderClock() {
+ const now = new Date();
+ if (_clock.style === 'digital') {
+ const el = document.getElementById('clock-digital');
+ if (el) el.textContent =
+ String(now.getHours()).padStart(2,'0') + ':' +
+ String(now.getMinutes()).padStart(2,'0') + ':' +
+ String(now.getSeconds()).padStart(2,'0');
+ } else {
+ _drawAnalogClock(now);
+ }
+ if (_clock.show_date) {
+ const days = ['Sön','Mån','Tis','Ons','Tor','Fre','Lör'];
+ const months = ['jan','feb','mar','apr','maj','jun','jul','aug','sep','okt','nov','dec'];
+ const el = document.getElementById('clock-date');
+ if (el) el.textContent = days[now.getDay()] + ' ' + now.getDate() + ' ' + months[now.getMonth()];
+ }
+}
+
+function _drawAnalogClock(now) {
+ const canvas = document.getElementById('clock-analog');
+ if (!canvas) return;
+ const ctx = canvas.getContext('2d');
+ const W = canvas.width, H = canvas.height;
+ const cx = W / 2, cy = H / 2;
+ const R = W / 2 - 2;
+ const c = _clock.color;
+
+ ctx.clearRect(0, 0, W, H);
+
+ // Face
+ ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2);
+ ctx.fillStyle = 'rgba(0,0,0,0.45)'; ctx.fill();
+ ctx.strokeStyle = c; ctx.lineWidth = Math.max(2, R * 0.03); ctx.stroke();
+
+ // Ticks
+ for (let i = 0; i < 12; i++) {
+ const a = (i / 12) * Math.PI * 2 - Math.PI / 2;
+ const outer = R * 0.92;
+ const inner = (i % 3 === 0) ? R * 0.76 : R * 0.84;
+ ctx.beginPath();
+ ctx.moveTo(cx + Math.cos(a) * outer, cy + Math.sin(a) * outer);
+ ctx.lineTo(cx + Math.cos(a) * inner, cy + Math.sin(a) * inner);
+ ctx.strokeStyle = c;
+ ctx.lineWidth = (i % 3 === 0) ? Math.max(2, R * 0.04) : Math.max(1, R * 0.02);
+ ctx.stroke();
+ }
+
+ const h = now.getHours() % 12;
+ const m = now.getMinutes();
+ const s = now.getSeconds();
+ const ms = now.getMilliseconds();
+ const hA = ((h + m / 60) / 12) * Math.PI * 2 - Math.PI / 2;
+ const mA = ((m + s / 60) / 60) * Math.PI * 2 - Math.PI / 2;
+ const sA = ((s + ms / 1000) / 60) * Math.PI * 2 - Math.PI / 2;
+
+ _hand(ctx, cx, cy, hA, R * 0.55, Math.max(3, R * 0.055), c);
+ _hand(ctx, cx, cy, mA, R * 0.78, Math.max(2, R * 0.035), c);
+ _hand(ctx, cx, cy, sA, R * 0.88, Math.max(1, R * 0.018), '#ef4444');
+ _hand(ctx, cx, cy, sA + Math.PI, R * 0.18, Math.max(1, R * 0.018), '#ef4444');
+
+ ctx.beginPath(); ctx.arc(cx, cy, Math.max(3, R * 0.045), 0, Math.PI * 2);
+ ctx.fillStyle = c; ctx.fill();
+ ctx.beginPath(); ctx.arc(cx, cy, Math.max(2, R * 0.025), 0, Math.PI * 2);
+ ctx.fillStyle = '#ef4444'; ctx.fill();
+}
+
+function _hand(ctx, cx, cy, angle, len, width, color) {
+ ctx.beginPath();
+ ctx.moveTo(cx, cy);
+ ctx.lineTo(cx + Math.cos(angle) * len, cy + Math.sin(angle) * len);
+ ctx.strokeStyle = color; ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.stroke();
+}
+
+function _positionClock(el) {
+ const pos = _clock.position;
+ const pad = 18;
+ 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;
+
+ el.style.top = el.style.bottom = el.style.left = el.style.right = el.style.transform = '';
+ if (pos === 'top-left') { el.style.top = (pad + topOff) + 'px'; el.style.left = pad + 'px'; }
+ else if (pos === 'top-right') { el.style.top = (pad + topOff) + 'px'; el.style.right = pad + 'px'; }
+ else if (pos === 'bottom-left') { el.style.bottom = (pad + botOff) + 'px'; el.style.left = pad + 'px'; }
+ else if (pos === 'bottom-right') { el.style.bottom = (pad + botOff) + 'px'; el.style.right = pad + 'px'; }
+ else { el.style.top = '50%'; el.style.left = '50%'; el.style.transform = 'translate(-50%,-50%)'; }
+}
+
+socket.on('clock_settings', applyClockSettings);
+
+socket.on('connect', () => {
+ fetch('/api/ticker').then(r => r.json()).then(applyTicker).catch(() => {});
+ fetch('/api/chat').then(r => r.json()).then(applyChat).catch(() => {});
+ fetch('/api/clock').then(r => r.json()).then(applyClockSettings).catch(() => {});
+});
+
+function showLayer(name) {
+ ['iframe', 'video', 'local', 'image', 'idle'].forEach(l => {
+ document.getElementById('layer-' + l).style.display = 'none';
+ });
+ document.getElementById('layer-' + name).style.display = 'block';
+}
+
+socket.on('switch', (data) => {
+ // Per-job chat override: false = force-hide, anything else = follow global setting
+ _chat.jobEnabled = (data.show_chat !== false);
+
+ // Track scheduled flag for clock schedule_mode
+ const prevScheduled = _isScheduled;
+ _isScheduled = (data.scheduled === true);
+ if (_isScheduled !== prevScheduled && _clock.schedule_mode === 'scheduled_only') {
+ _refreshClock();
+ }
+
+ // 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;
+
+ if (data.type === 'kiosk_chat') {
+ document.getElementById('frame').src = window.location.origin + '/chat-display';
+ showLayer('iframe');
+
+ } else if (data.type === 'url') {
+ document.getElementById('frame').src = data.source;
+ showLayer('iframe');
+
+ } else if (data.type === 'video') {
+ const v = document.getElementById('video');
+ v.src = data.source;
+ showLayer('video');
+ v.load();
+ v.play().catch(() => { v.muted = true; v.play(); });
+
+ } else if (data.type === 'image') {
+ document.getElementById('image').src = data.source;
+ showLayer('image');
+
+ } else if (data.type === 'pdf' || data.type === 'game') {
+ document.getElementById('frame').src = data.source;
+ showLayer('iframe');
+
+ } else if (data.type === 'local') {
+ const html = data.html || '';
+ if (!html && !data.source) {
+ showLayer('idle');
+ } else {
+ document.getElementById('layer-local').innerHTML = html;
+ showLayer('local');
+ }
+ }
+
+ _updateChatOverlay();
+ _updateKioskChatOverlay();
+});
+
+socket.on('ticker', applyTicker);
+
+socket.on('sleep_toggle', (data) => {
+ document.getElementById('layer-sleep').style.display = data.active ? 'block' : 'none';
+});
diff --git a/kiosk/static/qrcode.min.js b/kiosk/static/qrcode.min.js
new file mode 100644
index 0000000..993e88f
--- /dev/null
+++ b/kiosk/static/qrcode.min.js
@@ -0,0 +1 @@
+var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c<a.length&&0==a[c];)c++;this.num=new Array(a.length-c+b);for(var d=0;d<a.length-c;d++)this.num[d]=a[d+c]}function j(a,b){this.totalCount=a,this.dataCount=b}function k(){this.buffer=[],this.length=0}function m(){return"undefined"!=typeof CanvasRenderingContext2D}function n(){var a=!1,b=navigator.userAgent;return/android/i.test(b)&&(a=!0,aMat=b.toString().match(/android ([0-9]\.[0-9])/i),aMat&&aMat[1]&&(a=parseFloat(aMat[1]))),a}function r(a,b){for(var c=1,e=s(a),f=0,g=l.length;g>=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d<this.moduleCount;d++){this.modules[d]=new Array(this.moduleCount);for(var e=0;e<this.moduleCount;e++)this.modules[d][e]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(a,c),this.typeNumber>=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f<this.modules.length;f++)for(var g=f*e,h=0;h<this.modules[f].length;h++){var i=h*e,j=this.modules[f][h];j&&(d.beginFill(0,100),d.moveTo(i,g),d.lineTo(i+e,g),d.lineTo(i+e,g+e),d.lineTo(i,g+e),d.endFill())}return d},setupTimingPattern:function(){for(var a=8;a<this.moduleCount-8;a++)null==this.modules[a][6]&&(this.modules[a][6]=0==a%2);for(var b=8;b<this.moduleCount-8;b++)null==this.modules[6][b]&&(this.modules[6][b]=0==b%2)},setupPositionAdjustPattern:function(){for(var a=f.getPatternPosition(this.typeNumber),b=0;b<a.length;b++)for(var c=0;c<a.length;c++){var d=a[b],e=a[c];if(null==this.modules[d][e])for(var g=-2;2>=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g<a.length&&(j=1==(1&a[g]>>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h<d.length;h++){var i=d[h];g.put(i.mode,4),g.put(i.getLength(),f.getLengthInBits(i.mode,a)),i.write(g)}for(var l=0,h=0;h<e.length;h++)l+=e[h].dataCount;if(g.getLengthInBits()>8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j<b.length;j++){var k=b[j].dataCount,l=b[j].totalCount-k;d=Math.max(d,k),e=Math.max(e,l),g[j]=new Array(k);for(var m=0;m<g[j].length;m++)g[j][m]=255&a.buffer[m+c];c+=k;var n=f.getErrorCorrectPolynomial(l),o=new i(g[j],n.getLength()-1),p=o.mod(n);h[j]=new Array(n.getLength()-1);for(var m=0;m<h[j].length;m++){var q=m+p.getLength()-h[j].length;h[j][m]=q>=0?p.get(q):0}}for(var r=0,m=0;m<b.length;m++)r+=b[m].totalCount;for(var s=new Array(r),t=0,m=0;d>m;m++)for(var j=0;j<b.length;j++)m<g[j].length&&(s[t++]=g[j][m]);for(var m=0;e>m;m++)for(var j=0;j<b.length;j++)m<h[j].length&&(s[t++]=h[j][m]);return s};for(var c={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},d={L:1,M:0,Q:3,H:2},e={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},f={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1335,G18:7973,G15_MASK:21522,getBCHTypeInfo:function(a){for(var b=a<<10;f.getBCHDigit(b)-f.getBCHDigit(f.G15)>=0;)b^=f.G15<<f.getBCHDigit(b)-f.getBCHDigit(f.G15);return(a<<10|b)^f.G15_MASK},getBCHTypeNumber:function(a){for(var b=a<<12;f.getBCHDigit(b)-f.getBCHDigit(f.G18)>=0;)b^=f.G18<<f.getBCHDigit(b)-f.getBCHDigit(f.G18);return a<<12|b},getBCHDigit:function(a){for(var b=0;0!=a;)b++,a>>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<<h;for(var h=8;256>h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;c<this.getLength();c++)for(var d=0;d<a.getLength();d++)b[c+d]^=g.gexp(g.glog(this.get(c))+g.glog(a.get(d)));return new i(b,0)},mod:function(a){if(this.getLength()-a.getLength()<0)return this;for(var b=g.glog(this.get(0))-g.glog(a.get(0)),c=new Array(this.getLength()),d=0;d<this.getLength();d++)c[d]=this.get(d);for(var d=0;d<a.getLength();d++)c[d]^=g.gexp(g.glog(a.get(d))+b);return new i(c,0).mod(a)}},j.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],j.getRSBlocks=function(a,b){var c=j.getRsBlockTable(a,b);if(void 0==c)throw new Error("bad rs block @ typeNumber:"+a+"/errorCorrectLevel:"+b);for(var d=c.length/3,e=[],f=0;d>f;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=['<table style="border:0;border-collapse:collapse;">'],h=0;d>h;h++){g.push("<tr>");for(var i=0;d>i;i++)g.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+e+"px;height:"+f+"px;background-color:"+(a.isDark(h,i)?b.colorDark:b.colorLight)+';"></td>');g.push("</tr>")}g.push("</table>"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}();
\ No newline at end of file
diff --git a/kiosk/static/style.css b/kiosk/static/style.css
new file mode 100644
index 0000000..5b3c0b0
--- /dev/null
+++ b/kiosk/static/style.css
@@ -0,0 +1,359 @@
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+/* Theme variables — supports prefers-color-scheme */
+:root {
+ --bg: #ffffff;
+ --text: #111111;
+ --muted: #777777;
+ --surface: #f6f6f6;
+ --border: #dddddd;
+ --accent: #2563eb;
+ --accent-hover: #1d4ed8;
+ --panel-bg: #ffffff;
+ --panel-border: #e6e6e6;
+ --control-bg: #ffffff;
+ --control-border: #cccccc;
+ --control-text: var(--text);
+ --progress-fill: #2563eb;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root:not([data-theme]) {
+ --bg: #111111;
+ --text: #e0e0e0;
+ --muted: #aaaaaa;
+ --surface: #1a1a1a;
+ --border: #2a2a2a;
+ --accent: #2563eb;
+ --accent-hover: #1d4ed8;
+ --panel-bg: #1a1a1a;
+ --panel-border: #2a2a2a;
+ --control-bg: #222222;
+ --control-border: #333333;
+ --control-text: var(--text);
+ --progress-fill: #2563eb;
+ }
+}
+
+/* Manuell temaöverstyring */
+:root[data-theme="dark"] {
+ --bg: #111111;
+ --text: #e0e0e0;
+ --muted: #aaaaaa;
+ --surface: #1a1a1a;
+ --border: #2a2a2a;
+ --accent: #2563eb;
+ --accent-hover: #1d4ed8;
+ --panel-bg: #1a1a1a;
+ --panel-border: #2a2a2a;
+ --control-bg: #222222;
+ --control-border: #333333;
+ --control-text: var(--text);
+ --progress-fill: #2563eb;
+}
+
+:root[data-theme="light"] {
+ --bg: #ffffff;
+ --text: #111111;
+ --muted: #777777;
+ --surface: #f6f6f6;
+ --border: #dddddd;
+ --accent: #2563eb;
+ --accent-hover: #1d4ed8;
+ --panel-bg: #ffffff;
+ --panel-border: #e6e6e6;
+ --control-bg: #ffffff;
+ --control-border: #cccccc;
+ --control-text: var(--text);
+ --progress-fill: #2563eb;
+}
+
+body {
+ font-family: system-ui, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.5;
+}
+
+.container {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 2rem 1rem;
+}
+
+h1 {
+ font-size: 1.6rem;
+ margin-bottom: 2rem;
+ color: var(--text);
+}
+
+h2 {
+ font-size: 1.1rem;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ margin-bottom: 1rem;
+}
+
+h3 {
+ font-size: .95rem;
+ color: var(--muted);
+ margin-bottom: .75rem;
+}
+
+section {
+ background: var(--panel-bg);
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+ padding: 1.25rem 1.5rem;
+ margin-bottom: 1.5rem;
+}
+
+/* Inputs & buttons */
+input, select {
+ background: var(--control-bg);
+ border: 1px solid var(--control-border);
+ color: var(--control-text);
+ padding: .45rem .7rem;
+ border-radius: 5px;
+ font-size: .9rem;
+}
+
+input:focus, select:focus {
+ outline: 2px solid #4a9eff;
+ border-color: transparent;
+}
+
+button {
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ padding: .45rem .9rem;
+ border-radius: 5px;
+ cursor: pointer;
+ font-size: .9rem;
+ white-space: nowrap;
+}
+
+button:hover { background: var(--accent-hover); }
+button.secondary { background: var(--control-bg); color: var(--control-text); border: 1px solid var(--control-border); }
+button.secondary:hover { filter: brightness(.95); }
+button.danger { background: #991b1b; }
+button.danger:hover { background: #7f1d1d; }
+button.toggle.on { background: #166534; }
+button.toggle.on:hover { background: #14532d; }
+button.toggle.off { background: var(--control-bg); color: var(--control-text); border: 1px solid var(--control-border); }
+button.toggle.off:hover { filter: brightness(.95); }
+
+/* Rows */
+.row {
+ display: flex;
+ gap: .5rem;
+ margin-bottom: .6rem;
+ align-items: center;
+}
+
+.row input { flex: 1; }
+
+/* Job form */
+.form-grid {
+ display: flex;
+ gap: .5rem;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.form-grid input, .form-grid select { flex: 1; min-width: 120px; }
+
+/* Table */
+table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 1.25rem;
+ font-size: .875rem;
+}
+
+th {
+ text-align: left;
+ color: #777;
+ font-weight: 500;
+ padding: .4rem .5rem;
+ border-bottom: 1px solid #2a2a2a;
+}
+
+td {
+ padding: .5rem .5rem;
+ border-bottom: 1px solid #222;
+ vertical-align: middle;
+}
+
+.source-cell {
+ max-width: 240px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.empty { color: #555; font-style: italic; }
+
+/* Sysinfo */
+.sysinfo-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: .75rem;
+}
+
+.stat {
+ background: var(--control-bg);
+ border-radius: 6px;
+ padding: .6rem .9rem;
+ display: flex;
+ flex-direction: column;
+ min-width: 100px;
+}
+
+.stat.wide { flex: 1; min-width: 260px; }
+.stat .label { font-size: .7rem; color: #666; text-transform: uppercase; letter-spacing: .06em; }
+.stat span:last-child { font-size: 1.1rem; font-weight: 600; color: #fff; margin-top: .15rem; }
+
+/* File browser modal */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0,0,0,.7);
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.modal {
+ background: var(--panel-bg);
+ border: 1px solid var(--panel-border);
+ border-radius: 8px;
+ width: min(560px, 95vw);
+ max-height: 70vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+ padding: .75rem 1rem;
+ border-bottom: 1px solid var(--panel-border);
+ flex-shrink: 0;
+}
+
+.modal-header .modal-path {
+ flex: 1;
+ font-size: .8rem;
+ color: #888;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.modal-shortcuts {
+ display: flex;
+ gap: .4rem;
+ padding: .5rem 1rem;
+ border-bottom: 1px solid #222;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+}
+
+.browser-list {
+ overflow-y: auto;
+ flex: 1;
+ padding: .25rem 0;
+}
+
+.browser-entry {
+ padding: .45rem 1rem;
+ cursor: pointer;
+ font-size: .875rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.browser-entry:hover { background: var(--control-bg); }
+.browser-entry.dir::before { content: '📁 '; }
+.browser-entry.file-video::before { content: '🎬 '; }
+.browser-entry.file-image::before { content: '🖼️ '; }
+.browser-entry.file-html::before { content: '📄 '; }
+
+.source-with-browse { display: flex; gap: .4rem; flex: 1; min-width: 180px; }
+.source-with-browse input { flex: 1; min-width: 0; }
+
+/* BBCode toolbar */
+.bbcode-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: .3rem;
+ margin-top: .4rem;
+ align-items: center;
+}
+.bbcode-bar .bb-btn { padding: .3rem .5rem; font-size: .8rem; }
+.bb-sep { width: 1px; height: 1.5rem; background: #333; margin: 0 .1rem; flex-shrink: 0; }
+.bbcode-bar input[type=number] { width: 4rem; flex: none; min-width: 0; }
+
+/* 2-column label/control grid */
+.prop-grid {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: .5rem .85rem;
+ align-items: center;
+ margin-bottom: .6rem;
+}
+.prop-grid label { color: #aaa; font-size: .9rem; white-space: nowrap; }
+.prop-grid .row { margin-bottom: 0; }
+input[type=color] {
+ width: 2.5rem; height: 2rem;
+ padding: .1rem .15rem;
+ cursor: pointer;
+ background: var(--control-bg);
+ border: 1px solid var(--control-border);
+ border-radius: 5px;
+ flex: none;
+}
+
+/* Upload */
+.upload-label {
+ display: inline-flex;
+ align-items: center;
+ background: var(--accent);
+ color: #fff;
+ padding: .45rem .9rem;
+ border-radius: 5px;
+ cursor: pointer;
+ font-size: .9rem;
+ white-space: nowrap;
+}
+.upload-label:hover { background: var(--accent-hover); }
+.upload-label input[type=file] { display: none; }
+
+#upload-progress {
+ display: flex;
+ align-items: center;
+ gap: .75rem;
+ margin-bottom: .6rem;
+}
+.progress-bar {
+ flex: 1;
+ background: var(--control-bg);
+ border-radius: 4px;
+ height: 6px;
+ overflow: hidden;
+}
+#progress-fill {
+ height: 100%;
+ background: var(--progress-fill);
+ border-radius: 4px;
+ width: 0%;
+ transition: width .15s;
+}
+#upload-status { font-size: .8rem; color: #888; white-space: nowrap; }
+.clock-pos-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
diff --git a/kiosk/sway-apps.sh b/kiosk/sway-apps.sh
new file mode 100755
index 0000000..9442c2e
--- /dev/null
+++ b/kiosk/sway-apps.sh
@@ -0,0 +1,65 @@
+#!/usr/bin/env bash
+# sway-apps.sh — startar chromium på rätt workspace när sway är redo.
+# Körs via "exec" i sway.conf.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CONF="$SCRIPT_DIR/kiosk.conf"
+
+# Vänta tills WiFi-adapter har en IP-adress (max 40 s)
+LOCAL_IP=""
+for _i in $(seq 1 40); do
+ LOCAL_IP=$(ip route get 8.8.8.8 2>/dev/null \
+ | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}')
+ [ -n "$LOCAL_IP" ] && [ "$LOCAL_IP" != "127.0.0.1" ] && break
+ sleep 1
+done
+[ -z "$LOCAL_IP" ] && LOCAL_IP="localhost"
+
+KIOSK_URL="http://${LOCAL_IP}:5000/display"
+ADMIN_URL="http://${LOCAL_IP}:5000/admin"
+KIOSK_INTERNAL_DISPLAY="on"
+
+if [ -f "$CONF" ]; then
+ # shellcheck disable=SC1090
+ . "$CONF"
+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"
+
+# Vänta tills kiosk-servern svarar
+until curl -sf -o /dev/null "$KIOSK_URL" 2>/dev/null; do sleep 1; done
+
+# --- Pre-seed Chromium-profil: stäng av translate ---
+mkdir -p /tmp/kiosk-display/Default
+python3 - <<'PYEOF'
+import json, os
+f = '/tmp/kiosk-display/Default/Preferences'
+p = json.load(open(f)) if os.path.exists(f) else {}
+p.setdefault('translate', {})['enabled'] = False
+p.setdefault('translate_blocked_languages', ['sv','en'])
+json.dump(p, open(f, 'w'))
+PYEOF
+
+# --- Workspace 1 (HDMI-A-1): kiosk-display ---
+swaymsg workspace 1
+sleep 0.3
+swaymsg exec "chromium $CHROMIUM_FLAGS \
+ --user-data-dir=/tmp/kiosk-display \
+ $KIOSK_URL"
+
+# --- Workspace 2 (eDP-1): admin ---
+if [ "$KIOSK_INTERNAL_DISPLAY" = "on" ]; then
+ sleep 1
+ swaymsg workspace 2
+ sleep 0.3
+ swaymsg exec "chromium $CHROMIUM_FLAGS \
+ --user-data-dir=/tmp/kiosk-admin \
+ $ADMIN_URL"
+fi
+
+# Återvänd fokus till kiosk-display
+sleep 1
+swaymsg workspace 1
diff --git a/kiosk/sway.conf b/kiosk/sway.conf
new file mode 100644
index 0000000..d75a1bc
--- /dev/null
+++ b/kiosk/sway.conf
@@ -0,0 +1,37 @@
+# sway.conf — kiosk-konfiguration
+# Output-direktion och workspace-tilldelning genereras dynamiskt av kiosk-launcher.sh
+include /tmp/kiosk-sway-outputs.conf
+
+# ── Utseende ──────────────────────────────────────────────────────────────────
+default_border none
+hide_edge_borders both
+gaps inner 0
+gaps outer 0
+font pango:Sans 1
+
+# ── Inaktivera statusbar ───────────────────────────────────────────────────────
+bar {
+ mode hide
+}
+
+# ── Alla chromium-fönster: fullskärm, håll skärmen vaken ─────────────────────
+for_window [app_id="chromium"] {
+ fullscreen enable
+ inhibit_idle fullscreen
+}
+
+# ── Dölj muspekare efter 3 s inaktivitet ─────────────────────────────────────
+seat seat0 hide_cursor 3000
+
+# ── Inaktivera skärmsläckning ─────────────────────────────────────────────────
+exec swayidle -w \
+ timeout 0 '' \
+ before-sleep '' \
+ 2>/dev/null || true
+
+# ── Starta applikationer ──────────────────────────────────────────────────────
+exec /home/mrfox/codes/display/kiosk/sway-apps.sh
+exec /home/mrfox/codes/display/kiosk/hdmi-monitor.sh
+
+# ── Nödutgång (om tangentbord finns) ─────────────────────────────────────────
+bindsym --to-code Mod4+Shift+e exec swaymsg exit
diff --git a/kiosk/templates/admin.html b/kiosk/templates/admin.html
new file mode 100644
index 0000000..067f7fa
--- /dev/null
+++ b/kiosk/templates/admin.html
@@ -0,0 +1,587 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Kiosk Admin</title>
+ <link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+ <div class="container">
+ <h1>Kiosk Admin</h1>
+
+ <!-- Sektion 1: Visa nu -->
+ <section>
+ <h2>Visa nu</h2>
+
+ <div class="row">
+ <input id="url-input" type="url" placeholder="https://example.com eller YouTube-länk">
+ <button onclick="visaAdress()">Visa</button>
+ </div>
+
+ <div class="row">
+ <label class="upload-label">
+ <input id="upload-input" type="file" accept="video/*,image/*,.html,.htm,.pdf,.pptx,.ppt,.odp" onchange="laddaUpp()">
+ Ladda upp fil
+ </label>
+ <button class="secondary" onclick="openBrowser(visaLokalFil)">Välj lokal fil</button>
+ </div>
+ <div id="upload-progress" style="display:none">
+ <div class="progress-bar"><div id="progress-fill"></div></div>
+ <span id="upload-status"></span>
+ </div>
+ <div class="row">
+ <button class="secondary" onclick="tomSkarm()">Töm skärm</button>
+ <button class="secondary" onclick="laddaOmDisplay()">Ladda om display</button>
+ </div>
+ </section>
+
+ <!-- Sektion 2: Schema -->
+ <section>
+ <h2>Schema</h2>
+
+ <table id="jobs-table">
+ <thead>
+ <tr>
+ <th>Namn</th>
+ <th>Tid</th>
+ <th>Typ</th>
+ <th>Källa</th>
+ <th>Chat</th>
+ <th>Aktiv</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody id="jobs-body">
+ <tr><td colspan="7" class="empty">Laddar...</td></tr>
+ </tbody>
+ </table>
+
+ <form id="job-form" onsubmit="laggTillJobb(event)">
+ <h3>Lägg till schemalagt jobb</h3>
+ <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">
+ <option value="url">URL / HTML</option>
+ <option value="youtube">YouTube</option>
+ <option value="video">Video</option>
+ <option value="image">Bild</option>
+ <option value="pdf">PDF / Presentation</option>
+ <option value="kiosk_chat">Kiosk Livechat</option>
+ </select>
+ <div class="source-with-browse">
+ <input name="source" type="text" placeholder="URL eller välj fil" required>
+ <button type="button" class="secondary" onclick="openBrowser(setSchemaCalla)">Bläddra</button>
+ </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>
+ <button type="submit">Lägg till</button>
+ </div>
+ </form>
+
+ <!-- Edit modal (re-uses job form fields) -->
+ <div id="edit-modal" class="modal-overlay" style="display:none" onclick="editModalClick(event)">
+ <div class="modal">
+ <div class="modal-header">
+ <strong>Redigera jobb</strong>
+ <button class="secondary" onclick="closeEditModal()">✕</button>
+ </div>
+ <div style="padding:1rem">
+ <form id="edit-form" onsubmit="saveEdit(event)">
+ <input name="id" type="hidden">
+ <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">
+ <option value="url">URL / HTML</option>
+ <option value="youtube">YouTube</option>
+ <option value="video">Video</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>
+ <div style="margin-top:.6rem"><button type="submit">Spara</button></div>
+ </form>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <!-- Sektion 3: Ticker -->
+ <section>
+ <h2>Löptext</h2>
+
+ <div class="prop-grid">
+ <label>Källa</label>
+ <select id="ticker-source" onchange="tickerSourceChanged()">
+ <option value="text">Text</option>
+ <option value="rss">RSS-flöde</option>
+ </select>
+
+ <label id="ticker-text-label">Text</label>
+ <div id="ticker-text-wrap">
+ <textarea id="ticker-text" rows="4" placeholder="Välkommen! Öppet 10–18... 🎉 Rad 2 scrollas efter rad 1 Rad 3..." style="width:100%;resize:vertical;font-family:inherit;line-height:1.5"></textarea>
+ <div class="bbcode-bar">
+ <button type="button" class="secondary bb-btn" onclick="insertBB('b')" title="Fet"><b>B</b></button>
+ <button type="button" class="secondary bb-btn" onclick="insertBB('i')" title="Kursiv"><i>I</i></button>
+ <button type="button" class="secondary bb-btn" onclick="insertBB('u')" title="Understruken"><u>U</u></button>
+ <button type="button" class="secondary bb-btn" onclick="insertBB('s')" title="Genomstruken"><s>S</s></button>
+ <span class="bb-sep"></span>
+ <input type="color" id="bb-color-val" value="#ffff00" title="Välj färg">
+ <button type="button" class="secondary bb-btn" onclick="insertBBColor()" title="Infoga färgtagg">[color]</button>
+ <input type="number" id="bb-size-val" value="48" min="8" max="300">
+ <button type="button" class="secondary bb-btn" onclick="insertBBSize()" title="Infoga storlekstagg">[size]</button>
+ <span class="bb-sep"></span>
+ <button type="button" class="secondary bb-btn" onclick="insertBB('shadow')" title="Textskugga">Skugga</button>
+ <button type="button" class="secondary bb-btn" onclick="insertBBGlow()" title="Glödeffekt (använder vald färg)">Glöd</button>
+ </div>
+ <div style="font-size:.75rem;color:#444;margin-top:.35rem">
+ Varje rad scrollas en i taget. Radbryt med Enter eller [br]. BBCode: [b]fet[/b] [color=#ff0000]röd[/color] [size=64]stor[/size] [shadow]skugga[/shadow]
+ </div>
+ </div>
+
+ <label id="ticker-rss-label" style="display:none">RSS-URL</label>
+ <div id="ticker-rss-wrap" style="display:none">
+ <input id="ticker-rss-url" type="url" placeholder="https://feeds.example.com/rss" style="width:100%">
+ <div style="font-size:.75rem;color:#444;margin-top:.35rem">
+ Stöder RSS 2.0 och Atom. Rubrikerna hämtas och scrollas en i taget.
+ </div>
+ </div>
+
+ <label id="ticker-rss-interval-label" style="display:none">Uppdatera</label>
+ <select id="ticker-rss-interval" style="display:none">
+ <option value="1">Varje minut</option>
+ <option value="5">Var 5:e minut</option>
+ <option value="10" selected>Var 10:e minut</option>
+ <option value="30">Var 30:e minut</option>
+ <option value="60">Varje timme</option>
+ </select>
+
+ <label>Position</label>
+ <select id="ticker-position">
+ <option value="bottom">Nederkant</option>
+ <option value="top">Överkant</option>
+ </select>
+
+ <label>Hastighet</label>
+ <div class="row">
+ <span style="color:#555;font-size:.8rem;white-space:nowrap">Långsam</span>
+ <input type="range" id="ticker-pps" min="30" max="800" step="10" value="150"
+ oninput="document.getElementById('ticker-pps-val').textContent=this.value+' px/s'">
+ <span style="color:#555;font-size:.8rem;white-space:nowrap">Snabb</span>
+ <span id="ticker-pps-val" style="color:#aaa;min-width:5rem;text-align:right">150 px/s</span>
+ </div>
+
+ <label>Scrolläge</label>
+ <select id="ticker-mode" onchange="tickerModeChanged()">
+ <option value="sequential">En rad i taget</option>
+ <option value="continuous">Löpande (kontinuerligt)</option>
+ </select>
+
+ <label id="ticker-sep-label" style="display:none">Separator</label>
+ <div id="ticker-sep-wrap" style="display:none" class="row">
+ <input id="ticker-separator" type="text" value="◆" style="max-width:8rem">
+ <span style="color:#555;font-size:.8rem">visas mellan rader, t.ex. ◆ • | 🔸</span>
+ </div>
+ </div>
+
+ <h3>Textstil</h3>
+ <div class="prop-grid">
+ <label>Färg</label>
+ <div class="row">
+ <input type="color" id="ticker-text-color" value="#ffffff">
+ <span style="color:#aaa;font-size:.85rem">Standard (överskrivs av [color]-taggar)</span>
+ </div>
+
+ <label>Storlek</label>
+ <div class="row">
+ <input type="range" id="ticker-font-size" min="0.8" max="5" step="0.1" value="2"
+ oninput="document.getElementById('ticker-font-size-val').textContent=parseFloat(this.value).toFixed(1)+'rem'">
+ <span id="ticker-font-size-val" style="color:#aaa;min-width:4rem">2.0rem</span>
+ </div>
+
+ <label>Typsnitt</label>
+ <select id="ticker-font-family">
+ <optgroup label="System">
+ <option value="sans-serif">System sans-serif</option>
+ <option value="serif">System serif</option>
+ <option value="monospace">Monospace</option>
+ </optgroup>
+ <optgroup label="Webb">
+ <option value="Arial, sans-serif">Arial</option>
+ <option value="Georgia, serif">Georgia</option>
+ <option value="Impact, sans-serif">Impact</option>
+ <option value="'Courier New', monospace">Courier New</option>
+ <option value="'Times New Roman', serif">Times New Roman</option>
+ </optgroup>
+ <optgroup label="Kod (apt: fonts-cascadia-code)">
+ <option value="'Cascadia Code', monospace">Cascadia Code</option>
+ </optgroup>
+ <optgroup label="Nerd Fonts (installeras separat)">
+ <option value="'JetBrainsMono Nerd Font', monospace">JetBrainsMono NF</option>
+ <option value="'FiraCode Nerd Font', monospace">FiraCode NF</option>
+ <option value="'Hack Nerd Font', monospace">Hack NF</option>
+ <option value="'Inconsolata Nerd Font', monospace">Inconsolata NF</option>
+ <option value="'CaskaydiaCove Nerd Font', monospace">CaskaydiaCove NF</option>
+ <option value="'RobotoMono Nerd Font', monospace">RobotoMono NF</option>
+ <option value="'MesloLGS NF', monospace">MesloLGS NF</option>
+ <option value="'DejaVuSansMono Nerd Font', monospace">DejaVuSansMono NF</option>
+ </optgroup>
+ </select>
+ </div>
+
+ <h3>Tickerrad</h3>
+ <div class="prop-grid">
+ <label>Bakgrundsfärg</label>
+ <input type="color" id="ticker-bar-color" value="#000000">
+
+ <label>Opacitet</label>
+ <div class="row">
+ <input type="range" id="ticker-bar-opacity" min="0" max="100" step="5" value="72"
+ oninput="document.getElementById('ticker-bar-opacity-val').textContent=this.value+'%'">
+ <span id="ticker-bar-opacity-val" style="color:#aaa;min-width:3rem">72%</span>
+ </div>
+
+ <label>Höjd</label>
+ <div class="row">
+ <input type="range" id="ticker-bar-padding" min="0.1" max="1.5" step="0.05" value="0.45"
+ oninput="document.getElementById('ticker-bar-padding-val').textContent=parseFloat(this.value).toFixed(2)+'rem'">
+ <span id="ticker-bar-padding-val" style="color:#aaa;min-width:4rem">0.45rem</span>
+ </div>
+ </div>
+
+ <div class="row">
+ <button onclick="aktiveraTicker()">Aktivera</button>
+ <button class="secondary" onclick="avaktriveraTicker()">Stäng av</button>
+ <span id="ticker-status" style="color:#aaa;font-size:0.85rem;align-self:center"></span>
+ </div>
+ </section>
+
+ <!-- Sektion 4: Chat -->
+ <section>
+ <h2>Livechat</h2>
+
+ <h3>Kiosk Livechat</h3>
+ <p style="font-size:.85rem;color:#555;margin-bottom:.75rem">Inbyggd chat — besökare deltar via QR-kod på sin mobil. Kan visas som helskärm (schema-typ "Kiosk Livechat") eller som overlay ovanpå annat innehåll.</p>
+
+ <div class="prop-grid">
+ <label>Overlay</label>
+ <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+ <input type="checkbox" id="kiosk-chat-enabled">
+ Visa kiosk-chat som overlay ovanpå annat innehåll
+ </label>
+
+ <label>Position</label>
+ <select id="kiosk-chat-position">
+ <option value="right">Höger sida</option>
+ <option value="left">Vänster sida</option>
+ </select>
+
+ <label>Bredd</label>
+ <div class="row">
+ <input type="range" id="kiosk-chat-width" min="10" max="60" step="1" value="30"
+ oninput="document.getElementById('kiosk-chat-width-val').textContent=this.value+'%'">
+ <span id="kiosk-chat-width-val" style="color:#aaa;min-width:3rem">30%</span>
+ </div>
+
+ <label>Höjd</label>
+ <div class="row">
+ <input type="range" id="kiosk-chat-height" min="30" max="100" step="5" value="80"
+ oninput="document.getElementById('kiosk-chat-height-val').textContent=this.value+'%'">
+ <span id="kiosk-chat-height-val" style="color:#aaa;min-width:3rem">80%</span>
+ </div>
+ </div>
+
+ <div class="row" style="margin-bottom:1.25rem">
+ <button onclick="aktiveraChatOverlay('kiosk')">Aktivera overlay</button>
+ <button class="secondary" onclick="avaktiveraChatOverlay('kiosk')">Stäng av overlay</button>
+ <span id="kiosk-chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
+
+ <h3>YouTube Livechat</h3>
+ <p style="font-size:.85rem;color:#555;margin-bottom:1rem">Visas automatiskt som en overlay när ett YouTube-liveflöde spelas. Döljs vid annat innehåll.</p>
+
+ <div class="prop-grid">
+ <label>Position</label>
+ <select id="chat-position">
+ <option value="right">Höger sida</option>
+ <option value="left">Vänster sida</option>
+ </select>
+
+ <label>Bredd</label>
+ <div class="row">
+ <input type="range" id="chat-width-pct" min="5" max="60" step="1" value="25"
+ oninput="document.getElementById('chat-width-pct-val').textContent=this.value+'%'">
+ <span id="chat-width-pct-val" style="color:#aaa;min-width:3rem">25%</span>
+ </div>
+
+ <label>Höjd</label>
+ <div class="row">
+ <input type="range" id="chat-height" min="30" max="100" step="5" value="100"
+ oninput="document.getElementById('chat-height-val').textContent=this.value+'%'">
+ <span id="chat-height-val" style="color:#aaa;min-width:3rem">100%</span>
+ </div>
+ </div>
+
+ <h3>Bakgrund</h3>
+ <div class="prop-grid">
+ <label>Färg</label>
+ <input type="color" id="chat-bg-color" value="#000000">
+
+ <label>Transparens</label>
+ <div class="row">
+ <input type="range" id="chat-bg-opacity" min="0" max="100" step="5" value="70"
+ oninput="document.getElementById('chat-bg-opacity-val').textContent=this.value+'%'">
+ <span id="chat-bg-opacity-val" style="color:#aaa;min-width:3rem">70%</span>
+ </div>
+ </div>
+
+ <div class="row">
+ <button onclick="aktiveraChatOverlay('yt')">Aktivera</button>
+ <button class="secondary" onclick="avaktiveraChatOverlay('yt')">Stäng av</button>
+ <button id="chat-theme-btn" class="secondary">Chatt: Auto</button>
+ <span id="chat-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
+ </section>
+
+ <!-- Sektion 5: Klocka -->
+ <section>
+ <h2>Klocka</h2>
+
+ <div class="prop-grid">
+ <label>Stil</label>
+ <select id="clock-style">
+ <option value="digital">Digital</option>
+ <option value="analog">Analog</option>
+ </select>
+
+ <label>Datum</label>
+ <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+ <input type="checkbox" id="clock-show-date">
+ Visa datum under klockan
+ </label>
+
+ <label>Position</label>
+ <div style="display:flex;gap:.4rem;flex-wrap:wrap">
+ <button type="button" id="clkpos-top-left" class="secondary clock-pos-btn" onclick="setClockPos('top-left')">↖ Övre vänster</button>
+ <button type="button" id="clkpos-top-right" class="secondary clock-pos-btn" onclick="setClockPos('top-right')">↗ Övre höger</button>
+ <button type="button" id="clkpos-bottom-left" class="secondary clock-pos-btn" onclick="setClockPos('bottom-left')">↙ Nedre vänster</button>
+ <button type="button" id="clkpos-bottom-right" class="secondary clock-pos-btn" onclick="setClockPos('bottom-right')">↘ Nedre höger</button>
+ <button type="button" id="clkpos-center" class="secondary clock-pos-btn" onclick="setClockPos('center')">⊙ Centrerad</button>
+ </div>
+
+ <label>Storlek</label>
+ <div class="row">
+ <span style="color:#555;font-size:.8rem;white-space:nowrap">Liten</span>
+ <input type="range" id="clock-size" min="1" max="5" step="0.5" value="2"
+ oninput="document.getElementById('clock-size-val').textContent=this.value">
+ <span style="color:#555;font-size:.8rem;white-space:nowrap">Stor</span>
+ <span id="clock-size-val" style="color:#aaa;min-width:2rem;text-align:right">2</span>
+ </div>
+
+ <label>Färg</label>
+ <input type="color" id="clock-color" value="#ffffff">
+
+ <label>Opacitet</label>
+ <div class="row">
+ <input type="range" id="clock-opacity" min="10" max="100" step="5" value="90"
+ oninput="document.getElementById('clock-opacity-val').textContent=this.value+'%'">
+ <span id="clock-opacity-val" style="color:#aaa;min-width:3rem">90%</span>
+ </div>
+
+ <label>Schema</label>
+ <select id="clock-schedule-mode">
+ <option value="always">Visa alltid</option>
+ <option value="scheduled_only">Bara under schemalagd visning</option>
+ </select>
+ </div>
+
+ <div class="row" style="margin-top:.75rem">
+ <button onclick="aktiveraClock()">Aktivera</button>
+ <button class="secondary" onclick="avaktiveraClock()">Stäng av</button>
+ <span id="clock-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
+ </section>
+
+ <!-- Sektion 6: Sovläge -->
+ <section>
+ <h2>Sovläge</h2>
+ <div class="prop-grid">
+ <label>Aktivera</label>
+ <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+ <input type="checkbox" id="sleep-enabled">
+ Täck displayen med svart skärm under angiven tid
+ </label>
+
+ <label>Sov från</label>
+ <input type="time" id="sleep-start" value="22:00" style="max-width:9rem">
+
+ <label>Vakna kl</label>
+ <input type="time" id="sleep-end" value="07:00" style="max-width:9rem">
+ </div>
+ <div class="row" style="margin-top:.75rem">
+ <button onclick="sparaSovlage()">Spara</button>
+ <span id="sleep-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
+ </section>
+
+ <!-- Sektion: Wi-Fi -->
+ <section>
+ <h2>Wi-Fi</h2>
+
+ <!-- Status -->
+ <div id="wifi-status-box" class="prop-grid" style="margin-bottom:1rem">
+ <label>Status</label>
+ <div>
+ <span id="wifi-status-dot" style="display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:#555;margin-right:.4rem;vertical-align:middle"></span>
+ <span id="wifi-status-text" style="font-size:.9rem">Laddar...</span>
+ </div>
+ <label>IP</label>
+ <span id="wifi-ip" style="font-size:.9rem;font-family:monospace">—</span>
+ <label>Signal</label>
+ <div style="display:flex;align-items:center;gap:.5rem">
+ <div id="wifi-signal-bar" style="display:flex;align-items:flex-end;gap:2px;height:14px">
+ <span style="width:4px;border-radius:1px;background:#333;height:4px"></span>
+ <span style="width:4px;border-radius:1px;background:#333;height:7px"></span>
+ <span style="width:4px;border-radius:1px;background:#333;height:11px"></span>
+ <span style="width:4px;border-radius:1px;background:#333;height:14px"></span>
+ </div>
+ <span id="wifi-signal-val" style="color:#aaa;font-size:.8rem">—</span>
+ </div>
+ </div>
+
+ <div class="row" style="margin-bottom:1rem">
+ <button onclick="wifiScan()" id="wifi-scan-btn">Skanna nätverk</button>
+ <button class="secondary" onclick="wifiDisconnect()">Koppla från</button>
+ <span id="wifi-scan-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
+
+ <!-- Nätverkslista -->
+ <div id="wifi-networks" style="display:none;margin-bottom:1.25rem">
+ <h3>Tillgängliga nätverk</h3>
+ <div id="wifi-network-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem"></div>
+ </div>
+
+ <!-- Sparade nätverk -->
+ <div id="wifi-saved-wrap">
+ <h3>Sparade nätverk</h3>
+ <div id="wifi-saved-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem;font-size:.9rem"></div>
+ </div>
+ </section>
+
+ <!-- Wi-Fi lösenordsmodal -->
+ <div id="wifi-pw-modal" class="modal-overlay" style="display:none" onclick="wifiModalClick(event)">
+ <div class="modal">
+ <div class="modal-header">
+ <strong id="wifi-pw-title">Anslut till nätverk</strong>
+ <button class="secondary" onclick="closeWifiModal()">✕</button>
+ </div>
+ <div style="padding:1rem">
+ <div class="prop-grid">
+ <label>Lösenord</label>
+ <input id="wifi-pw-input" type="password" placeholder="Lämna tomt om öppet nätverk" style="width:100%" onkeydown="if(event.key==='Enter')wifiDoConnect()">
+ </div>
+ <div class="row" style="margin-top:.75rem">
+ <button onclick="wifiDoConnect()" id="wifi-connect-btn">Anslut</button>
+ <span id="wifi-connect-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Sektion 6: Inställningar -->
+ <section>
+ <h2>Inställningar</h2>
+
+ <h3>Ljud</h3>
+ <div class="prop-grid">
+ <label>Volym</label>
+ <div class="row">
+ <input id="setting-volume" type="range" min="0" max="100" step="1" style="flex:1"
+ oninput="document.getElementById('volume-val').textContent=this.value+'%'">
+ <span id="volume-val" style="color:#aaa;min-width:3rem;text-align:right">—</span>
+ <button onclick="setVolym()">Sätt</button>
+ </div>
+
+ <label>Ljudprofil</label>
+ <select id="setting-audio"></select>
+ </div>
+
+ <h3>Display</h3>
+ <div class="prop-grid">
+ <label>Intern display</label>
+ <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+ <input type="checkbox" id="setting-internal-display" checked onchange="setInternDisplay(this.checked)">
+ Aktivera eDP-1 med admin-gränssnitt
+ </label>
+
+ <label>HDMI-utgång</label>
+ <select id="setting-display">
+ <option value="last">Sista anslutna (standard)</option>
+ <option value="first">Första anslutna</option>
+ </select>
+
+ <label>Upplösning</label>
+ <div style="display:flex;gap:.5rem;align-items:center">
+ <select id="setting-display-mode" style="flex:1">
+ <option value="">(Ingen / lämna tomt)</option>
+ </select>
+ <input id="setting-display-mode-custom" type="text" placeholder="Ex: 1920x1080" style="display:none;min-width:10rem">
+ </div>
+ </div>
+ <div id="displays-info" style="font-size:.82rem;color:#555;margin:.2rem 0 .75rem"></div>
+
+ <div class="row">
+ <button onclick="sparaInstallningar()">Spara inställningar</button>
+ <button class="secondary" onclick="applyDisplayNow()">Tillämpa upplösning nu</button>
+ <span id="settings-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
+ </section>
+
+ <!-- Sektion 6: System -->
+ <section>
+ <h2>System</h2>
+ <div class="row">
+ <button class="secondary" onclick="startaOmProgram()">Starta om programmet</button>
+ <button class="danger" onclick="startaOmSystemet()">Starta om datorn</button>
+ <button class="danger" onclick="stangAvDatorn()">Stäng av datorn</button>
+ </div>
+ </section>
+
+ <!-- Sektion 7: Systeminfo -->
+ <section>
+ <h2>Systeminfo</h2>
+ <div id="sysinfo" class="sysinfo-grid">
+ <div class="stat"><span class="label">CPU</span><span id="cpu">—</span></div>
+ <div class="stat"><span class="label">RAM</span><span id="ram">—</span></div>
+ <div class="stat"><span class="label">Disk</span><span id="disk">—</span></div>
+ <div class="stat"><span class="label">Uptime</span><span id="uptime">—</span></div>
+ <div class="stat wide"><span class="label">Aktiv källa</span><span id="active-source">—</span></div>
+ </div>
+ </section>
+ </div>
+
+ <!-- Filbläddrare -->
+ <div id="browser-modal" class="modal-overlay" style="display:none" onclick="browserOverlayClick(event)">
+ <div class="modal">
+ <div class="modal-header">
+ <span class="modal-path" id="browser-path"></span>
+ <button class="secondary" onclick="closeBrowser()">✕</button>
+ </div>
+ <div class="modal-shortcuts">
+ <button class="secondary" onclick="browseTo('/home/mrfox')">Hem</button>
+ <button class="secondary" onclick="browseTo('/media')">USB (/media)</button>
+ <button class="secondary" onclick="browseTo('/mnt')">Monterat (/mnt)</button>
+ </div>
+ <div id="browser-list" class="browser-list"></div>
+ </div>
+ </div>
+
+ <script src="/static/socket.io.min.js"></script>
+ <script src="/static/admin.js"></script>
+</body>
+</html>
diff --git a/kiosk/templates/chat_display.html b/kiosk/templates/chat_display.html
new file mode 100644
index 0000000..6f22f35
--- /dev/null
+++ b/kiosk/templates/chat_display.html
@@ -0,0 +1,258 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+ <meta charset="UTF-8">
+ <title>Kiosk Livechat</title>
+ <script src="/static/socket.io.min.js"></script>
+ <style>
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+
+ html, body {
+ width: 100%; height: 100%;
+ background: #0f0f0f;
+ color: #e8e8e8;
+ font-family: 'Segoe UI', system-ui, sans-serif;
+ overflow: hidden;
+ }
+
+ /* Layout — chat left, QR panel right */
+ #root {
+ display: flex;
+ height: 100vh;
+ gap: 0;
+ }
+
+ /* === Chat column === */
+ #chat-col {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ padding: 1.2rem 1rem 1rem 1.4rem;
+ overflow: hidden;
+ }
+
+ #chat-header {
+ display: flex;
+ align-items: center;
+ gap: .75rem;
+ margin-bottom: .9rem;
+ flex-shrink: 0;
+ }
+
+ #chat-header h1 {
+ font-size: 1.35rem;
+ font-weight: 700;
+ color: #fff;
+ letter-spacing: .02em;
+ }
+
+ #viewer-count {
+ font-size: .85rem;
+ color: #888;
+ background: #1a1a1a;
+ padding: .2rem .6rem;
+ border-radius: 99px;
+ }
+
+ /* Message list — fills remaining height, scrolls from bottom */
+ #messages {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+ }
+
+ #msg-inner {
+ position: absolute;
+ bottom: 0; left: 0; right: 0;
+ display: flex;
+ flex-direction: column;
+ gap: .55rem;
+ padding-bottom: .2rem;
+ }
+
+ .msg {
+ display: flex;
+ align-items: flex-start;
+ gap: .55rem;
+ animation: slide-in .25s ease;
+ }
+
+ @keyframes slide-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+
+ .msg-avatar {
+ width: 2rem; height: 2rem;
+ border-radius: 50%;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 1rem;
+ flex-shrink: 0;
+ margin-top: .1rem;
+ }
+
+ .msg-body { flex: 1; min-width: 0; }
+
+ .msg-name {
+ font-size: .78rem;
+ font-weight: 700;
+ margin-bottom: .15rem;
+ }
+
+ .msg-text {
+ font-size: 1.05rem;
+ line-height: 1.45;
+ word-break: break-word;
+ color: #e8e8e8;
+ }
+
+ .msg.system .msg-text {
+ font-size: .82rem;
+ color: #666;
+ font-style: italic;
+ }
+
+ /* === QR panel === */
+ #qr-col {
+ width: 180px;
+ flex-shrink: 0;
+ background: #181818;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: .9rem;
+ padding: 1.2rem .8rem;
+ border-left: 1px solid #222;
+ }
+
+ #qr-canvas {
+ border-radius: 8px;
+ background: #fff;
+ padding: 6px;
+ }
+
+ #qr-label {
+ font-size: .72rem;
+ color: #666;
+ text-align: center;
+ word-break: break-all;
+ }
+
+ #qr-title {
+ font-size: .85rem;
+ font-weight: 600;
+ color: #aaa;
+ text-align: center;
+ }
+
+ .dot-live {
+ width: .55rem; height: .55rem;
+ background: #ef4444;
+ border-radius: 50%;
+ display: inline-block;
+ animation: pulse 1.4s infinite;
+ }
+
+ @keyframes pulse {
+ 0%,100% { opacity: 1; }
+ 50% { opacity: .3; }
+ }
+ </style>
+</head>
+<body>
+<div id="root">
+
+ <!-- Chat column -->
+ <div id="chat-col">
+ <div id="chat-header">
+ <span class="dot-live"></span>
+ <h1>Livechat</h1>
+ <span id="viewer-count">0 aktiva</span>
+ </div>
+ <div id="messages">
+ <div id="msg-inner"></div>
+ </div>
+ </div>
+
+ <!-- QR panel -->
+ <div id="qr-col">
+ <div id="qr-title">Delta i chatten</div>
+ <img id="qr-img" src="/api/chat-qr" alt="QR" style="width:140px;height:140px;border-radius:8px;background:#fff;display:block">
+ <div id="qr-label">{{ local_ip }}:5000/chat-mobile</div>
+ </div>
+
+</div>
+
+<script>
+// I overlay-läge (?overlay=1) döljs QR-panelen och bakgrunden görs transparent
+const isOverlay = new URLSearchParams(location.search).has('overlay');
+if (isOverlay) {
+ document.body.style.background = 'transparent';
+ document.getElementById('root').style.background = 'transparent';
+ document.getElementById('qr-col').style.display = 'none';
+ document.getElementById('chat-col').style.padding = '.5rem .75rem';
+}
+
+const MOBILE_URL = 'http://{{ local_ip }}:5000/chat-mobile';
+const MAX_MSGS = 40;
+
+// SocketIO — /chat namespace
+const socket = io('/chat');
+
+const msgInner = document.getElementById('msg-inner');
+const countEl = document.getElementById('viewer-count');
+
+function avatarBg(color) {
+ return `background:${color}22; color:${color};`;
+}
+
+function avatarContent(emoji, name, avatarUrl) {
+ if (avatarUrl) return `<img src="${avatarUrl}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
+ return escHtml(emoji || name.charAt(0).toUpperCase());
+}
+
+function addMsg(name, text, color, emoji, avatarUrl) {
+ const div = document.createElement('div');
+ div.className = 'msg';
+ div.innerHTML = `
+ <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>`;
+ msgInner.appendChild(div);
+ while (msgInner.children.length > MAX_MSGS) msgInner.removeChild(msgInner.firstChild);
+}
+
+function addSystem(text) {
+ const div = document.createElement('div');
+ div.className = 'msg system';
+ div.innerHTML = `<div class="msg-body"><div class="msg-text">${escHtml(text)}</div></div>`;
+ msgInner.appendChild(div);
+ while (msgInner.children.length > MAX_MSGS) msgInner.removeChild(msgInner.firstChild);
+}
+
+function escHtml(s) {
+ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
+}
+
+socket.on('chat_history', (msgs) => {
+ msgInner.innerHTML = '';
+ msgs.forEach(m => 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('system_message', (d) => {
+ addSystem(d.text);
+});
+
+socket.on('viewer_count', (d) => {
+ countEl.textContent = d.count + ' aktiva';
+});
+</script>
+</body>
+</html>
diff --git a/kiosk/templates/chat_mobile.html b/kiosk/templates/chat_mobile.html
new file mode 100644
index 0000000..96d6880
--- /dev/null
+++ b/kiosk/templates/chat_mobile.html
@@ -0,0 +1,667 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
+ <title>Livechat</title>
+ <script src="/static/socket.io.min.js"></script>
+ <style>
+ * { box-sizing: border-box; margin: 0; padding: 0; }
+
+ :root {
+ --bg: #0f0f0f;
+ --surface: #1a1a1a;
+ --border: #2a2a2a;
+ --text: #e8e8e8;
+ --muted: #777;
+ --accent: #2563eb;
+ --accent-h: #1d4ed8;
+ }
+
+ html, body {
+ height: 100%;
+ background: var(--bg);
+ color: var(--text);
+ font-family: 'Segoe UI', system-ui, sans-serif;
+ font-size: 15px;
+ }
+
+ /* ====== AUTH SCREEN ====== */
+ #auth-screen {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 2rem 1.5rem;
+ gap: 1rem;
+ }
+
+ #auth-screen h1 {
+ font-size: 1.6rem;
+ font-weight: 700;
+ color: #fff;
+ margin-bottom: .25rem;
+ }
+
+ /* Tab bar */
+ .tab-bar {
+ display: flex;
+ width: 100%;
+ max-width: 340px;
+ background: var(--surface);
+ border-radius: 10px;
+ padding: .25rem;
+ gap: .25rem;
+ }
+
+ .tab-btn {
+ flex: 1;
+ padding: .6rem;
+ border: none;
+ border-radius: 8px;
+ background: transparent;
+ color: var(--muted);
+ font-size: .95rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background .15s, color .15s;
+ }
+
+ .tab-btn.active {
+ background: var(--accent);
+ color: #fff;
+ }
+
+ /* Forms */
+ .auth-form {
+ display: none;
+ flex-direction: column;
+ gap: .75rem;
+ width: 100%;
+ max-width: 340px;
+ }
+
+ .auth-form.visible { display: flex; }
+
+ input[type=text], input[type=password] {
+ width: 100%;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: .75rem 1rem;
+ border-radius: 10px;
+ font-size: 1rem;
+ }
+
+ input::placeholder { color: var(--muted); }
+ input:focus { outline: 2px solid var(--accent); border-color: transparent; }
+
+ .auth-btn {
+ width: 100%;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ padding: .85rem 1rem;
+ border-radius: 10px;
+ font-size: 1rem;
+ font-weight: 600;
+ cursor: pointer;
+ margin-top: .25rem;
+ }
+
+ .auth-btn:hover { background: var(--accent-h); }
+ .auth-btn:disabled { opacity: .5; cursor: default; }
+
+ .auth-error {
+ color: #f87171;
+ font-size: .85rem;
+ min-height: 1.2em;
+ text-align: center;
+ }
+
+ /* Avatar grid (register only) */
+ .avatar-label {
+ font-size: .8rem;
+ color: var(--muted);
+ font-weight: 600;
+ letter-spacing: .04em;
+ text-transform: uppercase;
+ }
+
+ .avatar-pick {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: .6rem;
+ width: 100%;
+ }
+
+ .avatar-pick-circle {
+ width: 80px; height: 80px;
+ border-radius: 50%;
+ background: var(--surface);
+ border: 2px dashed var(--border);
+ display: flex; align-items: center; justify-content: center;
+ overflow: hidden;
+ cursor: pointer;
+ transition: border-color .15s;
+ }
+
+ .avatar-pick-circle:hover { border-color: var(--accent); }
+ .avatar-pick-circle img { width: 100%; height: 100%; object-fit: cover; display: none; }
+ .avatar-pick-circle .pick-icon { font-size: 2rem; color: var(--muted); }
+
+ .avatar-pick-hint {
+ font-size: .8rem;
+ color: var(--muted);
+ text-align: center;
+ }
+
+ /* ====== CHAT SCREEN ====== */
+ #chat-screen {
+ display: none;
+ flex-direction: column;
+ height: 100vh;
+ }
+
+ #chat-topbar {
+ display: flex;
+ align-items: center;
+ gap: .6rem;
+ padding: .75rem 1rem;
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ }
+
+ #chat-topbar .me-avatar { font-size: 1.3rem; }
+ #chat-topbar .me-name { font-weight: 600; font-size: .95rem; }
+
+ #chat-topbar .viewers {
+ margin-left: auto;
+ font-size: .8rem;
+ color: var(--muted);
+ background: var(--bg);
+ padding: .2rem .6rem;
+ border-radius: 99px;
+ }
+
+ #logout-btn {
+ background: none;
+ border: none;
+ color: var(--muted);
+ font-size: 1.1rem;
+ cursor: pointer;
+ padding: .2rem .35rem;
+ border-radius: 6px;
+ }
+ #logout-btn:hover { color: #f87171; }
+
+ #avatar-upload-btn {
+ position: relative;
+ width: 2rem; height: 2rem;
+ border-radius: 50%;
+ overflow: hidden;
+ flex-shrink: 0;
+ cursor: pointer;
+ border: 2px solid #333;
+ }
+ #avatar-upload-btn img {
+ width: 100%; height: 100%;
+ object-fit: cover;
+ }
+ #avatar-upload-btn .avatar-overlay {
+ display: none;
+ position: absolute;
+ inset: 0;
+ background: #0009;
+ font-size: .65rem;
+ color: #fff;
+ align-items: center;
+ justify-content: center;
+ }
+ #avatar-upload-btn:hover .avatar-overlay { display: flex; }
+ #avatar-file { display: none; }
+
+ #chat-log {
+ flex: 1;
+ overflow-y: auto;
+ padding: .75rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: .55rem;
+ }
+
+ .cm { display: flex; gap: .5rem; align-items: flex-start; }
+ .cm.mine { flex-direction: row-reverse; }
+
+ .cm-avatar {
+ width: 1.9rem; height: 1.9rem;
+ border-radius: 50%;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 1rem;
+ flex-shrink: 0;
+ }
+
+ .cm-bubble {
+ max-width: 78%;
+ padding: .5rem .75rem;
+ border-radius: 14px;
+ font-size: .95rem;
+ line-height: 1.4;
+ word-break: break-word;
+ }
+
+ .cm:not(.mine) .cm-bubble {
+ background: var(--surface);
+ border-top-left-radius: 4px;
+ }
+
+ .cm.mine .cm-bubble {
+ background: var(--accent);
+ color: #fff;
+ border-top-right-radius: 4px;
+ }
+
+ .cm-name {
+ font-size: .72rem;
+ font-weight: 700;
+ margin-bottom: .15rem;
+ }
+
+ .cm.mine .cm-name { display: none; }
+
+ .cm.system { justify-content: center; }
+ .cm.system .cm-bubble {
+ background: transparent;
+ color: var(--muted);
+ font-size: .8rem;
+ font-style: italic;
+ text-align: center;
+ padding: .2rem;
+ }
+
+ #input-row {
+ display: flex;
+ gap: .5rem;
+ padding: .65rem .75rem;
+ background: var(--surface);
+ border-top: 1px solid var(--border);
+ flex-shrink: 0;
+ }
+
+ #msg-input {
+ flex: 1;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ color: var(--text);
+ padding: .6rem .85rem;
+ border-radius: 20px;
+ font-size: .95rem;
+ resize: none;
+ min-height: 2.4rem;
+ max-height: 6rem;
+ overflow-y: auto;
+ line-height: 1.4;
+ font-family: inherit;
+ }
+
+ #msg-input::placeholder { color: var(--muted); }
+ #msg-input:focus { outline: 2px solid var(--accent); border-color: transparent; }
+
+ #send-btn {
+ width: 2.4rem; height: 2.4rem;
+ border-radius: 50%;
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ font-size: 1.1rem;
+ cursor: pointer;
+ flex-shrink: 0;
+ align-self: flex-end;
+ display: flex; align-items: center; justify-content: center;
+ }
+
+ #send-btn:disabled { background: #333; color: #555; cursor: default; }
+ #send-btn:not(:disabled):hover { background: var(--accent-h); }
+ </style>
+</head>
+<body>
+
+<!-- AUTH SCREEN -->
+<div id="auth-screen">
+ <h1>💬 Livechat</h1>
+
+ <div class="tab-bar">
+ <button class="tab-btn active" id="tab-login-btn" onclick="showTab('login')">Logga in</button>
+ <button class="tab-btn" id="tab-register-btn" onclick="showTab('register')">Registrera</button>
+ </div>
+
+ <!-- Login form -->
+ <form class="auth-form visible" id="login-form" onsubmit="return false">
+ <input type="text" id="login-username" placeholder="Användarnamn" autocomplete="username" autocorrect="off" autocapitalize="none">
+ <input type="password" id="login-password" placeholder="Lösenord" autocomplete="current-password">
+ <div class="auth-error" id="login-error"></div>
+ <button class="auth-btn" id="login-btn" onclick="doLogin()">Logga in</button>
+ </form>
+
+ <!-- Register form -->
+ <form class="auth-form" id="register-form" onsubmit="return false">
+ <input type="text" id="reg-username" placeholder="Välj användarnamn" autocomplete="username" autocorrect="off" autocapitalize="none" maxlength="32">
+ <input type="password" id="reg-password" placeholder="Välj lösenord" autocomplete="new-password">
+ <div class="avatar-label">Profilbild (obligatorisk)</div>
+ <div class="avatar-pick">
+ <div class="avatar-pick-circle" id="reg-avatar-circle" onclick="document.getElementById('reg-avatar-file').click()">
+ <img id="reg-avatar-preview" alt="">
+ <span class="pick-icon" id="reg-avatar-icon">📷</span>
+ </div>
+ <div class="avatar-pick-hint">Tryck för att välja bild från kameran eller bildbiblioteket</div>
+ <input type="file" id="reg-avatar-file" accept="image/*" capture="environment" style="display:none">
+ </div>
+ <div class="auth-error" id="reg-error"></div>
+ <button class="auth-btn" id="reg-btn" onclick="doRegister()">Skapa konto</button>
+ </form>
+</div>
+
+<!-- CHAT SCREEN -->
+<div id="chat-screen">
+ <div id="chat-topbar">
+ <div id="avatar-upload-btn" title="Byt profilbild" onclick="document.getElementById('avatar-file').click()">
+ <img id="me-avatar-img" src="" alt="" style="display:none">
+ <span id="me-avatar" style="font-size:1.3rem;display:flex;align-items:center;justify-content:center;width:100%;height:100%;"></span>
+ <div class="avatar-overlay">📷</div>
+ </div>
+ <input type="file" id="avatar-file" accept="image/jpeg,image/png,image/webp">
+ <span class="me-name" id="me-name"></span>
+ <span class="viewers" id="viewer-count">0 aktiva</span>
+ <button id="logout-btn" title="Logga ut" onclick="doLogout()">✕</button>
+ </div>
+
+ <div id="chat-log"></div>
+
+ <div id="input-row">
+ <textarea id="msg-input" placeholder="Skriv ett meddelande..." rows="1"></textarea>
+ <button id="send-btn" disabled>➤</button>
+ </div>
+</div>
+
+<script>
+const AVATARS = ['😀','😎','🤖','🦊','🐼','🐸','🦁','🐯','🦄','🐻',
+ '🌟','🔥','⚡','🎮','🎵','🍕','🚀','💎','🌈','👾'];
+
+const TOKEN_KEY = 'kiosk_chat_token';
+const socket = io('/chat');
+let currentUser = null; // {username, emoji, color, token}
+
+// ---- Avatar pick (register) ----
+let regAvatarFile = null;
+
+document.getElementById('reg-avatar-file').addEventListener('change', (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ regAvatarFile = file;
+ const preview = document.getElementById('reg-avatar-preview');
+ const icon = document.getElementById('reg-avatar-icon');
+ const url = URL.createObjectURL(file);
+ preview.src = url;
+ preview.style.display = 'block';
+ icon.style.display = 'none';
+});
+
+// ---- Tab switching ----
+function showTab(tab) {
+ document.getElementById('login-form').classList.toggle('visible', tab === 'login');
+ document.getElementById('register-form').classList.toggle('visible', tab === 'register');
+ document.getElementById('tab-login-btn').classList.toggle('active', tab === 'login');
+ document.getElementById('tab-register-btn').classList.toggle('active', tab === 'register');
+ document.getElementById('login-error').textContent = '';
+ document.getElementById('reg-error').textContent = '';
+ // Reset avatar picker when switching tabs
+ regAvatarFile = null;
+ document.getElementById('reg-avatar-preview').style.display = 'none';
+ document.getElementById('reg-avatar-preview').src = '';
+ document.getElementById('reg-avatar-icon').style.display = '';
+ document.getElementById('reg-avatar-file').value = '';
+}
+
+// ---- Auth helpers ----
+function escHtml(s) {
+ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
+}
+
+async function doLogin() {
+ const username = document.getElementById('login-username').value.trim();
+ const password = document.getElementById('login-password').value;
+ const errEl = document.getElementById('login-error');
+ if (!username || !password) { errEl.textContent = 'Fyll i alla fält'; return; }
+ document.getElementById('login-btn').disabled = true;
+ try {
+ const r = await fetch('/api/chat/login', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({username, password}),
+ });
+ const d = await r.json();
+ if (!r.ok) { errEl.textContent = d.error || 'Inloggning misslyckades'; return; }
+ localStorage.setItem(TOKEN_KEY, d.token);
+ enterChat(d);
+ } catch {
+ errEl.textContent = 'Nätverksfel, försök igen';
+ } finally {
+ document.getElementById('login-btn').disabled = false;
+ }
+}
+
+async function doRegister() {
+ const username = document.getElementById('reg-username').value.trim();
+ const password = document.getElementById('reg-password').value;
+ const errEl = document.getElementById('reg-error');
+ if (!username || !password) { errEl.textContent = 'Fyll i alla fält'; return; }
+ if (!regAvatarFile) { errEl.textContent = 'Välj en profilbild för att fortsätta'; return; }
+ document.getElementById('reg-btn').disabled = true;
+ try {
+ const randomEmoji = AVATARS[Math.floor(Math.random() * AVATARS.length)];
+ const r = await fetch('/api/chat/register', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({username, password, emoji: randomEmoji}),
+ });
+ const d = await r.json();
+ if (!r.ok) { errEl.textContent = d.error || 'Registrering misslyckades'; return; }
+ // Upload avatar immediately after registration
+ const fd = new FormData();
+ fd.append('token', d.token);
+ fd.append('avatar', regAvatarFile);
+ const ar = await fetch('/api/chat/avatar', {method: 'POST', body: fd});
+ if (ar.ok) {
+ const ad = await ar.json();
+ d.avatar_url = ad.avatar_url;
+ }
+ localStorage.setItem(TOKEN_KEY, d.token);
+ enterChat(d);
+ } catch {
+ errEl.textContent = 'Nätverksfel, försök igen';
+ } finally {
+ document.getElementById('reg-btn').disabled = false;
+ }
+}
+
+function setMyAvatar(avatarUrl, emoji) {
+ const img = document.getElementById('me-avatar-img');
+ const span = document.getElementById('me-avatar');
+ if (avatarUrl) {
+ img.src = avatarUrl + '?t=' + Date.now();
+ img.style.display = 'block';
+ span.style.display = 'none';
+ } else {
+ img.style.display = 'none';
+ span.style.display = 'flex';
+ span.textContent = emoji || '?';
+ }
+}
+
+function enterChat(user) {
+ currentUser = user;
+ document.getElementById('auth-screen').style.display = 'none';
+ const cs = document.getElementById('chat-screen');
+ cs.style.display = 'flex';
+ setMyAvatar(user.avatar_url, user.emoji);
+ document.getElementById('me-name').textContent = user.username;
+ document.getElementById('msg-input').focus();
+ socket.emit('join', {token: user.token});
+}
+
+// ---- Avatar upload ----
+document.getElementById('avatar-file').addEventListener('change', async (e) => {
+ const file = e.target.files[0];
+ if (!file || !currentUser) return;
+ const fd = new FormData();
+ fd.append('token', currentUser.token);
+ fd.append('avatar', file);
+ try {
+ const r = await fetch('/api/chat/avatar', {method: 'POST', body: fd});
+ if (!r.ok) return;
+ const d = await r.json();
+ currentUser.avatar_url = d.avatar_url;
+ setMyAvatar(d.avatar_url, currentUser.emoji);
+ } catch {}
+ e.target.value = '';
+});
+
+function doLogout() {
+ localStorage.removeItem(TOKEN_KEY);
+ currentUser = null;
+ socket.disconnect();
+ document.getElementById('chat-screen').style.display = 'none';
+ document.getElementById('auth-screen').style.display = 'flex';
+ document.getElementById('chat-log').innerHTML = '';
+ document.getElementById('login-password').value = '';
+ socket.connect();
+}
+
+// ---- Auto-login from stored token ----
+(async function tryAutoLogin() {
+ const token = localStorage.getItem(TOKEN_KEY);
+ if (!token) return;
+ try {
+ const r = await fetch('/api/chat/validate', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({token}),
+ });
+ if (!r.ok) { localStorage.removeItem(TOKEN_KEY); return; }
+ const d = await r.json();
+ enterChat({...d, token});
+ } catch {}
+})();
+
+// ---- Socket events ----
+socket.on('chat_history', (msgs) => {
+ if (!currentUser) return; // ignorera historik som anländer före inloggning
+ chatLog.innerHTML = '';
+ msgs.forEach(m => {
+ if (m.name === currentUser.username) addMyMsg(m.text);
+ else addOtherMsg(m);
+ });
+});
+
+socket.on('chat_message', (d) => {
+ if (!currentUser) return;
+ if (d.name !== currentUser.username) addOtherMsg(d);
+});
+
+socket.on('system_message', (d) => {
+ addSystemMsg(d.text);
+});
+
+socket.on('viewer_count', (d) => {
+ document.getElementById('viewer-count').textContent = d.count + ' aktiva';
+});
+
+socket.on('auth_error', (d) => {
+ localStorage.removeItem(TOKEN_KEY);
+ doLogout();
+ document.getElementById('login-error').textContent = d.error || 'Sessionen har löpt ut';
+ showTab('login');
+});
+
+// Re-join after reconnect
+socket.on('connect', () => {
+ if (currentUser) socket.emit('join', {token: currentUser.token});
+});
+
+// ---- Chat input ----
+const chatLog = document.getElementById('chat-log');
+const msgInput = document.getElementById('msg-input');
+const sendBtn = document.getElementById('send-btn');
+
+msgInput.addEventListener('input', () => {
+ sendBtn.disabled = msgInput.value.trim().length === 0;
+ msgInput.style.height = '';
+ msgInput.style.height = Math.min(msgInput.scrollHeight, 96) + 'px';
+});
+
+msgInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ if (!sendBtn.disabled) sendBtn.click();
+ }
+});
+
+sendBtn.addEventListener('click', () => {
+ const text = msgInput.value.trim();
+ if (!text || !currentUser) return;
+ socket.emit('chat_message', {text});
+ addMyMsg(text);
+ msgInput.value = '';
+ msgInput.style.height = '';
+ sendBtn.disabled = true;
+});
+
+// ---- Message renderers ----
+function myAvatarHtml() {
+ if (currentUser.avatar_url) return `<img src="${currentUser.avatar_url}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
+ return escHtml(currentUser.emoji);
+}
+
+function addMyMsg(text) {
+ const div = document.createElement('div');
+ div.className = 'cm mine';
+ div.innerHTML = `
+ <div class="cm-avatar" style="background:#2563eb22;color:#60a5fa">${myAvatarHtml()}</div>
+ <div class="cm-bubble">${escHtml(text)}</div>`;
+ chatLog.appendChild(div);
+ chatLog.scrollTop = chatLog.scrollHeight;
+}
+
+function otherAvatarHtml(d) {
+ if (d.avatar_url) return `<img src="${d.avatar_url}" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`;
+ return escHtml(d.emoji || d.name.charAt(0));
+}
+
+function addOtherMsg(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>
+ <div class="cm-bubble">${escHtml(d.text)}</div>
+ </div>`;
+ chatLog.appendChild(div);
+ chatLog.scrollTop = chatLog.scrollHeight;
+}
+
+function addSystemMsg(text) {
+ const div = document.createElement('div');
+ div.className = 'cm system';
+ div.innerHTML = `<div class="cm-bubble">${escHtml(text)}</div>`;
+ chatLog.appendChild(div);
+ chatLog.scrollTop = chatLog.scrollHeight;
+}
+
+window.addEventListener('beforeunload', () => socket.disconnect());
+</script>
+</body>
+</html>
diff --git a/kiosk/templates/display.html b/kiosk/templates/display.html
new file mode 100644
index 0000000..2a03622
--- /dev/null
+++ b/kiosk/templates/display.html
@@ -0,0 +1,197 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Kiosk Display</title>
+ <style>
+ * { margin: 0; padding: 0; box-sizing: border-box; }
+ html, body { width: 100%; height: 100%; overflow: hidden; background: #000; cursor: none; }
+ * { cursor: none !important; }
+ .layer { position: fixed; inset: 0; display: none; }
+ #layer-iframe iframe { width: 100%; height: 100%; border: none; }
+ #layer-video { background: #000; display: flex; align-items: center; justify-content: center; }
+ #layer-video video { width: 100%; height: 100%; object-fit: contain; }
+ #layer-image { background: #000; display: flex; align-items: center; justify-content: center; }
+ #layer-image img { width: 100%; height: 100%; object-fit: contain; }
+ #layer-idle { background: #111; display: flex; align-items: center; justify-content: center; }
+ #layer-idle .ip { color: #fff; font-family: monospace; font-size: 4vw; opacity: 0.6; }
+
+ /* Ticker overlay */
+ #ticker {
+ display: none;
+ position: fixed;
+ left: 0; right: 0;
+ z-index: 9999;
+ overflow: hidden;
+ pointer-events: none;
+ }
+ #ticker.top { top: 0; }
+ #ticker.bottom { bottom: 0; }
+ #ticker-text {
+ display: inline-block;
+ white-space: nowrap;
+ }
+ @keyframes ticker-scroll {
+ from { transform: translateX(100vw); }
+ to { transform: translateX(-100%); }
+ }
+ /* === Kiosk chat overlay === */
+ #kiosk-chat-overlay {
+ font-family: 'Segoe UI', system-ui, sans-serif;
+ }
+ #kc-msgs {
+ position: absolute;
+ bottom: 0; left: 0; right: 0;
+ display: flex;
+ flex-direction: column;
+ gap: .55rem;
+ padding: .75rem;
+ }
+ .kc-msg {
+ display: flex;
+ align-items: flex-start;
+ gap: .55rem;
+ animation: kc-in .25s ease;
+ }
+ .kc-avatar {
+ width: 2rem; height: 2rem;
+ border-radius: 50%;
+ display: flex; align-items: center; justify-content: center;
+ font-size: 1rem;
+ flex-shrink: 0;
+ margin-top: .1rem;
+ }
+ .kc-body { flex: 1; min-width: 0; }
+ .kc-name {
+ font-size: .78rem;
+ font-weight: 700;
+ margin-bottom: .15rem;
+ }
+ .kc-text {
+ font-size: 1.05rem;
+ line-height: 1.45;
+ word-break: break-word;
+ color: #e8e8e8;
+ text-shadow: 0 1px 4px #000c;
+ }
+ .kc-system {
+ font-size: .8rem;
+ color: #aaa;
+ font-style: italic;
+ padding: .05rem .25rem;
+ text-shadow: 0 1px 3px #000c;
+ }
+ @keyframes kc-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+ @keyframes ticker-scroll-seamless {
+ from { transform: translateX(0); }
+ to { transform: translateX(-50%); }
+ }
+ /* kiosk chat overlay header */
+ #kc-header {
+ display: flex;
+ align-items: center;
+ gap: .5rem;
+ padding: .55rem .75rem .45rem;
+ border-bottom: 1px solid #ffffff14;
+ flex-shrink: 0;
+ }
+ #kc-title {
+ font-size: .9rem;
+ font-weight: 700;
+ color: #fff;
+ letter-spacing: .02em;
+ }
+ #kc-count {
+ font-size: .72rem;
+ color: #888;
+ background: #1a1a1a;
+ padding: .15rem .5rem;
+ border-radius: 99px;
+ margin-right: auto;
+ }
+ #kc-qr {
+ width: 48px; height: 48px;
+ border-radius: 5px;
+ background: #fff;
+ padding: 2px;
+ flex-shrink: 0;
+ }
+ #kc-msgs-wrap {
+ flex: 1;
+ overflow: hidden;
+ position: relative;
+ }
+ .dot-live {
+ width: .5rem; height: .5rem;
+ background: #ef4444;
+ border-radius: 50%;
+ display: inline-block;
+ flex-shrink: 0;
+ animation: kc-pulse 1.4s infinite;
+ }
+ @keyframes kc-pulse {
+ 0%,100% { opacity: 1; }
+ 50% { opacity: .3; }
+ }
+ </style>
+</head>
+<body>
+ <div id="layer-iframe" class="layer">
+ <iframe id="frame" src="" frameborder="0" allow="autoplay; fullscreen; picture-in-picture"></iframe>
+ </div>
+
+ <div id="layer-video" class="layer">
+ <video id="video" autoplay></video>
+ </div>
+
+ <div id="layer-local" class="layer"></div>
+
+ <div id="layer-image" class="layer">
+ <img id="image" src="" alt="">
+ </div>
+
+ <!-- YouTube Livechat overlay -->
+ <div id="chat-overlay" style="display:none; position:fixed; top:0; z-index:9998; pointer-events:auto; transition:opacity .3s;">
+ <iframe id="chat-frame" src="" frameborder="0" style="width:100%;height:100%;border:none;"></iframe>
+ </div>
+
+ <!-- Kiosk Livechat overlay (inbyggd, ingen iframe) -->
+ <div id="kiosk-chat-overlay" style="display:none; position:fixed; top:0; z-index:9997; pointer-events:none; overflow:hidden; font-family:'Segoe UI',system-ui,sans-serif; flex-direction:column; background:#0f0f0fd0;">
+ <div id="kc-header">
+ <span class="dot-live"></span>
+ <span id="kc-title">Livechat</span>
+ <span id="kc-count">0 aktiva</span>
+ <img id="kc-qr" src="/api/chat-qr" alt="QR">
+ </div>
+ <div id="kc-msgs-wrap">
+ <div id="kc-msgs" style="position:absolute;bottom:0;left:0;right:0;display:flex;flex-direction:column;gap:.45rem;padding:.6rem .75rem;"></div>
+ </div>
+ </div>
+
+ <!-- Ticker overlay -->
+ <div id="ticker" class="bottom">
+ <span id="ticker-text"></span>
+ </div>
+
+ <div id="layer-idle" class="layer" style="display: block;">
+ <span class="ip">{{ local_ip }}:5000</span>
+ </div>
+
+ <!-- Clock overlay -->
+ <div id="clock-overlay" style="display:none; position:fixed; z-index:9990; pointer-events:none; flex-direction:column; align-items:center;">
+ <div id="clock-digital"></div>
+ <canvas id="clock-analog"></canvas>
+ <div id="clock-date"></div>
+ </div>
+
+ <!-- Söv-overlay — täcker allt -->
+ <div id="layer-sleep" style="display:none; position:fixed; inset:0; background:#000; z-index:99999;"></div>
+
+ <script src="/static/socket.io.min.js"></script>
+ <script src="/static/display.js"></script>
+</body>
+</html>
diff --git a/kiosk/templates/login.html b/kiosk/templates/login.html
new file mode 100644
index 0000000..6964b2c
--- /dev/null
+++ b/kiosk/templates/login.html
@@ -0,0 +1,36 @@
+<!DOCTYPE html>
+<html lang="sv">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Kiosk — Logga in</title>
+ <link rel="stylesheet" href="/static/style.css">
+ <style>
+ body { display: flex; align-items: center; justify-content: center; min-height: 100vh; }
+ .login-box { background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 8px; padding: 2rem; width: 100%; max-width: 340px; }
+ h2 { color: #fff; margin-bottom: 1.5rem; }
+ .field { display: flex; flex-direction: column; gap: .4rem; margin-bottom: 1rem; }
+ label { font-size: .8rem; color: #888; text-transform: uppercase; letter-spacing: .06em; }
+ input { width: 100%; }
+ button { width: 100%; margin-top: .5rem; padding: .6rem; }
+ .error { color: #f87171; font-size: .875rem; margin-bottom: 1rem; }
+ </style>
+</head>
+<body>
+ <div class="login-box">
+ <h2>Logga in</h2>
+ {% if error %}<p class="error">{{ error }}</p>{% endif %}
+ <form method="post">
+ <div class="field">
+ <label>Användarnamn</label>
+ <input type="text" name="username" autofocus autocomplete="username">
+ </div>
+ <div class="field">
+ <label>Lösenord</label>
+ <input type="password" name="password" autocomplete="current-password">
+ </div>
+ <button type="submit">Logga in</button>
+ </form>
+ </div>
+</body>
+</html>
diff --git a/start.sh b/start.sh
new file mode 100755
index 0000000..17cbfb1
--- /dev/null
+++ b/start.sh
@@ -0,0 +1,78 @@
+#!/bin/bash
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+KIOSK_DIR="$SCRIPT_DIR/kiosk"
+PID_FILE="/tmp/kiosk.pids"
+
+export KIOSK_USER="${KIOSK_USER:-mrfox}"
+export KIOSK_PASS="${KIOSK_PASS:-qweasdzxc}"
+export SECRET_KEY="${SECRET_KEY:-kiosk-hemlig-nyckel-byt-garna}"
+
+cleanup() {
+ echo "Stänger ner..."
+ kill "$FLASK_PID" 2>/dev/null
+ kill "$CAGE_PID" 2>/dev/null
+ rm -f "$PID_FILE"
+ wait 2>/dev/null
+ exit 0
+}
+trap cleanup SIGINT SIGTERM
+
+while true; do
+ # Läs konfiguration
+ KIOSK_AUDIO_PROFILE_INDEX=1
+ KIOSK_DISPLAY_OUTPUT="last"
+ [ -f "$SCRIPT_DIR/kiosk.conf" ] && source "$SCRIPT_DIR/kiosk.conf"
+ [[ "$KIOSK_DISPLAY_OUTPUT" != "first" && "$KIOSK_DISPLAY_OUTPUT" != "last" ]] && KIOSK_DISPLAY_OUTPUT="last"
+ if [ "$KIOSK_AUDIO_PROFILE_INDEX" != "1" ]; then
+ AUDIO_DEV=$(pw-dump 2>/dev/null | python3 -c "
+import json,sys
+for n in json.load(sys.stdin):
+ if n.get('type')=='PipeWire:Interface:Device':
+ p=n.get('info',{}).get('props',{})
+ if p.get('media.class')=='Audio/Device':
+ print(n['id']); break
+" 2>/dev/null)
+ [ -n "$AUDIO_DEV" ] && pw-cli s "$AUDIO_DEV" Profile "{ index: $KIOSK_AUDIO_PROFILE_INDEX }" 2>/dev/null && sleep 0.3
+ fi
+
+ echo "Startar Flask..."
+ cd "$KIOSK_DIR"
+ python3 app.py &
+ FLASK_PID=$!
+
+ echo "Väntar på Flask..."
+ until curl -sf -o /dev/null http://127.0.0.1:5000/display; do
+ sleep 1
+ kill -0 "$FLASK_PID" 2>/dev/null || break
+ done
+
+ kill -0 "$FLASK_PID" 2>/dev/null || { echo "Flask kraschade, startar om..."; sleep 2; continue; }
+
+ echo "Flask kör. Startar Cage + Chromium..."
+ cage -m "$KIOSK_DISPLAY_OUTPUT" -- chromium \
+ --kiosk \
+ --no-sandbox \
+ --disable-translate \
+ --disable-web-security \
+ --enable-features=VaapiVideoDecoder,VaapiVideoEncoder \
+ --disable-features=UseChromeOSDirectVideoDecoder \
+ --user-data-dir=/tmp/chromium-kiosk \
+ --disable-infobars \
+ --disable-session-crashed-bubble \
+ --disable-features=Translate \
+ --no-first-run \
+ --autoplay-policy=no-user-gesture-required \
+ --ozone-platform=wayland \
+ http://localhost:5000/display &
+ CAGE_PID=$!
+
+ echo "$FLASK_PID $CAGE_PID" > "$PID_FILE"
+ echo "Kiosk igång. Ctrl+C för att stänga."
+
+ wait "$CAGE_PID"
+ echo "Cage avslutades, startar om..."
+ kill "$FLASK_PID" 2>/dev/null
+ wait "$FLASK_PID" 2>/dev/null
+ sleep 2
+done
diff --git a/sudoers-kiosk b/sudoers-kiosk
new file mode 100644
index 0000000..4519f30
--- /dev/null
+++ b/sudoers-kiosk
@@ -0,0 +1 @@
+mrfox ALL=(ALL) NOPASSWD: /sbin/reboot, /usr/sbin/reboot