commit 7b2b540839b5a5bc97353ad8a458080b1e908ce2
Author: Jens Kristoffersson <jens.se@icloud.com>
AuthorDate: Sat Apr 4 16:28:36 2026 +0200
Commit: Jens Kristoffersson <jens.se@icloud.com>
CommitDate: Sat Apr 4 16:28:36 2026 +0200
uppdatering
---
README.md | 236 +++-
kiosk/addon_loader.py | 81 ++
kiosk/addons/__init__.py | 0
kiosk/addons/bluetooth/__init__.py | 16 +
kiosk/addons/bluetooth/routes.py | 198 +++
kiosk/addons/clock/__init__.py | 16 +
kiosk/addons/clock/routes.py | 61 +
kiosk/addons/filebrowser/__init__.py | 16 +
kiosk/addons/filebrowser/routes.py | 88 ++
kiosk/addons/livechat/__init__.py | 21 +
kiosk/addons/livechat/events.py | 84 ++
kiosk/addons/livechat/routes.py | 283 +++++
kiosk/addons/music/__init__.py | 16 +
kiosk/addons/music/routes.py | 159 +++
kiosk/addons/settings/__init__.py | 16 +
kiosk/addons/settings/routes.py | 273 ++++
kiosk/addons/sleep/__init__.py | 31 +
kiosk/addons/sleep/panel.html | 20 +
kiosk/addons/sleep/routes.py | 40 +
kiosk/addons/system/__init__.py | 16 +
kiosk/addons/system/routes.py | 33 +
kiosk/addons/ticker/__init__.py | 16 +
kiosk/addons/ticker/routes.py | 127 ++
kiosk/addons/wifi/__init__.py | 22 +
kiosk/addons/wifi/routes.py | 195 +++
kiosk/app.py | 1791 ++-------------------------
kiosk/auth.py | 15 +
kiosk/config.py | 16 +
kiosk/db.py | 28 +
kiosk/extensions.py | 7 +
kiosk/static/addons/.gitkeep | 0
kiosk/static/addons/sleep/admin.js | 25 +
kiosk/templates/addons/bluetooth/panel.html | 34 +
kiosk/templates/addons/clock/panel.html | 58 +
kiosk/templates/addons/livechat/panel.html | 86 ++
kiosk/templates/addons/music/panel.html | 67 +
kiosk/templates/addons/settings/panel.html | 48 +
kiosk/templates/addons/sleep/panel.html | 21 +
kiosk/templates/addons/system/panel.html | 9 +
kiosk/templates/addons/ticker/panel.html | 149 +++
kiosk/templates/addons/wifi/panel.html | 63 +
kiosk/templates/admin.html | 555 +--------
kiosk/templates/display.html | 3 +
kiosk/utils/__init__.py | 0
kiosk/utils/display.py | 147 +++
kiosk/utils/system.py | 13 +
kiosk/utils/youtube.py | 30 +
47 files changed, 2943 insertions(+), 2286 deletions(-)
diff --git a/README.md b/README.md
index 67c4cf7..2e7f12e 100644
--- a/README.md
+++ b/README.md
@@ -156,36 +156,231 @@ Dator startar
```
display/
-├── install.sh # Installationsscript
-├── start.sh # Gammal cage-baserad start (ej använd)
+├── install.sh # Installationsscript
└── 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
+ ├── app.py # Flask-app: core routes, WebSocket, startup
+ ├── auth.py # login_required-dekoratör
+ ├── config.py # Delade konstanter (ALLOWED_ROOTS, UPLOAD_DIR m.m.)
+ ├── db.py # SQLite-helpers + AddonDB
+ ├── extensions.py # Delad socketio-referens (undviker cirkulär import)
+ ├── addon_loader.py # Addon-discovery och Blueprint-registrering
+ ├── scheduler.py # Schemarotation (APScheduler)
+ ├── start.sh # Startar Flask (sätter env-vars)
+ ├── kiosk-launcher.sh # Startar Flask + sway, genererar output-config
+ ├── sway.conf # Sway-konfiguration
+ ├── 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
+ ├── addons/ # Addon-paket (ett paket per funktion)
+ │ ├── ticker/
+ │ ├── clock/
+ │ ├── music/
+ │ ├── livechat/
+ │ ├── sleep/
+ │ ├── wifi/
+ │ ├── bluetooth/
+ │ ├── filebrowser/
+ │ ├── settings/
+ │ └── system/
+ ├── utils/
+ │ ├── display.py # sway_env, apply_display_mode, apply_saved_audio
+ │ ├── system.py # local_ip
+ │ └── youtube.py # yt_extract, playlist_cache
+ ├── uploads/ # Uppladdade mediefiler
├── static/
│ ├── admin.js
│ ├── display.js
│ ├── style.css
- │ └── socket.io.min.js # Laddas ner av install.sh
+ │ ├── socket.io.min.js
+ │ └── addons/ # Statiska filer per addon (overlay.js m.m.)
└── templates/
- ├── admin.html
+ ├── admin.html # Skaltemplate — inkluderar addon-paneler dynamiskt
├── display.html
├── login.html
├── chat_display.html
- └── chat_mobile.html
+ ├── chat_mobile.html
+ └── addons/ # HTML-fragment per addon-panel
+ ├── ticker/panel.html
+ ├── clock/panel.html
+ └── ...
```
---
+## Skapa ett addon
+
+Addons är vanliga Python-paket under `kiosk/addons/`. De autodiscoveras vid uppstart — ingen registrering behövs.
+
+### 1. Skapa paketmappen
+
+```
+kiosk/addons/mitt_addon/
+ __init__.py
+ routes.py
+```
+
+### 2. Deklarera manifestet i `__init__.py`
+
+```python
+ADDON = {
+ "name": "mitt_addon", # måste matcha mappnamnet
+ "label": "Mitt Addon", # visas i adminpanelen
+ "version": "1.0",
+ "order": 10, # sorteringsordning i adminpanelen
+ "has_panel": True, # True om panel.html finns
+ "has_overlay": False, # True om static/addons/<name>/overlay.js finns
+ "has_socketio": False, # True om register_socketio() finns
+ "state_prefix": "mitt_addon_", # prefix för alla DB-nycklar
+ "default_enabled": True,
+}
+
+def create_blueprint():
+ from .routes import bp
+ return bp
+
+# Behövs bara om has_socketio = True:
+def register_socketio(socketio):
+ from .events import MittNamespace
+ socketio.on_namespace(MittNamespace('/mitt_addon'))
+```
+
+### 3. Definiera routes i `routes.py`
+
+```python
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+from auth import login_required
+
+bp = Blueprint('mitt_addon', __name__, url_prefix='/api/mitt_addon')
+
+@bp.route('/settings', methods=['GET'])
+def get_settings():
+ state = db.get_state()
+ return jsonify({
+ 'enabled': state.get('mitt_addon_enabled', '0') == '1',
+ 'value': state.get('mitt_addon_value', ''),
+ })
+
+@bp.route('/settings', methods=['POST'])
+@login_required
+def set_settings():
+ data = request.get_json() or {}
+ enabled = bool(data.get('enabled', False))
+ value = str(data.get('value', ''))
+ db.set_state('mitt_addon_enabled', '1' if enabled else '0')
+ db.set_state('mitt_addon_value', value)
+ extensions.socketio.emit('mitt_addon_update', {'enabled': enabled, 'value': value})
+ return '', 204
+```
+
+**Tips — `AddonDB` för namnrymdsavgränsad DB-åtkomst:**
+
+```python
+from db import AddonDB
+adb = AddonDB('mitt_addon_') # prefixar alla nycklar automatiskt
+
+adb.set('enabled', '1') # lagras som "mitt_addon_enabled"
+adb.get('value', 'default') # läser "mitt_addon_value"
+adb.get_all() # {'enabled': '1', 'value': '...'} (utan prefix)
+```
+
+### 4. Adminpanel — `templates/addons/mitt_addon/panel.html`
+
+HTML-fragmentet inkluderas automatiskt i `admin.html` om `has_panel = True`. Det behöver ingen `<html>`/`<body>` — bara innehållet:
+
+```html
+<section>
+ <h2>Mitt Addon</h2>
+ <div class="prop-grid">
+ <label>Aktivera</label>
+ <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+ <input type="checkbox" id="mitt-addon-enabled">
+ Aktivera funktionen
+ </label>
+
+ <label>Värde</label>
+ <input type="text" id="mitt-addon-value" placeholder="...">
+ </div>
+ <div class="row" style="margin-top:.75rem">
+ <button onclick="sparaMittAddon()">Spara</button>
+ <span id="mitt-addon-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
+</section>
+```
+
+### 5. Display-overlay — `static/addons/mitt_addon/overlay.js` *(valfritt)*
+
+Om `has_overlay = True` laddas skriptet automatiskt på display-sidan. Skriptet skapar sina egna DOM-element och lyssnar på SocketIO-events:
+
+```javascript
+(function () {
+ const el = document.createElement('div');
+ el.id = 'mitt-addon-overlay';
+ el.style.cssText = 'display:none;position:fixed;z-index:9000;pointer-events:none;';
+ document.body.appendChild(el);
+
+ const socket = window._kioskSocket; // definieras i display.js
+
+ socket.on('mitt_addon_update', (data) => {
+ el.style.display = data.enabled ? 'block' : 'none';
+ el.textContent = data.value;
+ });
+})();
+```
+
+### 6. SocketIO-events — `events.py` *(valfritt)*
+
+Behövs bara om addons behöver lyssna på WebSocket-events (inte bara sända):
+
+```python
+from flask import request
+from flask_socketio import Namespace
+import extensions
+
+class MittNamespace(Namespace):
+ def on_connect(self):
+ extensions.socketio.emit('mitt_status', {'ok': True}, to=request.sid,
+ namespace='/mitt_addon')
+
+ def on_mitt_event(self, data):
+ # hantera inkommande event
+ extensions.socketio.emit('mitt_svar', {'echo': data}, namespace='/mitt_addon')
+```
+
+### Referens — ADDON-manifest
+
+| Fält | Typ | Beskrivning |
+|---|---|---|
+| `name` | str | Unikt ID — måste matcha mappnamnet |
+| `label` | str | Visningsnamn i adminpanel |
+| `version` | str | Fri versionssträng |
+| `order` | int | Sorteringsordning för panelen (lägre = högre upp) |
+| `has_panel` | bool | `True` om `templates/addons/<name>/panel.html` finns |
+| `has_overlay` | bool | `True` om `static/addons/<name>/overlay.js` finns |
+| `has_socketio` | bool | `True` om `register_socketio()` är definierad i `__init__.py` |
+| `state_prefix` | str | Prefix för alla nycklar som addons sparar i DB |
+| `default_enabled` | bool | Om addons är aktiverat första gången (kan ändras i admin) |
+
+### Aktivera / inaktivera ett addon
+
+Varje addon kan slås av och på via API (kräver admin-inloggning):
+
+```bash
+# Inaktivera
+curl -X POST http://localhost:5000/api/addons/mitt_addon/toggle \
+ -H "Cookie: session=..."
+
+# Lista status för alla addons
+curl http://localhost:5000/api/addons -H "Cookie: session=..."
+```
+
+Ändringen sparas i databasen och träder i kraft vid nästa omstart.
+
+---
+
## Konfiguration
### kiosk.conf
@@ -212,6 +407,7 @@ export SECRET_KEY=en-lång-hemlig-sträng
| Komponent | Teknologi |
|---|---|
| Backend | Python 3, Flask, Flask-SocketIO, eventlet |
+| Addon-system | Flask Blueprints + `pkgutil.iter_modules()` |
| Schemaläggare | APScheduler |
| Databas | SQLite |
| Frontend | Vanilla JS, Socket.IO |
@@ -252,3 +448,9 @@ Kontrollera att `seat seat0 hide_cursor 3000` finns i `sway.conf` (läggs dit av
**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.
+
+**Ett addon laddas inte:**
+Kontrollera att mappnamnet matchar `ADDON["name"]` och att `__init__.py` innehåller ett `ADDON`-dict. Addons loggas vid uppstart:
+```
+[addon_loader] Laddade addon: mitt_addon (Mitt Addon)
+```
diff --git a/kiosk/addon_loader.py b/kiosk/addon_loader.py
new file mode 100644
index 0000000..2a5aacf
--- /dev/null
+++ b/kiosk/addon_loader.py
@@ -0,0 +1,81 @@
+"""
+Addon-loader för kiosk.
+
+Skannar kiosk/addons/ efter underpaket med ett ADDON-manifest (dict i __init__.py).
+Registrerar aktiverade addons som Flask-Blueprints och SocketIO-handlers vid uppstart.
+"""
+
+import pkgutil
+import importlib
+import db
+
+_loaded_addons = [] # lista av ADDON-dicts i laddningsordning
+
+
+def discover_and_load(app, socketio):
+ """
+ Skanna addons/-paketet och ladda aktiverade addons.
+ Anropas efter att app och socketio skapats, innan socketio.run().
+ """
+ import addons # kiosk/addons/__init__.py
+
+ for finder, name, is_pkg in pkgutil.iter_modules(addons.__path__):
+ if not is_pkg:
+ continue
+
+ try:
+ mod = importlib.import_module(f'addons.{name}')
+ except Exception as e:
+ print(f'[addon_loader] Kunde inte importera addon "{name}": {e}')
+ continue
+
+ manifest = getattr(mod, 'ADDON', None)
+ if not manifest:
+ continue
+
+ # Kolla aktiverat i DB; faller tillbaka på default_enabled
+ state_key = f'addon_{name}_enabled'
+ db_val = db.get_state().get(state_key)
+ if db_val is None:
+ enabled = manifest.get('default_enabled', True)
+ else:
+ enabled = db_val == '1'
+
+ if not enabled:
+ print(f'[addon_loader] Addon "{name}" är inaktiverat, hoppar över.')
+ continue
+
+ # Registrera Flask Blueprint
+ if hasattr(mod, 'create_blueprint'):
+ try:
+ bp = mod.create_blueprint()
+ app.register_blueprint(bp)
+ except Exception as e:
+ print(f'[addon_loader] Blueprint-fel för addon "{name}": {e}')
+ continue
+
+ # Registrera SocketIO-handlers
+ if manifest.get('has_socketio') and hasattr(mod, 'register_socketio'):
+ try:
+ mod.register_socketio(socketio)
+ except Exception as e:
+ print(f'[addon_loader] SocketIO-fel för addon "{name}": {e}')
+
+ _loaded_addons.append(manifest)
+ print(f'[addon_loader] Laddade addon: {name} ({manifest.get("label", name)})')
+
+ _loaded_addons.sort(key=lambda m: m.get('order', 99))
+
+
+def get_loaded_addons():
+ """Returnerar en kopia av listan med laddade addon-manifest."""
+ return list(_loaded_addons)
+
+
+def get_all_addon_names():
+ """
+ Returnerar alla addon-namn som finns i addons/-mappen
+ (både aktiverade och inaktiverade).
+ """
+ import addons
+ return [name for _, name, is_pkg in pkgutil.iter_modules(addons.__path__) if is_pkg]
diff --git a/kiosk/addons/__init__.py b/kiosk/addons/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/kiosk/addons/bluetooth/__init__.py b/kiosk/addons/bluetooth/__init__.py
new file mode 100644
index 0000000..9e2347e
--- /dev/null
+++ b/kiosk/addons/bluetooth/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+ "name": "bluetooth",
+ "label": "Bluetooth",
+ "version": "1.0",
+ "order": 10,
+ "has_overlay": False,
+ "has_panel": True,
+ "has_socketio": False,
+ "state_prefix": "bt_",
+ "default_enabled": True,
+}
+
+
+def create_blueprint():
+ from .routes import bp
+ return bp
diff --git a/kiosk/addons/bluetooth/routes.py b/kiosk/addons/bluetooth/routes.py
new file mode 100644
index 0000000..2c720ee
--- /dev/null
+++ b/kiosk/addons/bluetooth/routes.py
@@ -0,0 +1,198 @@
+import re
+import subprocess
+import threading
+import time
+from flask import Blueprint, jsonify, request, abort
+from auth import login_required
+
+bp = Blueprint('bluetooth', __name__, url_prefix='/api/bluetooth')
+
+_BT_ADDR_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
+
+_bt_scanning = False
+_bt_scan_lock = threading.Lock()
+_bt_scan_proc = None
+
+
+def _bt_run(*args, timeout=15):
+ cmd = ['bluetoothctl'] + list(args)
+ try:
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+ return r.returncode, r.stdout.strip()
+ except subprocess.TimeoutExpired:
+ return 1, 'timeout'
+ except Exception as e:
+ return 1, str(e)
+
+
+def _bt_status():
+ rc, out = _bt_run('show')
+ powered = False
+ name = ''
+ addr = ''
+ discovering = False
+ for line in out.splitlines():
+ s = line.strip()
+ if s.startswith('Powered:'): powered = 'yes' in s
+ elif s.startswith('Name:'): name = s.split(':', 1)[1].strip()
+ elif s.startswith('Discovering:'): discovering = 'yes' in s
+ m = re.search(r'([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', out.split('\n')[0] if out else '')
+ if m:
+ addr = m.group(0)
+ return {
+ 'powered': powered,
+ 'name': name,
+ 'address': addr,
+ 'discovering': discovering or _bt_scanning,
+ 'available': rc == 0,
+ }
+
+
+def _bt_device_info(addr):
+ rc, info = _bt_run('info', addr)
+ connected = paired = trusted = False
+ dev_name = ''
+ dev_type = ''
+ for line in info.splitlines():
+ s = line.strip()
+ if s.startswith('Name:'): dev_name = s.split(':', 1)[1].strip()
+ elif s.startswith('Connected:'): connected = 'yes' in s
+ elif s.startswith('Paired:'): paired = 'yes' in s
+ elif s.startswith('Trusted:'): trusted = 'yes' in s
+ elif s.startswith('Icon:'):
+ icon = s.split(':', 1)[1].strip()
+ if any(k in icon for k in ('audio', 'headphone', 'headset', 'speaker')):
+ dev_type = 'audio'
+ elif 'phone' in icon:
+ dev_type = 'phone'
+ elif 'input' in icon:
+ dev_type = 'input'
+ else:
+ dev_type = icon
+ return {
+ 'address': addr, 'name': dev_name,
+ 'connected': connected, 'paired': paired, 'trusted': trusted,
+ 'type': dev_type,
+ }
+
+
+def _bt_devices():
+ rc, out = _bt_run('devices')
+ result = []
+ for line in out.splitlines():
+ m = re.match(r'Device\s+([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\s+(.*)', line)
+ if m:
+ addr = m.group(1)
+ name = m.group(2).strip()
+ info = _bt_device_info(addr)
+ if not info['name']:
+ info['name'] = name
+ result.append(info)
+ return result
+
+
+def _bt_scan_worker(duration=15):
+ global _bt_scanning, _bt_scan_proc
+ try:
+ _bt_scan_proc = subprocess.Popen(
+ ['bluetoothctl', 'scan', 'on'],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ )
+ time.sleep(duration)
+ finally:
+ try:
+ if _bt_scan_proc:
+ _bt_scan_proc.terminate()
+ _bt_scan_proc = None
+ except Exception:
+ pass
+ _bt_run('scan', 'off', timeout=5)
+ with _bt_scan_lock:
+ _bt_scanning = False
+
+
+@bp.route('/status')
+@login_required
+def api_bt_status():
+ return jsonify(_bt_status())
+
+
+@bp.route('/devices')
+@login_required
+def api_bt_devices():
+ return jsonify({'devices': _bt_devices(), 'scanning': _bt_scanning})
+
+
+@bp.route('/scan', methods=['POST'])
+@login_required
+def api_bt_scan():
+ global _bt_scanning
+ with _bt_scan_lock:
+ if _bt_scanning:
+ return jsonify({'ok': True, 'scanning': True})
+ _bt_scanning = True
+ t = threading.Thread(target=_bt_scan_worker, args=(15,), daemon=True)
+ t.start()
+ return jsonify({'ok': True, 'scanning': True})
+
+
+@bp.route('/pair', methods=['POST'])
+@login_required
+def api_bt_pair():
+ data = request.get_json()
+ if not data or not data.get('address'):
+ abort(400)
+ addr = str(data['address'])
+ if not _BT_ADDR_RE.match(addr):
+ abort(400)
+ _bt_run('trust', addr, timeout=5)
+ rc, out = _bt_run('pair', addr, timeout=30)
+ ok = rc == 0 or 'already paired' in out.lower() or 'already exists' in out.lower()
+ if ok:
+ _bt_run('connect', addr, timeout=15)
+ return jsonify({'ok': ok, 'message': '' if ok else out.split('\n')[-1]})
+
+
+@bp.route('/connect', methods=['POST'])
+@login_required
+def api_bt_connect():
+ data = request.get_json()
+ if not data or not data.get('address'):
+ abort(400)
+ addr = str(data['address'])
+ if not _BT_ADDR_RE.match(addr):
+ abort(400)
+ rc, out = _bt_run('connect', addr, timeout=15)
+ ok = rc == 0 or 'connected' in out.lower()
+ return jsonify({'ok': ok, 'message': '' if ok else out.split('\n')[-1]})
+
+
+@bp.route('/disconnect', methods=['POST'])
+@login_required
+def api_bt_disconnect():
+ data = request.get_json()
+ if not data or not data.get('address'):
+ abort(400)
+ addr = str(data['address'])
+ if not _BT_ADDR_RE.match(addr):
+ abort(400)
+ _bt_run('disconnect', addr, timeout=10)
+ return '', 204
+
+
+@bp.route('/device/<path:address>', methods=['DELETE'])
+@login_required
+def api_bt_remove(address):
+ if not _BT_ADDR_RE.match(address):
+ abort(400)
+ _bt_run('remove', address, timeout=10)
+ return '', 204
+
+
+@bp.route('/power', methods=['POST'])
+@login_required
+def api_bt_power():
+ data = request.get_json() or {}
+ on = bool(data.get('on', True))
+ _bt_run('power', 'on' if on else 'off', timeout=5)
+ return jsonify(_bt_status())
diff --git a/kiosk/addons/clock/__init__.py b/kiosk/addons/clock/__init__.py
new file mode 100644
index 0000000..b686f5c
--- /dev/null
+++ b/kiosk/addons/clock/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+ "name": "clock",
+ "label": "Klocka",
+ "version": "1.0",
+ "order": 3,
+ "has_overlay": False,
+ "has_panel": True,
+ "has_socketio": False,
+ "state_prefix": "clock_",
+ "default_enabled": True,
+}
+
+
+def create_blueprint():
+ from .routes import bp
+ return bp
diff --git a/kiosk/addons/clock/routes.py b/kiosk/addons/clock/routes.py
new file mode 100644
index 0000000..e4fcd0d
--- /dev/null
+++ b/kiosk/addons/clock/routes.py
@@ -0,0 +1,61 @@
+import re
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+from auth import login_required
+
+bp = Blueprint('clock', __name__, url_prefix='/api')
+
+_HEX_COLOR = re.compile(r'^#[0-9a-fA-F]{6}$')
+
+
+@bp.route('/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'),
+ })
+
+
+@bp.route('/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,
+ }
+ extensions.socketio.emit('clock_settings', payload)
+ return '', 204
diff --git a/kiosk/addons/filebrowser/__init__.py b/kiosk/addons/filebrowser/__init__.py
new file mode 100644
index 0000000..26296c3
--- /dev/null
+++ b/kiosk/addons/filebrowser/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+ "name": "filebrowser",
+ "label": "Filhanterare",
+ "version": "1.0",
+ "order": 7,
+ "has_overlay": False,
+ "has_panel": False,
+ "has_socketio": False,
+ "state_prefix": "filebrowser_",
+ "default_enabled": True,
+}
+
+
+def create_blueprint():
+ from .routes import bp
+ return bp
diff --git a/kiosk/addons/filebrowser/routes.py b/kiosk/addons/filebrowser/routes.py
new file mode 100644
index 0000000..d07aab7
--- /dev/null
+++ b/kiosk/addons/filebrowser/routes.py
@@ -0,0 +1,88 @@
+import shutil
+import subprocess
+from pathlib import Path
+from werkzeug.utils import secure_filename
+from flask import Blueprint, jsonify, request, abort, send_file
+from auth import login_required
+from config import ALLOWED_ROOTS, MEDIA_EXTENSIONS, UPLOAD_DIR
+
+bp = Blueprint('filebrowser', __name__)
+
+
+@bp.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)
+
+
+@bp.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
+
+ return jsonify({'path': str(dest), 'url': '/files?path=' + str(dest)})
+
+
+@bp.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})
diff --git a/kiosk/addons/livechat/__init__.py b/kiosk/addons/livechat/__init__.py
new file mode 100644
index 0000000..080b0be
--- /dev/null
+++ b/kiosk/addons/livechat/__init__.py
@@ -0,0 +1,21 @@
+ADDON = {
+ "name": "livechat",
+ "label": "Livechat",
+ "version": "1.0",
+ "order": 5,
+ "has_overlay": False,
+ "has_panel": True,
+ "has_socketio": True,
+ "state_prefix": "chat_",
+ "default_enabled": True,
+}
+
+
+def create_blueprint():
+ from .routes import bp
+ return bp
+
+
+def register_socketio(socketio):
+ from .events import ChatNamespace
+ socketio.on_namespace(ChatNamespace('/chat'))
diff --git a/kiosk/addons/livechat/events.py b/kiosk/addons/livechat/events.py
new file mode 100644
index 0000000..6860e66
--- /dev/null
+++ b/kiosk/addons/livechat/events.py
@@ -0,0 +1,84 @@
+from flask import request
+from flask_socketio import Namespace
+import extensions
+from .routes import chat_tokens
+
+
+class ChatNamespace(Namespace):
+ _users = {} # sid -> {name, color, emoji, avatar_url}
+ _messages = [] # recent message history (last 50)
+
+ def on_connect(self):
+ sid = request.sid
+ if self._messages:
+ extensions.socketio.emit('chat_history', self._messages, to=sid, namespace='/chat')
+ extensions.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:
+ extensions.socketio.emit('chat_history', self._messages, to=request.sid, namespace='/chat')
+ extensions.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)
+ extensions.socketio.emit('chat_message', msg, namespace='/chat')
+
+ def on_play_suggestion(self, data):
+ user = self._users.get(request.sid)
+ if not user:
+ return
+ url = str(data.get('url', '')).strip()[:500]
+ if not url or not url.startswith(('http://', 'https://')):
+ return
+ msg = {
+ 'name': user['name'],
+ 'color': user['color'],
+ 'emoji': user.get('emoji'),
+ 'avatar_url': user.get('avatar_url'),
+ 'text': url,
+ 'kind': 'play_suggestion',
+ }
+ self._messages.append(msg)
+ if len(self._messages) > 50:
+ self._messages.pop(0)
+ # Skicka till alla i /chat-namespacet (display + mobil)
+ extensions.socketio.emit('play_suggestion', msg, namespace='/chat')
+ # Notifiera admin-panelen via huvud-namespacet
+ extensions.socketio.emit('play_suggestion', {
+ 'name': user['name'], 'color': user['color'], 'url': url,
+ })
+
+ def on_disconnect(self):
+ user = self._users.pop(request.sid, None)
+ if user:
+ extensions.socketio.emit('viewer_count', {'count': len(self._users)}, namespace='/chat')
diff --git a/kiosk/addons/livechat/routes.py b/kiosk/addons/livechat/routes.py
new file mode 100644
index 0000000..63a2b5a
--- /dev/null
+++ b/kiosk/addons/livechat/routes.py
@@ -0,0 +1,283 @@
+import re
+import json
+import uuid
+from pathlib import Path
+from werkzeug.utils import secure_filename
+from werkzeug.security import generate_password_hash, check_password_hash
+from flask import Blueprint, jsonify, request, abort, send_file, render_template
+import db
+import extensions
+from auth import login_required
+from utils.system import local_ip
+
+bp = Blueprint('livechat', __name__)
+
+_HEX_COLOR = re.compile(r'^#[0-9a-fA-F]{6}$')
+
+CHAT_USERS_FILE = Path(__file__).parent.parent.parent / 'chat_users.json'
+CHAT_AVATARS_DIR = Path(__file__).parent.parent.parent / 'static' / 'chat_avatars'
+CHAT_IMAGES_DIR = Path(__file__).parent.parent.parent / 'static' / 'chat_images'
+
+CHAT_AVATARS_DIR.mkdir(exist_ok=True)
+CHAT_IMAGES_DIR.mkdir(exist_ok=True)
+
+_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.webp']
+_CHAT_IMG_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
+
+_CHAT_COLORS = [
+ '#f87171', '#fb923c', '#fbbf24', '#a3e635',
+ '#34d399', '#38bdf8', '#818cf8', '#e879f9',
+ '#f472b6', '#94a3b8',
+]
+
+# Shared in-memory token store: token -> {username, emoji, color, avatar_url}
+# Imported by events.py so both modules share the same dict.
+chat_tokens = {}
+
+
+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))
+
+
+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
+
+
+def _restore_chat_tokens():
+ users = _load_chat_users()
+ for username, u in users.items():
+ token = u.get('token')
+ if token:
+ chat_tokens[token] = {
+ 'username': username,
+ 'emoji': u.get('emoji', '😀'),
+ 'color': u.get('color', '#94a3b8'),
+ 'avatar_url': get_avatar_url(username),
+ }
+
+
+_restore_chat_tokens()
+
+
+# --- Page routes ---
+
+@bp.route('/chat-display')
+def chat_display():
+ return render_template('chat_display.html', local_ip=local_ip())
+
+
+@bp.route('/chat-mobile')
+def chat_mobile():
+ return render_template('chat_mobile.html')
+
+
+@bp.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)
+
+
+# --- Auth routes ---
+
+@bp.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)]
+ token = str(uuid.uuid4())
+ users[username] = {
+ 'password_hash': generate_password_hash(password),
+ 'emoji': emoji,
+ 'color': color,
+ 'token': token,
+ }
+ _save_chat_users(users)
+ 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})
+
+
+@bp.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}
+ users[key]['token'] = token
+ _save_chat_users(users)
+ return jsonify({'token': token, 'username': key, 'emoji': u['emoji'], 'color': u['color'], 'avatar_url': avatar_url})
+
+
+@bp.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
+ if not user.get('avatar_url'):
+ user['avatar_url'] = get_avatar_url(user['username'])
+ return jsonify({'username': user['username'], 'emoji': user['emoji'], 'color': user['color'], 'avatar_url': user.get('avatar_url')})
+
+
+# --- Avatar & image routes ---
+
+@bp.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)
+
+
+@bp.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})
+
+
+@bp.route('/api/chat/image', methods=['POST'])
+def upload_chat_image():
+ token = request.form.get('token', '')
+ user_info = chat_tokens.get(token)
+ if not user_info:
+ return jsonify({'error': 'Ogiltig token'}), 401
+ f = request.files.get('image')
+ if not f or not f.filename:
+ return jsonify({'error': 'Ingen fil'}), 400
+ ext = Path(secure_filename(f.filename)).suffix.lower()
+ if ext not in _CHAT_IMG_EXTS:
+ return jsonify({'error': 'Använd jpg, png, gif eller webp'}), 400
+ filename = uuid.uuid4().hex + ext
+ f.save(str(CHAT_IMAGES_DIR / filename))
+ return jsonify({'url': f'/static/chat_images/{filename}'})
+
+
+# --- Chat overlay settings (YouTube chat + kiosk chat display) ---
+
+@bp.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')),
+ })
+
+
+@bp.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,
+ }
+ extensions.socketio.emit('chat_settings', payload)
+ return '', 204
diff --git a/kiosk/addons/music/__init__.py b/kiosk/addons/music/__init__.py
new file mode 100644
index 0000000..03b6a81
--- /dev/null
+++ b/kiosk/addons/music/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+ "name": "music",
+ "label": "Musik",
+ "version": "1.0",
+ "order": 4,
+ "has_overlay": False,
+ "has_panel": True,
+ "has_socketio": False,
+ "state_prefix": "music_",
+ "default_enabled": True,
+}
+
+
+def create_blueprint():
+ from .routes import bp
+ return bp
diff --git a/kiosk/addons/music/routes.py b/kiosk/addons/music/routes.py
new file mode 100644
index 0000000..4bb8173
--- /dev/null
+++ b/kiosk/addons/music/routes.py
@@ -0,0 +1,159 @@
+import subprocess
+from pathlib import Path
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+from auth import login_required
+from config import ALLOWED_ROOTS
+from utils.youtube import yt_extract, playlist_cache as _yt_playlist_cache
+
+bp = Blueprint('music', __name__, url_prefix='/api')
+
+_MUSIC_EXTS = {'.mp3', '.flac', '.ogg', '.wav', '.m4a', '.aac'}
+_music_playlist_cache = []
+
+
+@bp.route('/music', methods=['GET'])
+def get_music():
+ state = db.get_state()
+ return jsonify({
+ 'enabled': state.get('music_enabled', '0') == '1',
+ 'source_type': state.get('music_source_type', 'local'),
+ 'source': state.get('music_source', ''),
+ 'volume': int(state.get('music_volume', '50')),
+ 'shuffle': state.get('music_shuffle', '0') == '1',
+ 'show_overlay': state.get('music_show_overlay', '1') == '1',
+ 'overlay_position': state.get('music_overlay_position', 'bottom-left'),
+ 'now_playing': {
+ 'title': state.get('music_now_title', ''),
+ 'artist': state.get('music_now_artist', ''),
+ },
+ })
+
+
+@bp.route('/music', methods=['POST'])
+@login_required
+def set_music():
+ data = request.get_json()
+ if not data:
+ abort(400)
+ enabled = bool(data.get('enabled', False))
+ source_type = 'youtube' if data.get('source_type') == 'youtube' else 'local'
+ source = str(data.get('source', ''))
+ volume = max(0, min(100, int(data.get('volume', 50))))
+ shuffle = bool(data.get('shuffle', False))
+ show_overlay = bool(data.get('show_overlay', True))
+ overlay_position = str(data.get('overlay_position', 'bottom-left'))
+ if overlay_position not in {'top-left', 'top-right', 'bottom-left', 'bottom-right'}:
+ overlay_position = 'bottom-left'
+
+ db.set_state('music_enabled', '1' if enabled else '0')
+ db.set_state('music_source_type', source_type)
+ db.set_state('music_source', source)
+ db.set_state('music_volume', str(volume))
+ db.set_state('music_shuffle', '1' if shuffle else '0')
+ db.set_state('music_show_overlay', '1' if show_overlay else '0')
+ db.set_state('music_overlay_position', overlay_position)
+
+ payload = {
+ 'enabled': enabled, 'source_type': source_type,
+ 'source': source, 'volume': volume, 'shuffle': shuffle,
+ 'show_overlay': show_overlay, 'overlay_position': overlay_position,
+ }
+ extensions.socketio.emit('music_settings', payload)
+ return '', 204
+
+
+@bp.route('/music/tracks', methods=['GET'])
+@login_required
+def get_music_tracks():
+ folder = request.args.get('path', '')
+ if not folder:
+ return jsonify([])
+ p = Path(folder)
+ try:
+ p = p.resolve()
+ if not any(str(p).startswith(str(r)) for r in ALLOWED_ROOTS):
+ abort(403)
+ except Exception:
+ abort(400)
+ if not p.is_dir():
+ return jsonify([])
+ tracks = sorted([
+ {'name': f.name, 'path': str(f)}
+ for f in p.iterdir()
+ if f.is_file() and f.suffix.lower() in _MUSIC_EXTS
+ ], key=lambda x: x['name'])
+ return jsonify(tracks)
+
+
+@bp.route('/music/extract', methods=['POST'])
+@login_required
+def extract_music_playlist():
+ """Extract video IDs for the music playlist and persist to DB."""
+ global _music_playlist_cache
+ data = request.get_json() or {}
+ url = str(data.get('url', '')).strip()
+ if not url:
+ return jsonify({'error': 'URL saknas'}), 400
+ try:
+ ids, _total_seconds = yt_extract(url)
+ except subprocess.TimeoutExpired:
+ return jsonify({'error': 'Timeout — spellistans hämtning tog för lång tid'}), 504
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+
+ _music_playlist_cache = ids
+ _yt_playlist_cache[url] = ids
+ db.set_state('music_playlist_ids', ','.join(ids))
+ return jsonify({'count': len(ids), 'ids': ids})
+
+
+@bp.route('/music/playlist', methods=['GET'])
+def get_music_playlist():
+ """Return the extracted playlist video IDs."""
+ global _music_playlist_cache
+ if not _music_playlist_cache:
+ stored = db.get_state().get('music_playlist_ids', '')
+ _music_playlist_cache = [v for v in stored.split(',') if v] if stored else []
+ return jsonify({'ids': _music_playlist_cache, 'count': len(_music_playlist_cache)})
+
+
+@bp.route('/music/nowplaying', methods=['POST'])
+def update_now_playing():
+ """Called by display client to broadcast now-playing state to admin."""
+ data = request.get_json() or {}
+ title = str(data.get('title', ''))[:200]
+ artist = str(data.get('artist', ''))[:200]
+ db.set_state('music_now_title', title)
+ db.set_state('music_now_artist', artist)
+ extensions.socketio.emit('now_playing', {'title': title, 'artist': artist})
+ return '', 204
+
+
+# --- YouTube playlist extraction (video & musik) ---
+
+@bp.route('/playlist/extract', methods=['POST'])
+@login_required
+def extract_playlist():
+ """Extract video IDs from any public YouTube playlist URL and cache by URL."""
+ data = request.get_json() or {}
+ url = str(data.get('url', '')).strip()
+ if not url:
+ return jsonify({'error': 'URL saknas'}), 400
+ try:
+ ids, total_seconds = yt_extract(url)
+ except subprocess.TimeoutExpired:
+ return jsonify({'error': 'Timeout'}), 504
+ except Exception as e:
+ return jsonify({'error': str(e)}), 500
+ _yt_playlist_cache[url] = ids
+ return jsonify({'count': len(ids), 'ids': ids, 'total_seconds': total_seconds})
+
+
+@bp.route('/playlist/ids', methods=['GET'])
+def get_playlist_ids():
+ """Return cached video IDs for a playlist URL."""
+ url = request.args.get('url', '').strip()
+ ids = _yt_playlist_cache.get(url, [])
+ return jsonify({'ids': ids, 'count': len(ids)})
diff --git a/kiosk/addons/settings/__init__.py b/kiosk/addons/settings/__init__.py
new file mode 100644
index 0000000..1abaa41
--- /dev/null
+++ b/kiosk/addons/settings/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+ "name": "settings",
+ "label": "Inställningar",
+ "version": "1.0",
+ "order": 8,
+ "has_overlay": False,
+ "has_panel": True,
+ "has_socketio": False,
+ "state_prefix": "settings_",
+ "default_enabled": True,
+}
+
+
+def create_blueprint():
+ from .routes import bp
+ return bp
diff --git a/kiosk/addons/settings/routes.py b/kiosk/addons/settings/routes.py
new file mode 100644
index 0000000..2728c1d
--- /dev/null
+++ b/kiosk/addons/settings/routes.py
@@ -0,0 +1,273 @@
+import os
+import re
+import shutil
+import subprocess
+from pathlib import Path
+import eventlet
+from flask import Blueprint, jsonify, request, abort
+import db
+from auth import login_required
+from utils.display import (
+ sway_env, audio_device_id, parse_profiles,
+ apply_saved_audio, apply_display_mode,
+)
+from utils.system import local_ip
+
+bp = Blueprint('settings', __name__, url_prefix='/api')
+
+
+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)
+ 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://{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
+
+
+@bp.route('/settings/apply_display', methods=['POST'])
+@login_required
+def apply_display_now():
+ 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)
+ apply_saved_audio()
+ return jsonify({'ok': bool(ok)})
+ except Exception as e:
+ return jsonify({'ok': False, 'error': str(e)}), 500
+
+
+@bp.route('/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([])
+
+
+@bp.route('/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: xrandr-läge (X11)
+ try:
+ if shutil.which('xrandr'):
+ out = ''
+ 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] = []
+ continue
+ if cur_out:
+ m2 = re.match(r"^\s+(\d+)x(\d+)\s+([0-9\.]+)\s*Hz?(.*)$", line)
+ if m2:
+ token = f"{m2.group(1)}x{m2.group(2)}@{m2.group(3)}"
+ if token not in modes_map[cur_out]:
+ modes_map[cur_out].append(token)
+ if '*' in (m2.group(4) or '') or 'current' in (m2.group(4) or ''):
+ modes_map.setdefault('__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']]
+ curmap = modes_map.get('__current__', {})
+ for o in outputs:
+ if o['name'] in curmap:
+ o['current_mode'] = curmap[o['name']]
+ except Exception:
+ pass
+
+ # Best-effort: wlr-randr-läge (Wayland)
+ 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()}')
+ for sock in sorted(Path(xdg_runtime).glob('wayland-[0-9]*')):
+ if sock.suffix != '.lock':
+ env['WAYLAND_DISPLAY'] = sock.name
+ break
+ out = subprocess.check_output(['wlr-randr'], timeout=5, env=env).decode(errors='ignore')
+ cur_out = None
+ modes_map = {}
+ for line in out.splitlines():
+ if not line.strip():
+ continue
+ if line[0] not in (' ', '\t'):
+ cur_out = line.strip().split()[0]
+ modes_map[cur_out] = []
+ continue
+ m = re.search(r"(\d+)x(\d+)\s+px,\s+([0-9\.]+)\s+Hz(?:.*(current))?", line)
+ if cur_out and m:
+ token = f"{m.group(1)}x{m.group(2)}@{m.group(3)}"
+ if token not in modes_map[cur_out]:
+ modes_map[cur_out].append(token)
+ if 'current' in line:
+ modes_map.setdefault('__current__', {})[cur_out] = token
+ current_map = modes_map.pop('__current__', {})
+ 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)
+
+
+@bp.route('/volume', methods=['GET'])
+@login_required
+def get_volume():
+ try:
+ out = subprocess.check_output(['wpctl', 'get-volume', '@DEFAULT_AUDIO_SINK@'], timeout=5).decode()
+ vol = round(float(out.split()[1]) * 100)
+ return jsonify({'volume': vol})
+ except Exception:
+ return jsonify({'volume': 100})
+
+
+@bp.route('/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
+
+
+@bp.route('/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'),
+ })
+
+
+@bp.route('/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__)).parent.parent / '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 '')
+ )
+ 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
+
+
+@bp.route('/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'
+ db.set_state('internal_display', val)
+ state = db.get_state()
+ conf = Path(os.path.dirname(__file__)).parent.parent / '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
diff --git a/kiosk/addons/sleep/__init__.py b/kiosk/addons/sleep/__init__.py
new file mode 100644
index 0000000..61f7d91
--- /dev/null
+++ b/kiosk/addons/sleep/__init__.py
@@ -0,0 +1,31 @@
+ADDON = {
+ "name": "sleep",
+ "label": "Sovläge",
+ "version": "1.0",
+ "order": 6,
+ "has_overlay": False,
+ "has_panel": True,
+ "has_socketio": False,
+ "state_prefix": "sleep_",
+ "default_enabled": True,
+}
+
+
+def create_blueprint():
+ from .routes import bp
+ return bp
+
+
+def is_sleep_active(start, end):
+ """Returnerar True om nuvarande tid faller inom sovfönstret."""
+ try:
+ import datetime
+ now = 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
+ # Hanterar fönster som sträcker sig över midnatt (t.ex. 22:00–07:00)
+ return (cur >= s or cur < e) if s > e else (s <= cur < e)
+ except Exception:
+ return False
diff --git a/kiosk/addons/sleep/panel.html b/kiosk/addons/sleep/panel.html
new file mode 100644
index 0000000..7b7aac1
--- /dev/null
+++ b/kiosk/addons/sleep/panel.html
@@ -0,0 +1,20 @@
+<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>
diff --git a/kiosk/addons/sleep/routes.py b/kiosk/addons/sleep/routes.py
new file mode 100644
index 0000000..9eae5f1
--- /dev/null
+++ b/kiosk/addons/sleep/routes.py
@@ -0,0 +1,40 @@
+import re
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+import scheduler as sched
+from auth import login_required
+from . import is_sleep_active
+
+bp = Blueprint('sleep', __name__, url_prefix='/api/sleep')
+
+
+@bp.route('', 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'),
+ })
+
+
+@bp.route('', 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'))
+ if not re.match(r'^\d{2}:\d{2}$', start): start = '22:00'
+ if not re.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()
+ active = enabled and is_sleep_active(start, end)
+ extensions.socketio.emit('sleep_toggle', {'active': active})
+ return '', 204
diff --git a/kiosk/addons/system/__init__.py b/kiosk/addons/system/__init__.py
new file mode 100644
index 0000000..d740e18
--- /dev/null
+++ b/kiosk/addons/system/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+ "name": "system",
+ "label": "System",
+ "version": "1.0",
+ "order": 11,
+ "has_overlay": False,
+ "has_panel": True,
+ "has_socketio": False,
+ "state_prefix": "system_",
+ "default_enabled": True,
+}
+
+
+def create_blueprint():
+ from .routes import bp
+ return bp
diff --git a/kiosk/addons/system/routes.py b/kiosk/addons/system/routes.py
new file mode 100644
index 0000000..0cd0e74
--- /dev/null
+++ b/kiosk/addons/system/routes.py
@@ -0,0 +1,33 @@
+import subprocess
+from flask import Blueprint, jsonify
+from auth import login_required
+from utils.display import sway_env
+
+bp = Blueprint('system', __name__, url_prefix='/api')
+
+
+@bp.route('/reboot', methods=['POST'])
+@login_required
+def reboot():
+ subprocess.Popen(['sudo', 'reboot'])
+ return '', 204
+
+
+@bp.route('/shutdown', methods=['POST'])
+@login_required
+def shutdown():
+ subprocess.Popen(['sudo', 'poweroff'])
+ return '', 204
+
+
+@bp.route('/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
diff --git a/kiosk/addons/ticker/__init__.py b/kiosk/addons/ticker/__init__.py
new file mode 100644
index 0000000..d6782c9
--- /dev/null
+++ b/kiosk/addons/ticker/__init__.py
@@ -0,0 +1,16 @@
+ADDON = {
+ "name": "ticker",
+ "label": "Löptext",
+ "version": "1.0",
+ "order": 2,
+ "has_overlay": False,
+ "has_panel": True,
+ "has_socketio": False,
+ "state_prefix": "ticker_",
+ "default_enabled": True,
+}
+
+
+def create_blueprint():
+ from .routes import bp
+ return bp
diff --git a/kiosk/addons/ticker/routes.py b/kiosk/addons/ticker/routes.py
new file mode 100644
index 0000000..8b222f9
--- /dev/null
+++ b/kiosk/addons/ticker/routes.py
@@ -0,0 +1,127 @@
+import re
+import urllib.request as _urlreq
+import xml.etree.ElementTree as _ET
+from datetime import datetime as _dt, timedelta as _td
+from flask import Blueprint, jsonify, request, abort
+import db
+import extensions
+from auth import login_required
+
+bp = Blueprint('ticker', __name__, url_prefix='/api')
+
+_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']
+
+
+@bp.route('/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})
+
+
+@bp.route('/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')),
+ })
+
+
+@bp.route('/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,
+ }
+ extensions.socketio.emit('ticker', payload)
+ return '', 204
diff --git a/kiosk/addons/wifi/__init__.py b/kiosk/addons/wifi/__init__.py
new file mode 100644
index 0000000..be5decd
--- /dev/null
+++ b/kiosk/addons/wifi/__init__.py
@@ -0,0 +1,22 @@
+ADDON = {
+ "name": "wifi",
+ "label": "Wi-Fi",
+ "version": "1.0",
+ "order": 9,
+ "has_overlay": False,
+ "has_panel": True,
+ "has_socketio": False,
+ "state_prefix": "wifi_",
+ "default_enabled": True,
+}
+
+
+def create_blueprint():
+ from .routes import bp
+ return bp
+
+
+def wifi_status():
+ """Publikt API för att hämta WiFi-status (används av app.py startup)."""
+ from .routes import _wifi_status
+ return _wifi_status()
diff --git a/kiosk/addons/wifi/routes.py b/kiosk/addons/wifi/routes.py
new file mode 100644
index 0000000..4339cba
--- /dev/null
+++ b/kiosk/addons/wifi/routes.py
@@ -0,0 +1,195 @@
+import re
+import subprocess
+import time
+from flask import Blueprint, jsonify, request, abort
+from auth import login_required
+
+bp = Blueprint('wifi', __name__, url_prefix='/api/wifi')
+
+
+def _wifi_iface():
+ try:
+ with open('/proc/net/wireless') as f:
+ for line in f:
+ if ':' in line:
+ iface = line.split(':')[0].strip()
+ if iface:
+ return iface
+ 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')
+ 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)
+ 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?)'
+
+
+@bp.route('/status')
+@login_required
+def api_wifi_status():
+ return jsonify(_wifi_status())
+
+
+@bp.route('/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)})
+
+
+@bp.route('/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 ''})
+
+
+@bp.route('/disconnect', methods=['POST'])
+@login_required
+def api_wifi_disconnect():
+ _wpa('disconnect')
+ return '', 204
+
+
+@bp.route('/saved')
+@login_required
+def api_wifi_saved():
+ return jsonify({'saved': _wifi_saved()})
+
+
+@bp.route('/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
diff --git a/kiosk/app.py b/kiosk/app.py
index 66e894b..ef547a8 100644
--- a/kiosk/app.py
+++ b/kiosk/app.py
@@ -2,71 +2,24 @@ 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
+from flask import Flask, render_template, jsonify, request, abort, session, redirect, url_for
+from flask_socketio import SocketIO, emit
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, avatar_url}
-
-def _restore_chat_tokens():
- """Ladda sparade tokens från chat_users.json vid uppstart."""
- users = _load_chat_users()
- for username, u in users.items():
- token = u.get('token')
- if token:
- _chat_tokens[token] = {
- 'username': username,
- 'emoji': u.get('emoji', '😀'),
- 'color': u.get('color', '#94a3b8'),
- 'avatar_url': None, # fylls i av validate-endpoint efter att _get_avatar_url är definierad
- }
-
-_CHAT_COLORS = [
- '#f87171','#fb923c','#fbbf24','#a3e635',
- '#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
-
-
-# Återställ tokens nu när _get_avatar_url är definierad
-_restore_chat_tokens()
+from auth import login_required
+from addons.sleep import is_sleep_active as _is_sleep_active
+from addons.wifi import wifi_status as _wifi_status
+from addons.settings.routes import _apply_internal_display
+from addon_loader import discover_and_load, get_loaded_addons
+from utils.system import local_ip as _local_ip
+from utils.display import (
+ sway_env as _sway_env,
+ apply_saved_audio as _apply_saved_audio,
+ apply_display_mode as _apply_display_mode,
+)
# Load kiosk.conf (if present) into DB state so settings survive app restarts
@@ -101,33 +54,16 @@ app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', os.urandom(24))
socketio = SocketIO(app, async_mode='eventlet', cors_allowed_origins='*')
+import extensions as _ext
+_ext.socketio = socketio
+
+# Ladda addons direkt vid import så Blueprints registreras oavsett startmetod
+db.init_db()
+discover_and_load(app, socketio)
+
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 ---
@@ -150,20 +86,10 @@ def logout():
# --- 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())
+ overlays = [a['name'] for a in get_loaded_addons() if a.get('has_overlay')]
+ return render_template('display.html', local_ip=_local_ip(), active_overlays=overlays)
# --- Admin page ---
@@ -171,7 +97,8 @@ def display():
@app.route('/admin')
@login_required
def admin():
- return render_template('admin.html')
+ panels = [a for a in get_loaded_addons() if a.get('has_panel')]
+ return render_template('admin.html', addon_panels=panels)
# --- API: sysinfo ---
@@ -256,250 +183,12 @@ def reorder_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)]
- token = str(_uuid.uuid4())
- users[username] = {
- 'password_hash': generate_password_hash(password),
- 'emoji': emoji,
- 'color': color,
- 'token': token,
- }
- _save_chat_users(users)
- _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}
- users[key]['token'] = token
- _save_chat_users(users)
- 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
- if not user.get('avatar_url'):
- user['avatar_url'] = _get_avatar_url(user['username'])
- return jsonify({'username': user['username'], 'emoji': user['emoji'], 'color': user['color'], 'avatar_url': user.get('avatar_url')})
-
-
-@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)
-
-
-CHAT_IMAGES_DIR = Path(__file__).parent / 'static' / 'chat_images'
-CHAT_IMAGES_DIR.mkdir(exist_ok=True)
-_CHAT_IMG_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
-
-
-@app.route('/api/chat/image', methods=['POST'])
-def upload_chat_image():
- token = request.form.get('token', '')
- user_info = _chat_tokens.get(token)
- if not user_info:
- return jsonify({'error': 'Ogiltig token'}), 401
- f = request.files.get('image')
- if not f or not f.filename:
- return jsonify({'error': 'Ingen fil'}), 400
- ext = Path(secure_filename(f.filename)).suffix.lower()
- if ext not in _CHAT_IMG_EXTS:
- return jsonify({'error': 'Använd jpg, png, gif eller webp'}), 400
- filename = _uuid.uuid4().hex + ext
- f.save(str(CHAT_IMAGES_DIR / filename))
- return jsonify({'url': f'/static/chat_images/{filename}'})
-
-
-@app.route('/api/chat/avatar', methods=['POST'])
-def upload_chat_avatar():
- token = request.form.get('token', '')
- 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('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_play_suggestion(self, data):
- user = self._users.get(request.sid)
- if not user:
- return
- url = str(data.get('url', '')).strip()[:500]
- if not url or not url.startswith(('http://', 'https://')):
- return
- msg = {
- 'name': user['name'],
- 'color': user['color'],
- 'emoji': user.get('emoji'),
- 'avatar_url': user.get('avatar_url'),
- 'text': url,
- 'kind': 'play_suggestion',
- }
- self._messages.append(msg)
- if len(self._messages) > 50:
- self._messages.pop(0)
- # Skicka till alla i /chat-namespacet (display + mobil)
- socketio.emit('play_suggestion', msg, namespace='/chat')
- # Notifiera admin-panelen via huvud-namespacet
- socketio.emit('play_suggestion', {
- 'name': user['name'], 'color': user['color'], 'url': url,
- })
-
- def on_disconnect(self):
- user = self._users.pop(request.sid, None)
- if user:
- socketio.emit('viewer_count', {'count': len(self._users)}, namespace='/chat')
-
-
-socketio.on_namespace(ChatNamespace('/chat'))
+# --- Kiosk Livechat --- (migrerat till addons/livechat/)
# --- 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
+# _is_sleep_active importeras från addons.sleep
@socketio.on('connect')
@@ -537,1400 +226,26 @@ def handle_switch(data):
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
-
-
-# --- Music ---
-
-_MUSIC_EXTS = {'.mp3', '.flac', '.ogg', '.wav', '.m4a', '.aac'}
-
-@app.route('/api/music', methods=['GET'])
-def get_music():
- state = db.get_state()
- return jsonify({
- 'enabled': state.get('music_enabled', '0') == '1',
- 'source_type': state.get('music_source_type', 'local'),
- 'source': state.get('music_source', ''),
- 'volume': int(state.get('music_volume', '50')),
- 'shuffle': state.get('music_shuffle', '0') == '1',
- 'show_overlay': state.get('music_show_overlay', '1') == '1',
- 'overlay_position': state.get('music_overlay_position', 'bottom-left'),
- 'now_playing': {
- 'title': state.get('music_now_title', ''),
- 'artist': state.get('music_now_artist', ''),
- },
- })
-
-
-@app.route('/api/music', methods=['POST'])
-@login_required
-def set_music():
- data = request.get_json()
- if not data:
- abort(400)
- enabled = bool(data.get('enabled', False))
- source_type = 'youtube' if data.get('source_type') == 'youtube' else 'local'
- source = str(data.get('source', ''))
- volume = max(0, min(100, int(data.get('volume', 50))))
- shuffle = bool(data.get('shuffle', False))
- show_overlay = bool(data.get('show_overlay', True))
- overlay_position = str(data.get('overlay_position', 'bottom-left'))
- if overlay_position not in {'top-left', 'top-right', 'bottom-left', 'bottom-right'}:
- overlay_position = 'bottom-left'
-
- db.set_state('music_enabled', '1' if enabled else '0')
- db.set_state('music_source_type', source_type)
- db.set_state('music_source', source)
- db.set_state('music_volume', str(volume))
- db.set_state('music_shuffle', '1' if shuffle else '0')
- db.set_state('music_show_overlay', '1' if show_overlay else '0')
- db.set_state('music_overlay_position', overlay_position)
-
- payload = {
- 'enabled': enabled, 'source_type': source_type,
- 'source': source, 'volume': volume, 'shuffle': shuffle,
- 'show_overlay': show_overlay, 'overlay_position': overlay_position,
- }
- socketio.emit('music_settings', payload)
- return '', 204
-
-
-@app.route('/api/music/tracks', methods=['GET'])
-@login_required
-def get_music_tracks():
- folder = request.args.get('path', '')
- if not folder:
- return jsonify([])
- p = Path(folder)
- try:
- p = p.resolve()
- if not any(str(p).startswith(str(r)) for r in ALLOWED_ROOTS):
- abort(403)
- except Exception:
- abort(400)
- if not p.is_dir():
- return jsonify([])
- tracks = sorted([
- {'name': f.name, 'path': str(f)}
- for f in p.iterdir()
- if f.is_file() and f.suffix.lower() in _MUSIC_EXTS
- ], key=lambda x: x['name'])
- return jsonify(tracks)
-
-
-_music_playlist_cache = [] # in-memory list of video IDs from last extraction
-
-
-@app.route('/api/music/extract', methods=['POST'])
-@login_required
-def extract_music_playlist():
- """Extract video IDs for the music playlist and persist to DB."""
- data = request.get_json() or {}
- url = str(data.get('url', '')).strip()
- if not url:
- return jsonify({'error': 'URL saknas'}), 400
- try:
- ids, _total_seconds = _yt_extract(url)
- except subprocess.TimeoutExpired:
- return jsonify({'error': 'Timeout — spellistans hämtning tog för lång tid'}), 504
- except Exception as e:
- return jsonify({'error': str(e)}), 500
-
- global _music_playlist_cache
- _music_playlist_cache = ids
- _yt_playlist_cache[url] = ids
- db.set_state('music_playlist_ids', ','.join(ids))
- return jsonify({'count': len(ids), 'ids': ids})
-
-
-@app.route('/api/music/playlist', methods=['GET'])
-def get_music_playlist():
- """Return the extracted playlist video IDs."""
- global _music_playlist_cache
- if not _music_playlist_cache:
- stored = db.get_state().get('music_playlist_ids', '')
- _music_playlist_cache = [v for v in stored.split(',') if v] if stored else []
- return jsonify({'ids': _music_playlist_cache, 'count': len(_music_playlist_cache)})
-
-
-@app.route('/api/music/nowplaying', methods=['POST'])
-def update_now_playing():
- """Called by display client to broadcast now-playing state to admin."""
- data = request.get_json() or {}
- title = str(data.get('title', ''))[:200]
- artist = str(data.get('artist', ''))[:200]
- db.set_state('music_now_title', title)
- db.set_state('music_now_artist', artist)
- socketio.emit('now_playing', {'title': title, 'artist': artist})
- return '', 204
-
-
-# --- YouTube playlist extraction (video & musik) ---
-
-_yt_playlist_cache = {} # url -> [video_id, ...]
-
-
-def _yt_extract(url):
- """Run yt-dlp and return (ids, total_seconds). Raises on error."""
- import shutil
- ytdlp = shutil.which('yt-dlp') or '/home/mrfox/.local/bin/yt-dlp'
- if not ytdlp or not Path(ytdlp).exists():
- raise RuntimeError('yt-dlp är inte installerat')
- result = subprocess.run(
- [ytdlp, '--flat-playlist', '--print', 'id', '--print', '%(duration)s',
- '--no-warnings', url],
- capture_output=True, text=True, timeout=60
- )
- lines = [l.strip() for l in result.stdout.splitlines() if l.strip()]
- # Lines alternate: id, duration, id, duration, ...
- ids, total_seconds = [], 0
- for i in range(0, len(lines) - 1, 2):
- ids.append(lines[i])
- try:
- total_seconds += int(lines[i + 1])
- except ValueError:
- pass # duration okänd för denna video
- if not ids:
- raise RuntimeError(result.stderr[:300] or 'Inga videos hittades')
- return ids, total_seconds
-
-
-@app.route('/api/playlist/extract', methods=['POST'])
-@login_required
-def extract_playlist():
- """Extract video IDs from any public YouTube playlist URL and cache by URL."""
- data = request.get_json() or {}
- url = str(data.get('url', '')).strip()
- if not url:
- return jsonify({'error': 'URL saknas'}), 400
- try:
- ids, total_seconds = _yt_extract(url)
- except subprocess.TimeoutExpired:
- return jsonify({'error': 'Timeout'}), 504
- except Exception as e:
- return jsonify({'error': str(e)}), 500
- _yt_playlist_cache[url] = ids
- return jsonify({'count': len(ids), 'ids': ids, 'total_seconds': total_seconds})
-
-
-@app.route('/api/playlist/ids', methods=['GET'])
-def get_playlist_ids():
- """Return cached video IDs for a playlist URL."""
- url = request.args.get('url', '').strip()
- ids = _yt_playlist_cache.get(url, [])
- return jsonify({'ids': ids, 'count': len(ids)})
-
-
-# --- Wi-Fi ---
-
-def _wifi_iface():
- 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
-
-
-# --- Bluetooth ---
-
-import threading as _threading
-import time as _time
-
-_BT_ADDR_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
-
-_bt_scanning = False
-_bt_scan_lock = _threading.Lock()
-_bt_scan_proc = None
-
-
-def _bt_run(*args, timeout=15):
- cmd = ['bluetoothctl'] + list(args)
- try:
- r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
- return r.returncode, r.stdout.strip()
- except subprocess.TimeoutExpired:
- return 1, 'timeout'
- except Exception as e:
- return 1, str(e)
-
-
-def _bt_status():
- rc, out = _bt_run('show')
- powered = False
- name = ''
- addr = ''
- discovering = False
- for line in out.splitlines():
- s = line.strip()
- if s.startswith('Powered:'): powered = 'yes' in s
- elif s.startswith('Name:'): name = s.split(':', 1)[1].strip()
- elif s.startswith('Discovering:'): discovering = 'yes' in s
- m = re.search(r'([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', out.split('\n')[0] if out else '')
- if m:
- addr = m.group(0)
- return {
- 'powered': powered,
- 'name': name,
- 'address': addr,
- 'discovering': discovering or _bt_scanning,
- 'available': rc == 0,
- }
-
-
-def _bt_device_info(addr):
- rc, info = _bt_run('info', addr)
- connected = paired = trusted = False
- dev_name = ''
- dev_type = ''
- for line in info.splitlines():
- s = line.strip()
- if s.startswith('Name:'): dev_name = s.split(':', 1)[1].strip()
- elif s.startswith('Connected:'): connected = 'yes' in s
- elif s.startswith('Paired:'): paired = 'yes' in s
- elif s.startswith('Trusted:'): trusted = 'yes' in s
- elif s.startswith('Icon:'):
- icon = s.split(':', 1)[1].strip()
- if any(k in icon for k in ('audio', 'headphone', 'headset', 'speaker')):
- dev_type = 'audio'
- elif 'phone' in icon:
- dev_type = 'phone'
- elif 'input' in icon:
- dev_type = 'input'
- else:
- dev_type = icon
- return {
- 'address': addr, 'name': dev_name,
- 'connected': connected, 'paired': paired, 'trusted': trusted,
- 'type': dev_type,
- }
-
-
-def _bt_devices():
- rc, out = _bt_run('devices')
- result = []
- for line in out.splitlines():
- m = re.match(r'Device\s+([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\s+(.*)', line)
- if m:
- addr = m.group(1)
- name = m.group(2).strip()
- info = _bt_device_info(addr)
- if not info['name']:
- info['name'] = name
- result.append(info)
- return result
-
-
-def _bt_scan_worker(duration=15):
- global _bt_scanning, _bt_scan_proc
- try:
- _bt_scan_proc = subprocess.Popen(
- ['bluetoothctl', 'scan', 'on'],
- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
- )
- _time.sleep(duration)
- finally:
- try:
- if _bt_scan_proc:
- _bt_scan_proc.terminate()
- _bt_scan_proc = None
- except Exception:
- pass
- _bt_run('scan', 'off', timeout=5)
- with _bt_scan_lock:
- _bt_scanning = False
-
-
-@app.route('/api/bluetooth/status')
-@login_required
-def api_bt_status():
- return jsonify(_bt_status())
-
-
-@app.route('/api/bluetooth/devices')
-@login_required
-def api_bt_devices():
- return jsonify({'devices': _bt_devices(), 'scanning': _bt_scanning})
-
-
-@app.route('/api/bluetooth/scan', methods=['POST'])
-@login_required
-def api_bt_scan():
- global _bt_scanning
- with _bt_scan_lock:
- if _bt_scanning:
- return jsonify({'ok': True, 'scanning': True})
- _bt_scanning = True
- t = _threading.Thread(target=_bt_scan_worker, args=(15,), daemon=True)
- t.start()
- return jsonify({'ok': True, 'scanning': True})
-
-
-@app.route('/api/bluetooth/pair', methods=['POST'])
-@login_required
-def api_bt_pair():
- data = request.get_json()
- if not data or not data.get('address'):
- abort(400)
- addr = str(data['address'])
- if not _BT_ADDR_RE.match(addr):
- abort(400)
- _bt_run('trust', addr, timeout=5)
- rc, out = _bt_run('pair', addr, timeout=30)
- ok = rc == 0 or 'already paired' in out.lower() or 'already exists' in out.lower()
- if ok:
- _bt_run('connect', addr, timeout=15)
- return jsonify({'ok': ok, 'message': '' if ok else out.split('\n')[-1]})
-
-
-@app.route('/api/bluetooth/connect', methods=['POST'])
-@login_required
-def api_bt_connect():
- data = request.get_json()
- if not data or not data.get('address'):
- abort(400)
- addr = str(data['address'])
- if not _BT_ADDR_RE.match(addr):
- abort(400)
- rc, out = _bt_run('connect', addr, timeout=15)
- ok = rc == 0 or 'connected' in out.lower()
- return jsonify({'ok': ok, 'message': '' if ok else out.split('\n')[-1]})
-
-
-@app.route('/api/bluetooth/disconnect', methods=['POST'])
-@login_required
-def api_bt_disconnect():
- data = request.get_json()
- if not data or not data.get('address'):
- abort(400)
- addr = str(data['address'])
- if not _BT_ADDR_RE.match(addr):
- abort(400)
- _bt_run('disconnect', addr, timeout=10)
- return '', 204
-
-
-@app.route('/api/bluetooth/device/<path:address>', methods=['DELETE'])
-@login_required
-def api_bt_remove(address):
- if not _BT_ADDR_RE.match(address):
- abort(400)
- _bt_run('remove', address, timeout=10)
- return '', 204
+# --- File browser --- (migrerat till addons/filebrowser/)
-@app.route('/api/bluetooth/power', methods=['POST'])
-@login_required
-def api_bt_power():
- data = request.get_json() or {}
- on = bool(data.get('on', True))
- _bt_run('power', 'on' if on else 'off', timeout=5)
- return jsonify(_bt_status())
+# --- Settings --- (migrerat till addons/settings/)
-# --- System ---
+# --- Söv-läge --- (migrerat till addons/sleep/)
-@app.route('/api/reboot', methods=['POST'])
-@login_required
-def reboot():
- subprocess.Popen(['sudo', 'reboot'])
- return '', 204
+# --- Ticker --- (migrerat till addons/ticker/)
+# --- Chat overlay --- (migrerat till addons/livechat/)
+# --- Clock overlay --- (migrerat till addons/clock/)
-@app.route('/api/shutdown', methods=['POST'])
-@login_required
-def shutdown():
- subprocess.Popen(['sudo', 'poweroff'])
- return '', 204
+# --- Music --- (migrerat till addons/music/)
-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
+# --- Wi-Fi --- (migrerat till addons/wifi/)
+# --- Bluetooth --- (migrerat till addons/bluetooth/)
+# --- System + internal_display --- (migrerat till addons/system/ och addons/settings/)
# --- Startup ---
@@ -1961,8 +276,40 @@ def _apply_settings_after_cage():
_apply_internal_display(True, env)
+@app.route('/api/addons', methods=['GET'])
+@login_required
+def list_addons():
+ """Lista alla kända addons med aktiverad/inaktiverad status."""
+ from addon_loader import get_all_addon_names
+ state = db.get_state()
+ result = []
+ for name in get_all_addon_names():
+ db_val = state.get(f'addon_{name}_enabled')
+ loaded = any(a['name'] == name for a in get_loaded_addons())
+ result.append({
+ 'name': name,
+ 'enabled': loaded,
+ 'db_value': db_val,
+ })
+ return jsonify(result)
+
+
+@app.route('/api/addons/<name>/toggle', methods=['POST'])
+@login_required
+def toggle_addon(name):
+ """Aktivera/inaktivera ett addon. Kräver omstart för att träda i kraft."""
+ from addon_loader import get_all_addon_names
+ if name not in get_all_addon_names():
+ return jsonify({'error': 'Okänt addon'}), 404
+ state = db.get_state()
+ current_val = state.get(f'addon_{name}_enabled', '1')
+ new_val = '0' if current_val == '1' else '1'
+ db.set_state(f'addon_{name}_enabled', new_val)
+ socketio.emit('addons_changed', {'name': name, 'enabled': new_val == '1'})
+ return jsonify({'name': name, 'enabled': new_val == '1'})
+
+
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
diff --git a/kiosk/auth.py b/kiosk/auth.py
new file mode 100644
index 0000000..afb4209
--- /dev/null
+++ b/kiosk/auth.py
@@ -0,0 +1,15 @@
+from functools import wraps
+from flask import session, request, jsonify, redirect, url_for
+
+
+def login_required(f):
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ if not session.get('logged_in'):
+ 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
diff --git a/kiosk/config.py b/kiosk/config.py
new file mode 100644
index 0000000..444c0c1
--- /dev/null
+++ b/kiosk/config.py
@@ -0,0 +1,16 @@
+"""
+Delade konfigurationskonstanter för kiosk-appen.
+Importeras av app.py och addons som behöver dessa värden.
+"""
+from pathlib import Path
+
+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')
diff --git a/kiosk/db.py b/kiosk/db.py
index 36e0480..6492fe4 100644
--- a/kiosk/db.py
+++ b/kiosk/db.py
@@ -156,3 +156,31 @@ 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}
+
+
+class AddonDB:
+ """
+ Scoped DB-access för addons.
+ Alla nycklar prefixas automatiskt med addon-prefixet
+ så att addons inte kan råka skriva utanför sin namnrymd.
+
+ Exempel:
+ _db = AddonDB('ticker_')
+ _db.set('enabled', '1') # sparar 'ticker_enabled'
+ _db.get('enabled') # läser 'ticker_enabled'
+ """
+
+ def __init__(self, prefix: str):
+ self._prefix = prefix
+
+ def get(self, key: str, default=None):
+ return get_state().get(self._prefix + key, default)
+
+ def set(self, key: str, value):
+ set_state(self._prefix + key, str(value))
+
+ def get_all(self) -> dict:
+ """Returnerar alla värden för detta prefix, utan prefixet i nyckelnamnen."""
+ state = get_state()
+ pfx = self._prefix
+ return {k[len(pfx):]: v for k, v in state.items() if k.startswith(pfx)}
diff --git a/kiosk/extensions.py b/kiosk/extensions.py
new file mode 100644
index 0000000..b3c5b78
--- /dev/null
+++ b/kiosk/extensions.py
@@ -0,0 +1,7 @@
+"""
+Delade Flask-extensions.
+app.py sätter `socketio` direkt efter att objektet skapats.
+Addons importerar denna modul och använder t.ex. `extensions.socketio.emit(...)`.
+"""
+
+socketio = None
diff --git a/kiosk/static/addons/.gitkeep b/kiosk/static/addons/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/kiosk/static/addons/sleep/admin.js b/kiosk/static/addons/sleep/admin.js
new file mode 100644
index 0000000..1313daf
--- /dev/null
+++ b/kiosk/static/addons/sleep/admin.js
@@ -0,0 +1,25 @@
+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);
+}
diff --git a/kiosk/templates/addons/bluetooth/panel.html b/kiosk/templates/addons/bluetooth/panel.html
new file mode 100644
index 0000000..cb6793b
--- /dev/null
+++ b/kiosk/templates/addons/bluetooth/panel.html
@@ -0,0 +1,34 @@
+ <!-- Sektion: Bluetooth -->
+ <section>
+ <h2>Bluetooth</h2>
+
+ <!-- Adapterstatus -->
+ <div id="bt-status-box" class="prop-grid" style="margin-bottom:1rem">
+ <label>Adapter</label>
+ <div style="display:flex;align-items:center;gap:.6rem;flex-wrap:wrap">
+ <span id="bt-status-dot" style="display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:#555"></span>
+ <span id="bt-adapter-name" style="font-size:.9rem">Laddar...</span>
+ <span id="bt-adapter-addr" style="font-size:.78rem;color:#555;font-family:monospace"></span>
+ </div>
+ </div>
+
+ <div class="row" style="margin-bottom:1rem">
+ <button onclick="btScan()" id="bt-scan-btn">Skanna enheter</button>
+ <button class="secondary" id="bt-power-btn" onclick="btTogglePower()">Stäng av adapter</button>
+ <span id="bt-scan-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
+
+ <!-- Skannrade enheter -->
+ <div id="bt-found-wrap" style="display:none;margin-bottom:1.25rem">
+ <h3>Hittade enheter</h3>
+ <div id="bt-found-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem"></div>
+ </div>
+
+ <!-- Kopplade / kända enheter -->
+ <div id="bt-known-wrap">
+ <h3>Kopplade enheter</h3>
+ <div id="bt-known-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem;font-size:.9rem">
+ <span style="color:#555;font-style:italic">Inga kopplade enheter</span>
+ </div>
+ </div>
+ </section>
diff --git a/kiosk/templates/addons/clock/panel.html b/kiosk/templates/addons/clock/panel.html
new file mode 100644
index 0000000..751b194
--- /dev/null
+++ b/kiosk/templates/addons/clock/panel.html
@@ -0,0 +1,58 @@
+ <!-- Sektion: 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>
diff --git a/kiosk/templates/addons/livechat/panel.html b/kiosk/templates/addons/livechat/panel.html
new file mode 100644
index 0000000..19479e6
--- /dev/null
+++ b/kiosk/templates/addons/livechat/panel.html
@@ -0,0 +1,86 @@
+ <!-- Sektion: Livechat -->
+ <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>
diff --git a/kiosk/templates/addons/music/panel.html b/kiosk/templates/addons/music/panel.html
new file mode 100644
index 0000000..b3c0131
--- /dev/null
+++ b/kiosk/templates/addons/music/panel.html
@@ -0,0 +1,67 @@
+ <!-- Sektion: Musik -->
+ <section>
+ <h2>Musik</h2>
+ <p style="font-size:.85rem;color:#555;margin-bottom:.75rem">Spelar musik i bakgrunden oberoende av schemat. Fungerar med lokala MP3-filer (mapp) eller en YouTube-spellista. En "Spelar nu"-overlay kan visas på displayen.</p>
+
+ <div class="prop-grid">
+ <label>Källa</label>
+ <select id="music-source-type" onchange="musicSourceTypeChanged()">
+ <option value="local">Lokal mapp med MP3-filer</option>
+ <option value="youtube">YouTube-spellista (ljud)</option>
+ </select>
+
+ <label id="music-local-label">Mapp</label>
+ <div id="music-local-wrap" class="row">
+ <input id="music-source-local" type="text" placeholder="/home/mrfox/musik" style="flex:1">
+ <button type="button" class="secondary" onclick="openMusicFolderBrowser()">Bläddra</button>
+ </div>
+
+ <label id="music-yt-label" style="display:none">YouTube-URL</label>
+ <div id="music-yt-wrap" style="display:none">
+ <div style="display:flex;gap:.5rem;align-items:center">
+ <input id="music-source-youtube" type="url" placeholder="https://www.youtube.com/playlist?list=..." style="flex:1">
+ <button type="button" class="secondary" onclick="extractMusicPlaylist()" id="music-extract-btn">Extrahera</button>
+ </div>
+ <div id="music-extract-status" style="font-size:.75rem;color:#444;margin-top:.3rem">Ange en publik YouTube-spellista och klicka Extrahera för att hämta låtarna.</div>
+ </div>
+
+ <label>Volym</label>
+ <div class="row">
+ <input type="range" id="music-volume" min="0" max="100" step="5" value="50"
+ oninput="document.getElementById('music-volume-val').textContent=this.value+'%'">
+ <span id="music-volume-val" style="color:#aaa;min-width:3rem">50%</span>
+ </div>
+
+ <label>Blanda</label>
+ <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+ <input type="checkbox" id="music-shuffle">
+ Spela i slumpmässig ordning
+ </label>
+
+ <label>Overlay</label>
+ <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
+ <input type="checkbox" id="music-show-overlay" checked>
+ Visa "Spelar nu"-overlay på displayen
+ </label>
+
+ <label>Overlay-position</label>
+ <select id="music-overlay-position">
+ <option value="bottom-left">Nedre vänster</option>
+ <option value="bottom-right">Nedre höger</option>
+ <option value="top-left">Övre vänster</option>
+ <option value="top-right">Övre höger</option>
+ </select>
+ </div>
+
+ <!-- Spårlista (lokal) -->
+ <div id="music-track-list-wrap" style="display:none; margin:.75rem 0;">
+ <h3 style="margin-bottom:.4rem">Hittade spår</h3>
+ <div id="music-track-list" style="font-size:.8rem;color:#888;max-height:160px;overflow-y:auto;background:#111;border-radius:6px;padding:.5rem .75rem;line-height:1.7;"></div>
+ </div>
+
+ <div class="row" style="margin-top:.75rem">
+ <button onclick="setMusik(true)">Aktivera</button>
+ <button class="secondary" onclick="setMusik(false)">Stäng av</button>
+ <span id="music-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
+ </div>
+ </section>
diff --git a/kiosk/templates/addons/settings/panel.html b/kiosk/templates/addons/settings/panel.html
new file mode 100644
index 0000000..a4c70df
--- /dev/null
+++ b/kiosk/templates/addons/settings/panel.html
@@ -0,0 +1,48 @@
+ <!-- Sektion: 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>
diff --git a/kiosk/templates/addons/sleep/panel.html b/kiosk/templates/addons/sleep/panel.html
new file mode 100644
index 0000000..372d5c9
--- /dev/null
+++ b/kiosk/templates/addons/sleep/panel.html
@@ -0,0 +1,21 @@
+ <!-- Sektion: 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>
diff --git a/kiosk/templates/addons/system/panel.html b/kiosk/templates/addons/system/panel.html
new file mode 100644
index 0000000..e9b4ded
--- /dev/null
+++ b/kiosk/templates/addons/system/panel.html
@@ -0,0 +1,9 @@
+ <!-- Sektion: 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>
diff --git a/kiosk/templates/addons/ticker/panel.html b/kiosk/templates/addons/ticker/panel.html
new file mode 100644
index 0000000..c593dab
--- /dev/null
+++ b/kiosk/templates/addons/ticker/panel.html
@@ -0,0 +1,149 @@
+ <!-- Sektion: Löptext -->
+ <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>
diff --git a/kiosk/templates/addons/wifi/panel.html b/kiosk/templates/addons/wifi/panel.html
new file mode 100644
index 0000000..a8bf322
--- /dev/null
+++ b/kiosk/templates/addons/wifi/panel.html
@@ -0,0 +1,63 @@
+ <!-- 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>
diff --git a/kiosk/templates/admin.html b/kiosk/templates/admin.html
index 41cd947..7eb40c4 100644
--- a/kiosk/templates/admin.html
+++ b/kiosk/templates/admin.html
@@ -13,7 +13,7 @@
<!-- Föreslagna uppspelningar från chatten -->
<div id="play-suggestions" style="display:none; margin-bottom:.75rem; flex-direction:column; gap:.4rem;"></div>
- <!-- Sektion 1: Visa nu -->
+ <!-- Sektion: Visa nu -->
<section>
<h2>Visa nu</h2>
@@ -39,7 +39,7 @@
</div>
</section>
- <!-- Sektion 2: Schema -->
+ <!-- Sektion: Schema -->
<section>
<h2>Schema</h2>
@@ -94,7 +94,7 @@
<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">
@@ -130,551 +130,12 @@
</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: Musik -->
- <section>
- <h2>Musik</h2>
- <p style="font-size:.85rem;color:#555;margin-bottom:.75rem">Spelar musik i bakgrunden oberoende av schemat. Fungerar med lokala MP3-filer (mapp) eller en YouTube-spellista. En "Spelar nu"-overlay kan visas på displayen.</p>
-
- <div class="prop-grid">
- <label>Källa</label>
- <select id="music-source-type" onchange="musicSourceTypeChanged()">
- <option value="local">Lokal mapp med MP3-filer</option>
- <option value="youtube">YouTube-spellista (ljud)</option>
- </select>
-
- <label id="music-local-label">Mapp</label>
- <div id="music-local-wrap" class="row">
- <input id="music-source-local" type="text" placeholder="/home/mrfox/musik" style="flex:1">
- <button type="button" class="secondary" onclick="openMusicFolderBrowser()">Bläddra</button>
- </div>
-
- <label id="music-yt-label" style="display:none">YouTube-URL</label>
- <div id="music-yt-wrap" style="display:none">
- <div style="display:flex;gap:.5rem;align-items:center">
- <input id="music-source-youtube" type="url" placeholder="https://www.youtube.com/playlist?list=..." style="flex:1">
- <button type="button" class="secondary" onclick="extractMusicPlaylist()" id="music-extract-btn">Extrahera</button>
- </div>
- <div id="music-extract-status" style="font-size:.75rem;color:#444;margin-top:.3rem">Ange en publik YouTube-spellista och klicka Extrahera för att hämta låtarna.</div>
- </div>
-
- <label>Volym</label>
- <div class="row">
- <input type="range" id="music-volume" min="0" max="100" step="5" value="50"
- oninput="document.getElementById('music-volume-val').textContent=this.value+'%'">
- <span id="music-volume-val" style="color:#aaa;min-width:3rem">50%</span>
- </div>
-
- <label>Blanda</label>
- <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
- <input type="checkbox" id="music-shuffle">
- Spela i slumpmässig ordning
- </label>
-
- <label>Overlay</label>
- <label style="display:flex;align-items:center;gap:.5rem;font-size:.9rem">
- <input type="checkbox" id="music-show-overlay" checked>
- Visa "Spelar nu"-overlay på displayen
- </label>
-
- <label>Overlay-position</label>
- <select id="music-overlay-position">
- <option value="bottom-left">Nedre vänster</option>
- <option value="bottom-right">Nedre höger</option>
- <option value="top-left">Övre vänster</option>
- <option value="top-right">Övre höger</option>
- </select>
- </div>
-
- <!-- Spårlista (lokal) -->
- <div id="music-track-list-wrap" style="display:none; margin:.75rem 0;">
- <h3 style="margin-bottom:.4rem">Hittade spår</h3>
- <div id="music-track-list" style="font-size:.8rem;color:#888;max-height:160px;overflow-y:auto;background:#111;border-radius:6px;padding:.5rem .75rem;line-height:1.7;"></div>
- </div>
-
- <div class="row" style="margin-top:.75rem">
- <button onclick="setMusik(true)">Aktivera</button>
- <button class="secondary" onclick="setMusik(false)">Stäng av</button>
- <span id="music-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
- </div>
- </section>
-
- <!-- Sektion 7: Sovläge -->
- <section>
- <h2>Sovläge</h2>
- <div class="prop-grid">
- <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: Bluetooth -->
- <section>
- <h2>Bluetooth</h2>
-
- <!-- Adapterstatus -->
- <div id="bt-status-box" class="prop-grid" style="margin-bottom:1rem">
- <label>Adapter</label>
- <div style="display:flex;align-items:center;gap:.6rem;flex-wrap:wrap">
- <span id="bt-status-dot" style="display:inline-block;width:.6rem;height:.6rem;border-radius:50%;background:#555"></span>
- <span id="bt-adapter-name" style="font-size:.9rem">Laddar...</span>
- <span id="bt-adapter-addr" style="font-size:.78rem;color:#555;font-family:monospace"></span>
- </div>
- </div>
-
- <div class="row" style="margin-bottom:1rem">
- <button onclick="btScan()" id="bt-scan-btn">Skanna enheter</button>
- <button class="secondary" id="bt-power-btn" onclick="btTogglePower()">Stäng av adapter</button>
- <span id="bt-scan-status" style="color:#aaa;font-size:.85rem;align-self:center"></span>
- </div>
-
- <!-- Skannrade enheter -->
- <div id="bt-found-wrap" style="display:none;margin-bottom:1.25rem">
- <h3>Hittade enheter</h3>
- <div id="bt-found-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem"></div>
- </div>
-
- <!-- Kopplade / kända enheter -->
- <div id="bt-known-wrap">
- <h3>Kopplade enheter</h3>
- <div id="bt-known-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.3rem;font-size:.9rem">
- <span style="color:#555;font-style:italic">Inga kopplade enheter</span>
- </div>
- </div>
- </section>
-
- <!-- Sektion 6: Inställningar -->
- <section>
- <h2>Inställningar</h2>
-
- <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>
+ <!-- Addon-paneler (inlästa dynamiskt baserat på aktiverade addons) -->
+ {% for addon in addon_panels %}
+ {% include 'addons/' + addon.name + '/panel.html' ignore missing %}
+ {% endfor %}
- <!-- Sektion 7: Systeminfo -->
+ <!-- Sektion: Systeminfo -->
<section>
<h2>Systeminfo</h2>
<div id="sysinfo" class="sysinfo-grid">
diff --git a/kiosk/templates/display.html b/kiosk/templates/display.html
index f2a80ee..1130ee6 100644
--- a/kiosk/templates/display.html
+++ b/kiosk/templates/display.html
@@ -227,5 +227,8 @@
<script src="/static/socket.io.min.js"></script>
<script src="/static/display.js"></script>
+ {% for name in active_overlays %}
+ <script src="/static/addons/{{ name }}/overlay.js"></script>
+ {% endfor %}
</body>
</html>
diff --git a/kiosk/utils/__init__.py b/kiosk/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/kiosk/utils/display.py b/kiosk/utils/display.py
new file mode 100644
index 0000000..8aac0f0
--- /dev/null
+++ b/kiosk/utils/display.py
@@ -0,0 +1,147 @@
+import os
+import re
+import shutil
+import subprocess
+from pathlib import Path
+import db
+
+
+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
+
+
+def audio_device_id():
+ """Returnerar PipeWire interface-id för första 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):
+ """Tolkar pw-cli enum-params EnumProfile-utdata till en lista med 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'}
+ return [p for p in profiles
+ if p.get('name') not in skip
+ and not p.get('name', '').startswith('input:')]
+
+
+def apply_saved_audio():
+ """Tillämpar sparad ljudprofil från DB. Best-effort, icke-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):
+ """Tillämpar skärmupplösning best-effort. Provar wlr-randr, swaymsg, xrandr."""
+ m = re.search(r"(\d+)x(\d+)", mode_str)
+ if not m:
+ return False
+ width, height = m.group(1), m.group(2)
+
+ 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
+
+ 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
+
+ if shutil.which('wlr-randr') and out_name:
+ try:
+ 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
+
+ if shutil.which('swaymsg') and out_name:
+ try:
+ subprocess.run(['swaymsg', 'output', out_name, 'mode', width, height], timeout=5, check=True, env=env)
+ return True
+ except Exception:
+ pass
+
+ if os.environ.get('XDG_SESSION_TYPE', '').lower() == 'wayland' or os.environ.get('WAYLAND_DISPLAY'):
+ return False
+ if shutil.which('xrandr') and out_name:
+ for disp in (None, ':0', ':0.0', ':1'):
+ try:
+ env2 = os.environ.copy()
+ if disp:
+ env2['DISPLAY'] = disp
+ subprocess.run(['xrandr', '--output', out_name, '--mode', f'{width}x{height}'], env=env2, timeout=5, check=True)
+ return True
+ except Exception:
+ continue
+
+ return False
diff --git a/kiosk/utils/system.py b/kiosk/utils/system.py
new file mode 100644
index 0000000..170c710
--- /dev/null
+++ b/kiosk/utils/system.py
@@ -0,0 +1,13 @@
+import socket as _socket
+
+
+def local_ip():
+ """Returnerar maskinens lokala IP-adress."""
+ 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'
diff --git a/kiosk/utils/youtube.py b/kiosk/utils/youtube.py
new file mode 100644
index 0000000..ca5b879
--- /dev/null
+++ b/kiosk/utils/youtube.py
@@ -0,0 +1,30 @@
+import subprocess
+import shutil
+from pathlib import Path
+
+# In-memory cache: url -> [video_id, ...]
+playlist_cache = {}
+
+
+def yt_extract(url):
+ """Kör yt-dlp och returnera (ids, total_seconds). Kastar undantag vid fel."""
+ ytdlp = shutil.which('yt-dlp') or '/home/mrfox/.local/bin/yt-dlp'
+ if not ytdlp or not Path(ytdlp).exists():
+ raise RuntimeError('yt-dlp är inte installerat')
+ result = subprocess.run(
+ [ytdlp, '--flat-playlist', '--print', 'id', '--print', '%(duration)s',
+ '--no-warnings', url],
+ capture_output=True, text=True, timeout=60
+ )
+ lines = [l.strip() for l in result.stdout.splitlines() if l.strip()]
+ # Rader alternerar: id, duration, id, duration, ...
+ ids, total_seconds = [], 0
+ for i in range(0, len(lines) - 1, 2):
+ ids.append(lines[i])
+ try:
+ total_seconds += int(lines[i + 1])
+ except ValueError:
+ pass
+ if not ids:
+ raise RuntimeError(result.stderr[:300] or 'Inga videos hittades')
+ return ids, total_seconds