LabCodeHub öffentliche Ansicht
Anmelden
Küpper / ehc-tvwall öffentlich

fix: v1.0.0

R Rüdiger Küpper <rpr@9it.de> committete am 21.08.2026 01:27
14518d057bcd34fd95e411d13ea882ffd2148167
29 geänderte Datei(en) +4171 −47
Kontextzeilen: 0 1 2 3 10
hinzugefügt EHC-Dienstag.jpeg
+0 −0 Datei ansehen

Binärdatei – kein Textvergleich möglich.

hinzugefügt EHC-Montag.jpeg
+0 −0 Datei ansehen

Binärdatei – kein Textvergleich möglich.

hinzugefügt EHC-Woche.jpeg
+0 −0 Datei ansehen

Binärdatei – kein Textvergleich möglich.

hinzugefügt Makefile
+56 −0 Datei ansehen
@@ -0,0 +1,56 @@
1 +
# TV-Wall - Bauen und Paketieren
2 +
#
3 +
#   make                 lokal bauen (build/tvwall)
4 +
#   make pi64            fuer Raspberry Pi OS 64 Bit (Pi 3/4/5)
5 +
#   make pi32            fuer Raspberry Pi OS 32 Bit (Pi 2/3/Zero 2)
6 +
#   make dist            fertiges Installationspaket bauen (dist/*.tar.gz)
7 +
#   make run             lokal starten (ohne mpv, Web auf :8080)
8 +
9 +
BINARY  := tvwall
10 +
VERSION ?= 1.0.0
11 +
LDFLAGS := -s -w -X main.version=$(VERSION)
12 +
PKG     := ./cmd/tvwall
13 +
14 +
.PHONY: all build pi64 pi32 dist clean run test fmt vet
15 +
16 +
all: build
17 +
18 +
build:
19 +
	@mkdir -p build
20 +
	go build -trimpath -ldflags "$(LDFLAGS)" -o build/$(BINARY) $(PKG)
21 +
22 +
pi64:
23 +
	@mkdir -p build
24 +
	GOOS=linux GOARCH=arm64 CGO_ENABLED=0 \
25 +
		go build -trimpath -ldflags "$(LDFLAGS)" -o build/$(BINARY)-linux-arm64 $(PKG)
26 +
27 +
pi32:
28 +
	@mkdir -p build
29 +
	GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 \
30 +
		go build -trimpath -ldflags "$(LDFLAGS)" -o build/$(BINARY)-linux-arm $(PKG)
31 +
32 +
# Paket zum Kopieren auf den Pi: Binaries + Installer + Konfiguration.
33 +
dist: pi64 pi32
34 +
	@rm -rf dist/$(BINARY)-$(VERSION)
35 +
	@mkdir -p dist/$(BINARY)-$(VERSION)/build
36 +
	cp build/$(BINARY)-linux-arm64 build/$(BINARY)-linux-arm dist/$(BINARY)-$(VERSION)/build/
37 +
	cp -r install.sh config systemd scripts README.md dist/$(BINARY)-$(VERSION)/
38 +
	chmod +x dist/$(BINARY)-$(VERSION)/install.sh dist/$(BINARY)-$(VERSION)/scripts/*
39 +
	cd dist && tar czf $(BINARY)-$(VERSION).tar.gz $(BINARY)-$(VERSION)
40 +
	@echo
41 +
	@echo "Fertig: dist/$(BINARY)-$(VERSION).tar.gz"
42 +
43 +
fmt:
44 +
	gofmt -w ./cmd ./internal
45 +
46 +
vet:
47 +
	go vet ./...
48 +
49 +
test:
50 +
	go test ./...
51 +
52 +
run: build
53 +
	./build/$(BINARY) -no-player -config /dev/null -state-dir ./.devstate -addr :8080
54 +
55 +
clean:
56 +
	rm -rf build dist
hinzugefügt README.md
+345 −0 Datei ansehen
@@ -0,0 +1,345 @@
1 +
# TV-Wall
2 +
3 +
Digitale Anzeigetafel für den Raspberry Pi: Bilder und Videos aus einem Ordner
4 +
laufen im Vollbild in Endlosschleife. Neue Inhalte kommen per Webinterface auf
5 +
das Gerät — der Pi spannt dafür ein eigenes WLAN auf, in das man sich mit Handy
6 +
oder Notebook einwählt.
7 +
8 +
* **Wiedergabe** — Bilder je 5 Sekunden (einstellbar), Videos komplett, danach
9 +
  von vorne. Der Ordner wird alle 10 Sekunden neu eingelesen, neue Dateien
10 +
  landen automatisch hinten in der Playliste.
11 +
* **Webinterface** — Hochladen per Fingertipp, Reihenfolge per Ziehen ändern,
12 +
  Löschen, Pause/Weiter, Anzeigedauer umstellen.
13 +
* **Access Point** — eigenes WLAN mit frei wählbarem Namen, damit sich mehrere
14 +
  Geräte („TVWall-01“, „TVWall-02“, …) sauber unterscheiden lassen.
15 +
* **Autostart** — ein systemd-Dienst, der beim Einschalten von selbst hochfährt.
16 +
17 +
Ein einziges Go-Programm ohne Fremdbibliotheken, dazu `mpv` als Abspieler.
18 +
19 +
---
20 +
21 +
## Schnellstart
22 +
23 +
### 1. Auf dem Entwicklungsrechner bauen
24 +
25 +
```bash
26 +
make dist
27 +
```
28 +
29 +
Das erzeugt `dist/tvwall-<version>.tar.gz` mit fertigen Programmdateien für
30 +
64- und 32-Bit-Raspberry-Pi-Systeme.
31 +
32 +
### 2. Auf den Pi kopieren
33 +
34 +
```bash
35 +
scp dist/tvwall-*.tar.gz pi@raspberrypi.local:~
36 +
ssh pi@raspberrypi.local
37 +
tar xzf tvwall-*.tar.gz && cd tvwall-*
38 +
```
39 +
40 +
### 3. Namen vergeben und installieren
41 +
42 +
```bash
43 +
nano config/tvwall.conf      # AP_SSID und HOSTNAME für diesen Pi setzen
44 +
sudo ./install.sh
45 +
```
46 +
47 +
Der Installer richtet alles ein: Pakete, Benutzer, Medienordner, systemd-Dienst
48 +
und den Access Point. Danach ist das Gerät sofort einsatzbereit und startet bei
49 +
jedem Einschalten von allein.
50 +
51 +
> Alternativ direkt auf dem Pi bauen: Go ≥ 1.21 installieren
52 +
> (`sudo apt install golang-go`) und `sudo ./install.sh` im Quellverzeichnis
53 +
> aufrufen — der Installer übersetzt dann selbst.
54 +
55 +
### 4. Benutzen
56 +
57 +
1. Am Handy mit dem WLAN **TVWall-01** verbinden (Passwort aus `tvwall.conf`).
58 +
2. `http://192.168.4.1` bzw. `http://tvwall` im Browser öffnen.
59 +
3. Bilder und Videos hochladen, Reihenfolge ziehen — fertig.
60 +
61 +
---
62 +
63 +
## Konfiguration
64 +
65 +
Alle Einstellungen stehen in **`/etc/tvwall/tvwall.conf`**. Nach Änderungen:
66 +
67 +
```bash
68 +
sudo systemctl restart tvwall
69 +
```
70 +
71 +
| Schlüssel | Bedeutung |
72 +
|---|---|
73 +
| `AP_SSID` | Name des WLANs — **pro Pi eindeutig vergeben** |
74 +
| `AP_PASSWORD` | WLAN-Passwort, mindestens 8 Zeichen |
75 +
| `AP_COUNTRY` | Ländercode (`DE`); ohne ihn bleibt das WLAN aus |
76 +
| `AP_IP` | Adresse des Pi im eigenen Netz (Standard `192.168.4.1`) |
77 +
| `AP_CHANNEL` | WLAN-Kanal 1–11 |
78 +
| `CAPTIVE_PORTAL` | `yes` = Handys öffnen die Seite von selbst |
79 +
| `HOSTNAME` | Gerätename, auch als `http://<name>.local` erreichbar |
80 +
| `WEB_PORT` | Port des Webinterface (Standard 80) |
81 +
| `WEB_PASSWORD` | gesetzt = Webinterface fragt nach einem Passwort |
82 +
| `MAX_UPLOAD_MB` | Obergrenze je Upload-Vorgang |
83 +
| `IMAGE_DURATION` | Anzeigedauer für Bilder in Sekunden |
84 +
| `MUTE` | `yes` = Videos ohne Ton |
85 +
| `SCAN_INTERVAL` | Takt, in dem der Ordner neu eingelesen wird |
86 +
| `DISPLAY_MODE` | `auto` (empfohlen), `drm` oder `desktop` |
87 +
| `RUN_USER` | Benutzer des Dienstes |
88 +
| `FRESH_MPV_FOR_VIDEO` | `yes` startet mpv vor jedem Video neu (umgeht den `partial file`-Abbruch) |
89 +
| `MPV_HWDEC` | `no` (Standard, Software) oder `auto-safe` (Hardware, nur Pi 4) |
90 +
| `STALL_TIMEOUT` | Sekunden ohne Fortschritt, bevor ein Video übersprungen wird |
91 +
| `MPV_EXTRA_ARGS` | zusätzliche mpv-Optionen, z. B. `--video-rotate=90` |
92 +
| `STATE_DIR` | Datenverzeichnis, Medien liegen in `<STATE_DIR>/media` |
93 +
94 +
### Mehrere Geräte
95 +
96 +
Für jeden Pi nur zwei Zeilen anpassen:
97 +
98 +
```bash
99 +
AP_SSID="TVWall-02"
100 +
HOSTNAME="tvwall-02"
101 +
```
102 +
103 +
Danach `sudo ./install.sh` — oder auf einem bereits eingerichteten Gerät nach
104 +
dem Ändern der Datei:
105 +
106 +
```bash
107 +
sudo /opt/tvwall/scripts/setup-ap.sh
108 +
```
109 +
110 +
Alle Geräte dürfen dieselbe IP `192.168.4.1` verwenden: Man ist immer nur mit
111 +
einem WLAN gleichzeitig verbunden.
112 +
113 +
### Bildausgabe: `auto`, `drm` oder `desktop`
114 +
115 +
Ein Bildschirm hat immer nur einen Herrn. Läuft ein Desktop, gehört ihm die
116 +
Grafikausgabe — mpv kommt dann nur über die Sitzung dieses Desktops daran und
117 +
**nur als der Benutzer, dem die Sitzung gehört**. Läuft kein Desktop, malt mpv
118 +
direkt über DRM/KMS. Genau darum geht es bei dieser Einstellung.
119 +
120 +
* **`auto` (empfohlen)** — der Dienst sucht beim Start nach einer erreichbaren
121 +
  Wayland- oder X11-Sitzung und nimmt sonst DRM. Der Installer erkennt zusätzlich
122 +
  am Boot-Ziel, ob der Pi in den Desktop startet, und trägt bei Bedarf den
123 +
  Desktop-Benutzer als `RUN_USER` ein.
124 +
* **`drm`** — erzwingt die direkte Ausgabe. Richtig für **Raspberry Pi OS Lite**.
125 +
  Der Installer schaltet dafür den Login-Prompt auf `tty1` ab.
126 +
* **`desktop`** — erzwingt die Ausgabe über die laufende Desktop-Sitzung. Dann
127 +
  muss `RUN_USER` der Desktop-Benutzer sein (meist `pi`) und der Pi sich
128 +
  automatisch anmelden: `sudo raspi-config` → *System Options* → *Boot / Auto
129 +
  Login* → *Desktop Autologin*.
130 +
131 +
Der Wayland-Socket heißt je nach Oberfläche `wayland-0` oder `wayland-1` — er
132 +
wird gesucht, nicht geraten. Welchen Weg der Dienst tatsächlich gewählt hat,
133 +
steht im Protokoll und unten im Webinterface.
134 +
135 +
---
136 +
137 +
## Bedienung
138 +
139 +
### Webinterface
140 +
141 +
| Bereich | Funktion |
142 +
|---|---|
143 +
| **Hochladen** | Dateien ziehen oder antippen; mehrere gleichzeitig möglich |
144 +
| **Wiedergabe** | Zurück / Pause / Weiter, „Neu laden“ springt sofort in die aktuelle Playliste |
145 +
| **Einstellungen** | Anzeigedauer für Bilder, Ton stummschalten |
146 +
| **Playliste** | Reihenfolge per Ziehen am Griff `⠿` oder mit ↑ ↓, Löschen per 🗑 |
147 +
148 +
Reihenfolge und Einstellungen bleiben über einen Neustart hinweg erhalten.
149 +
150 +
### Ohne Webinterface
151 +
152 +
Dateien lassen sich auch direkt ablegen — per USB-Stick, `scp` oder
153 +
Netzwerkfreigabe:
154 +
155 +
```bash
156 +
scp urlaub.mp4 pi@tvwall-01.local:/var/lib/tvwall/media/
157 +
```
158 +
159 +
Sie tauchen beim nächsten Durchlauf (Standard: 10 Sekunden) automatisch in der
160 +
Playliste auf.
161 +
162 +
### Unterstützte Formate
163 +
164 +
* **Bilder:** jpg, jpeg, png, gif, bmp, webp, tif, tiff
165 +
* **iPhone-Fotos:** heic und heif werden beim Hochladen automatisch in JPEG
166 +
  umgewandelt (dafür wird ffmpeg gebraucht)
167 +
* **Videos:** mp4, m4v, mov, mkv, avi, webm, mpg, mpeg, wmv, flv, ts, 3gp, ogv
168 +
169 +
Alles andere wird beim Hochladen abgelehnt und im Ordner ignoriert.
170 +
171 +
---
172 +
173 +
## Betrieb
174 +
175 +
```bash
176 +
sudo /opt/tvwall/scripts/diagnose.sh   # Selbsttest bei Problemen
177 +
systemctl status tvwall          # Zustand
178 +
journalctl -u tvwall -f          # Protokoll mitlesen
179 +
sudo systemctl restart tvwall    # neu starten
180 +
sudo systemctl disable tvwall    # Autostart abschalten
181 +
```
182 +
183 +
Aktualisieren: neues Paket auspacken und `sudo ./install.sh` erneut ausführen.
184 +
Eine vorhandene `/etc/tvwall/tvwall.conf` bleibt dabei unangetastet; die neue
185 +
Fassung landet als `tvwall.conf.neu` daneben.
186 +
187 +
Entfernen:
188 +
189 +
```bash
190 +
sudo /opt/tvwall/scripts/uninstall.sh          # Medien bleiben erhalten
191 +
sudo /opt/tvwall/scripts/uninstall.sh --purge  # alles löschen
192 +
```
193 +
194 +
---
195 +
196 +
## Wenn etwas klemmt
197 +
198 +
**Der Bildschirm bleibt schwarz.**
199 +
Zuerst den Selbsttest laufen lassen — er prüft Dienst, Benutzer, Gruppen,
200 +
Bildschirm, Medien, Webinterface und Access Point und nennt die nötigen Schritte:
201 +
202 +
```bash
203 +
sudo /opt/tvwall/scripts/diagnose.sh
204 +
```
205 +
206 +
Die beiden häufigsten Ursachen:
207 +
208 +
1. **Ein Desktop läuft, der Dienst aber unter dem falschen Benutzer.** Die
209 +
   Desktop-Sitzung von `pi` liegt in `/run/user/1000` und ist für den Benutzer
210 +
   `tvwall` schlicht nicht zugänglich. Abhilfe: in `/etc/tvwall/tvwall.conf`
211 +
   `RUN_USER="pi"` und `DISPLAY_MODE="desktop"` setzen, dann
212 +
   `sudo /opt/tvwall/install.sh --no-ap`.
213 +
2. **`DISPLAY_MODE="drm"` auf einem Desktop-System.** Der Compositor hält den
214 +
   Bildschirm bereits, mpv kommt nicht daran. Abhilfe: `DISPLAY_MODE="auto"` —
215 +
   oder in `raspi-config` auf *Console Autologin* umstellen.
216 +
217 +
Zum Prüfen, ob mpv überhaupt ein Bild bekommt (Dienst vorher mit
218 +
`sudo systemctl stop tvwall` anhalten):
219 +
220 +
```bash
221 +
# mit Desktop
222 +
sudo -u pi XDG_RUNTIME_DIR=/run/user/1000 mpv --fullscreen /var/lib/tvwall/media/<datei>
223 +
# ohne Desktop
224 +
sudo -u tvwall mpv --vo=gpu --gpu-context=drm --fullscreen /var/lib/tvwall/media/<datei>
225 +
```
226 +
227 +
**Das WLAN taucht nicht auf.**
228 +
229 +
```bash
230 +
nmcli connection show --active     # läuft tvwall-ap?
231 +
rfkill list                        # Funk gesperrt?
232 +
sudo /opt/tvwall/scripts/setup-ap.sh
233 +
```
234 +
235 +
Häufigste Ursache: Der Pi ist noch mit einem anderen WLAN verbunden. Eine
236 +
Schnittstelle kann nicht gleichzeitig Client und Access Point sein —
237 +
andere WLAN-Verbindungen mit `nmcli connection modify <name> connection.autoconnect no`
238 +
abschalten. Zweithäufigste Ursache: fehlender Ländercode (`AP_COUNTRY`).
239 +
240 +
**Webinterface nicht erreichbar.**
241 +
Zuerst `http://192.168.4.1` probieren — Namen wie `tvwall.local` funktionieren
242 +
nicht auf allen Handys. Läuft der Dienst? `systemctl status tvwall`.
243 +
244 +
**Ein Video bleibt nach ein paar Sekunden stehen.**
245 +
Seit Version 1.0.0 wird jede Datei einzeln an mpv übergeben; der früher
246 +
beobachtete Abbruch mit `partial file` beim Übergang Bild → Video ist damit
247 +
erledigt. Tritt trotzdem etwas auf, misst `scripts/bisect-mpv.sh` jede Variante
248 +
einzeln durch.
249 +
250 +
Zuerst `MPV_HWDEC="no"` prüfen (seit Version 1.0.0 die Voreinstellung). Der
251 +
Raspberry Pi 5 hat *keinen* Hardware-Dekoder für H.264 mehr; mit `auto-safe`
252 +
läuft ein Video dort ein bis zwei Sekunden und friert dann ein — genau so lange,
253 +
wie der bereits gefüllte Puffer reicht. Zum Gegenprüfen hilft der direkte
254 +
Vergleich, weil mpv von sich aus `--hwdec=no` verwendet:
255 +
256 +
```bash
257 +
sudo systemctl stop tvwall
258 +
mpv --hwdec=no        --fullscreen /var/lib/tvwall/media/<datei>   # läuft
259 +
mpv --hwdec=auto-safe --fullscreen /var/lib/tvwall/media/<datei>   # friert ein
260 +
sudo systemctl start tvwall
261 +
```
262 +
263 +
Bleibt es dabei, misst dieses Skript jede Variante einzeln durch und nennt die
264 +
verantwortliche Option — statt zu raten:
265 +
266 +
```bash
267 +
sudo /opt/tvwall/scripts/bisect-mpv.sh /var/lib/tvwall/media/<datei>
268 +
```
269 +
270 +
Was in der Datei steckt, zeigt `diagnose.sh` oder direkt:
271 +
272 +
```bash
273 +
ffprobe -hide_banner /var/lib/tvwall/media/<datei>
274 +
```
275 +
276 +
Ist das Material breiter als 1920 Pixel (Handyvideos sind oft 4K), hilft nur
277 +
Herunterrechnen — am besten auf dem Rechner, nicht auf dem Pi:
278 +
279 +
```bash
280 +
ffmpeg -i gross.mov -vf scale=1920:-2 -c:v libx264 -preset slow -crf 23 \
281 +
       -c:a aac -movflags +faststart klein.mp4
282 +
```
283 +
284 +
Die Wiedergabe bleibt dabei nicht hängen: Läuft ein Video `STALL_TIMEOUT`
285 +
Sekunden (Standard 30) nicht weiter, wird es übersprungen und die Schleife
286 +
läuft weiter. Im Webinterface steht dann, welche Datei betroffen war.
287 +
288 +
**Kein Ton.**
289 +
Ausgabegerät prüfen (`mpv --audio-device=help`) und passend in
290 +
`MPV_EXTRA_ARGS="--audio-device=..."` eintragen. Bei HDMI-Ton hilft oft
291 +
`--audio-device=alsa/sysdefault:CARD=vc4hdmi0`.
292 +
293 +
---
294 +
295 +
## Aufbau
296 +
297 +
```
298 +
cmd/tvwall/main.go        Start, Signalbehandlung, periodischer Ordner-Durchlauf
299 +
internal/config/          tvwall.conf lesen, Laufzeit-Einstellungen speichern
300 +
internal/playlist/        Ordner einlesen, Reihenfolge merken, playlist.m3u schreiben
301 +
internal/mpv/             JSON-IPC-Verbindung zu mpv
302 +
internal/player/          mpv starten, überwachen, Dateien einzeln weiterschalten
303 +
internal/web/             HTTP-Schnittstelle und Oberfläche (eingebettet)
304 +
systemd/                  Vorlagen für den Dienst (drm / desktop)
305 +
internal/player/display.go  Wayland-/X11-/DRM-Erkennung
306 +
scripts/setup-ap.sh       Access Point per nmcli
307 +
scripts/diagnose.sh       Selbsttest bei Problemen
308 +
scripts/bisect-mpv.sh     misst, welche mpv-Option einen Haenger ausloest
309 +
scripts/probe-transition.py  prueft den Uebergang Bild -> Video im selben mpv
310 +
install.sh                Installation auf dem Pi
311 +
```
312 +
313 +
Ein paar Entscheidungen, die vielleicht nicht offensichtlich sind:
314 +
315 +
* **mpv spielt Bilder und Videos aus einer gemeinsamen Playliste.** Kein
316 +
  Umschalten zwischen zwei Programmen, keine Lücke am Übergang.
317 +
* **Die Playliste wird zwischen zwei Dateien getauscht**, nicht mittendrin: Ein
318 +
  Upload unterbricht kein laufendes Video. Wer nicht warten will, drückt
319 +
  „Neu laden“.
320 +
* **Die Oberfläche kommt ohne externe Bibliotheken aus** und ist ins Programm
321 +
  eingebettet. Am Access Point gibt es kein Internet — alles, was von einem CDN
322 +
  nachgeladen werden müsste, wäre dort tot.
323 +
* **Uploads landen erst in einer temporären Datei** und werden nach vollem
324 +
  Empfang umbenannt. So gerät keine halbe Datei in die Wiedergabe.
325 +
326 +
### Web-Schnittstelle
327 +
328 +
| Weg | Methode | Zweck |
329 +
|---|---|---|
330 +
| `/api/state` | GET | Playliste, Zustand, Einstellungen |
331 +
| `/api/upload` | POST | Dateien hochladen (multipart) |
332 +
| `/api/order` | POST | `{"order":["a.jpg","b.mp4"]}` |
333 +
| `/api/delete` | POST | `{"name":"a.jpg"}` |
334 +
| `/api/settings` | POST | `{"image_duration":5,"mute":false}` |
335 +
| `/api/control` | POST | `{"action":"next\|prev\|pause\|reload\|restart\|rescan"}` |
336 +
| `/thumb/<name>` | GET | Vorschaubild (per ffmpeg erzeugt, zwischengespeichert) |
337 +
| `/media/<name>` | GET | Originaldatei |
338 +
339 +
### Lokal ausprobieren
340 +
341 +
Ohne Pi, nur die Oberfläche (kein mpv nötig):
342 +
343 +
```bash
344 +
make run     # http://localhost:8080
345 +
```
hinzugefügt cmd/tvwall/main.go
+130 −0 Datei ansehen
@@ -0,0 +1,130 @@
1 +
// Kommando tvwall: Media-Player und Webinterface fuer die TV-Wall.
2 +
//
3 +
// Der Dienst liest regelmaessig den Medienordner ein, haelt daraus eine
4 +
// Playliste aktuell, laesst sie von mpv im Vollbild in Endlosschleife
5 +
// abspielen und stellt zum Hochladen und Sortieren ein Webinterface bereit.
6 +
package main
7 +
8 +
import (
9 +
	"context"
10 +
	"errors"
11 +
	"flag"
12 +
	"fmt"
13 +
	"log"
14 +
	"net/http"
15 +
	"os"
16 +
	"os/signal"
17 +
	"syscall"
18 +
	"time"
19 +
20 +
	"ehc/tvwall/internal/config"
21 +
	"ehc/tvwall/internal/player"
22 +
	"ehc/tvwall/internal/playlist"
23 +
	"ehc/tvwall/internal/web"
24 +
)
25 +
26 +
// version wird beim Bauen ueber -ldflags gesetzt.
27 +
var version = "dev"
28 +
29 +
func main() {
30 +
	var (
31 +
		configPath = flag.String("config", "/etc/tvwall/tvwall.conf", "Pfad zur Konfigurationsdatei")
32 +
		addr       = flag.String("addr", "", "Adresse des Webservers (ueberschreibt WEB_PORT)")
33 +
		stateDir   = flag.String("state-dir", "", "Datenverzeichnis (ueberschreibt STATE_DIR)")
34 +
		mpvSocket  = flag.String("mpv-socket", "", "Pfad des mpv-IPC-Sockets (ueberschreibt MPV_SOCKET)")
35 +
		noPlayer   = flag.Bool("no-player", false, "nur das Webinterface starten, kein mpv")
36 +
		showVer    = flag.Bool("version", false, "Version ausgeben und beenden")
37 +
	)
38 +
	flag.Parse()
39 +
40 +
	if *showVer {
41 +
		fmt.Printf("tvwall %s\n", version)
42 +
		return
43 +
	}
44 +
45 +
	log.SetFlags(log.Ldate | log.Ltime)
46 +
	log.Printf("tvwall %s startet", version)
47 +
48 +
	cfg, err := config.Load(*configPath)
49 +
	if err != nil {
50 +
		log.Fatalf("konfiguration %s: %v", *configPath, err)
51 +
	}
52 +
	if *stateDir != "" {
53 +
		cfg.StateDir = *stateDir
54 +
	}
55 +
	if *mpvSocket != "" {
56 +
		cfg.SocketPath = *mpvSocket
57 +
	}
58 +
	for _, dir := range []string{cfg.StateDir, cfg.MediaDir(), cfg.ThumbDir()} {
59 +
		if err := os.MkdirAll(dir, 0o775); err != nil {
60 +
			log.Fatalf("verzeichnis %s anlegen: %v", dir, err)
61 +
		}
62 +
	}
63 +
	log.Printf("medienordner: %s", cfg.MediaDir())
64 +
65 +
	settings := config.NewStore(cfg)
66 +
	pl := playlist.New(cfg)
67 +
	if _, err := pl.Scan(); err != nil {
68 +
		log.Printf("erster durchlauf des medienordners: %v", err)
69 +
	}
70 +
	log.Printf("playliste: %d eintraege", len(pl.Entries()))
71 +
72 +
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
73 +
	defer stop()
74 +
75 +
	pp := player.New(cfg, pl, settings)
76 +
	if *noPlayer {
77 +
		log.Printf("player deaktiviert (-no-player)")
78 +
	} else {
79 +
		go pp.Run(ctx)
80 +
	}
81 +
82 +
	go watchMedia(ctx, pl, time.Duration(cfg.ScanInterval)*time.Second)
83 +
84 +
	listen := *addr
85 +
	if listen == "" {
86 +
		listen = fmt.Sprintf(":%d", cfg.WebPort)
87 +
	}
88 +
	srv := web.New(cfg, pl, pp, settings)
89 +
90 +
	errCh := make(chan error, 1)
91 +
	go func() {
92 +
		if err := srv.ListenAndServe(listen); err != nil && !errors.Is(err, http.ErrServerClosed) {
93 +
			errCh <- err
94 +
		}
95 +
	}()
96 +
97 +
	select {
98 +
	case <-ctx.Done():
99 +
		log.Printf("beende …")
100 +
		// Dem Player einen Moment geben, mpv sauber zu beenden.
101 +
		time.Sleep(300 * time.Millisecond)
102 +
	case err := <-errCh:
103 +
		log.Fatalf("webserver: %v", err)
104 +
	}
105 +
}
106 +
107 +
// watchMedia liest den Medienordner regelmaessig ein. So landen auch Dateien
108 +
// in der Playliste, die per USB-Stick, SSH oder Netzwerkfreigabe ankommen.
109 +
func watchMedia(ctx context.Context, pl *playlist.Manager, every time.Duration) {
110 +
	if every < time.Second {
111 +
		every = 10 * time.Second
112 +
	}
113 +
	ticker := time.NewTicker(every)
114 +
	defer ticker.Stop()
115 +
	for {
116 +
		select {
117 +
		case <-ctx.Done():
118 +
			return
119 +
		case <-ticker.C:
120 +
			changed, err := pl.Scan()
121 +
			if err != nil {
122 +
				log.Printf("medienordner einlesen: %v", err)
123 +
				continue
124 +
			}
125 +
			if changed {
126 +
				log.Printf("medienordner geaendert: %d eintraege", len(pl.Entries()))
127 +
			}
128 +
		}
129 +
	}
130 +
}
geändert config/tvwall.conf
+73 −21 Datei ansehen
@@ -1,55 +1,107 @@
1 -
# ============================================================
1 +
# ============================================================================
2 2
#  TV-Wall Konfiguration
3 -
#  Wird von install.sh und scripts/setup-ap.sh eingelesen.
4 -
#  Nach Aenderungen:  sudo /opt/tvwall/install.sh   (bzw. setup-ap.sh)
5 -
# ============================================================
3 +
#
4 +
#  Installiert nach /etc/tvwall/tvwall.conf
5 +
#  Nach Aenderungen:   sudo systemctl restart tvwall
6 +
#  Nach Aenderung des WLAN-Namens zusaetzlich:
7 +
#                      sudo /opt/tvwall/scripts/setup-ap.sh
8 +
# ============================================================================
6 9
7 10
# --- WLAN Access Point ------------------------------------------------------
8 -
# Name des WLANs, mit dem man sich verbindet. MUSS pro Pi eindeutig sein!
11 +
12 +
# Name des WLANs, mit dem man sich verbindet.
13 +
# MUSS pro Pi eindeutig sein, damit man die Geraete unterscheiden kann.
9 14
AP_SSID="TVWall-01"
10 15
11 -
# WPA2-Passwort (mindestens 8 Zeichen)
16 +
# WPA2-Passwort, mindestens 8 Zeichen.
12 17
AP_PASSWORD="tvwall1234"
13 18
14 -
# Laendercode - wichtig, sonst bleibt das WLAN aus regulatorischen Gruenden aus
19 +
# Laendercode. Ohne passenden Code bleibt das WLAN aus rechtlichen Gruenden aus.
15 20
AP_COUNTRY="DE"
16 21
17 -
# IP-Adresse des Pi im eigenen WLAN. Das Webinterface laeuft auf dieser Adresse.
22 +
# Adresse des Pi im eigenen WLAN - unter dieser Adresse laeuft das Webinterface.
18 23
AP_IP="192.168.4.1"
19 24
20 -
# WLAN-Kanal (1-11 im 2.4-GHz-Band)
25 +
# WLAN-Kanal (1-11 im 2,4-GHz-Band).
21 26
AP_CHANNEL="7"
22 27
23 -
# Hostname des Pi. Erreichbar dann auch als http://<HOSTNAME>.local
28 +
# WLAN-Schnittstelle. Leer = automatisch erkennen (in der Regel wlan0).
29 +
AP_IFACE=""
30 +
31 +
# "yes" leitet jeden Namen auf den Pi um. Handys zeigen dann von selbst die
32 +
# Anmeldeseite an - manche trennen dafuer aber auch automatisch das WLAN.
33 +
CAPTIVE_PORTAL="no"
34 +
35 +
# Hostname des Pi. Erreichbar dann auch unter http://<HOSTNAME>.local
24 36
HOSTNAME="tvwall-01"
25 37
26 38
# --- Webinterface -----------------------------------------------------------
39 +
27 40
WEB_PORT="80"
28 41
29 -
# Optionales Passwort fuer das Webinterface. Leer = kein Login noetig.
42 +
# Passwortschutz fuer das Webinterface (Benutzername beliebig).
43 +
# Leer lassen = ohne Anmeldung erreichbar.
30 44
WEB_PASSWORD=""
31 45
32 -
# Maximale Uploadgroesse in MB
33 -
MAX_UPLOAD_MB="10048"
46 +
# Groesstmoeglicher Upload je Vorgang, in MB.
47 +
MAX_UPLOAD_MB="2048"
34 48
35 49
# --- Wiedergabe -------------------------------------------------------------
36 -
# Anzeigedauer fuer Bilder in Sekunden (spaeter auch im Webinterface aenderbar)
50 +
51 +
# Anzeigedauer fuer Bilder in Sekunden.
52 +
# Laesst sich spaeter auch im Webinterface umstellen.
37 53
IMAGE_DURATION="5"
38 54
55 +
# Tonausgabe ueberhaupt benutzen:  yes = ja,  no = mpv oeffnet kein Audiogeraet.
56 +
# "no" ist fuer eine reine Anzeigetafel der sicherere Weg: ein blockierendes
57 +
# oder falsch eingestelltes Audiogeraet kann sonst die Wiedergabe anhalten.
58 +
AUDIO="yes"
59 +
39 60
# Ton der Videos:  yes = stumm,  no = mit Ton
61 +
# (nur wirksam, wenn AUDIO="yes"; im Webinterface umschaltbar)
40 62
MUTE="no"
41 63
42 -
# Bildschirmausgabe:
43 -
#   drm     = Pi startet in die Konsole (Raspberry Pi OS Lite) -> empfohlen
44 -
#   desktop = Pi startet in den Desktop (labwc/wayfire/X11)
45 -
DISPLAY_MODE="drm"
64 +
# Wie oft der Medienordner nach neuen Dateien durchsucht wird (Sekunden).
65 +
SCAN_INTERVAL="10"
46 66
47 -
# Benutzer, unter dem der Player laeuft.
48 -
# Bei DISPLAY_MODE=desktop MUSS das der Desktop-Benutzer sein (meist "pi").
67 +
# Bildausgabe:
68 +
#   auto    = selbst erkennen (empfohlen)
69 +
#   drm     = direkt auf den Bildschirm, ohne Desktop (Raspberry Pi OS Lite)
70 +
#   desktop = in die laufende Desktop-Sitzung (labwc/wayfire/X11)
71 +
DISPLAY_MODE="auto"
72 +
73 +
# Benutzer, unter dem der Dienst laeuft.
74 +
# Wichtig: Laeuft ein Desktop, muss das der Benutzer sein, dem die
75 +
# Desktop-Sitzung gehoert (meist "pi") - nur der kommt an den Bildschirm.
76 +
# Bei DISPLAY_MODE="auto" erkennt install.sh das und traegt es hier ein.
49 77
RUN_USER="tvwall"
50 78
51 -
# Zusaetzliche mpv-Optionen (leer lassen, wenn unsicher)
79 +
# Hardwarebeschleunigte Videodekodierung.
80 +
#   no        = alles in Software dekodieren (Standard, mpvs eigene Vorgabe)
81 +
#   auto-safe = Hardware benutzen, wenn moeglich
82 +
# Der Raspberry Pi 5 hat KEINEN Hardware-Dekoder fuer H.264 - dort bleibt die
83 +
# Wiedergabe mit "auto-safe" nach ein bis zwei Sekunden stehen. Auf dem Pi 4
84 +
# kann "auto-safe" dagegen Rechenzeit sparen.
85 +
MPV_HWDEC="no"
86 +
87 +
# mpv vor jedem Video frisch starten.
88 +
# Auf manchen Systemen bricht ein Video ab, wenn mpv vorher schon ein Bild
89 +
# angezeigt hat ("partial file" im Protokoll). Ein frischer Prozess je Video
90 +
# umgeht das; der Preis ist ein kurzer schwarzer Moment davor.
91 +
# Mit scripts/probe-transition.py laesst sich pruefen, ob "no" reicht.
92 +
FRESH_MPV_FOR_VIDEO="yes"
93 +
94 +
# Laeuft ein Video so lange nicht weiter (Sekunden), wird es uebersprungen,
95 +
# damit eine einzelne Datei nicht die ganze Wiedergabe anhaelt. 0 = aus.
96 +
STALL_TIMEOUT="30"
97 +
98 +
# Zusaetzliche Optionen fuer mpv, z. B. --audio-device=... oder --video-rotate=90
52 99
MPV_EXTRA_ARGS=""
53 100
54 101
# --- Pfade ------------------------------------------------------------------
102 +
103 +
# Hier liegen Medien (Unterordner "media"), Playliste und Einstellungen.
55 104
STATE_DIR="/var/lib/tvwall"
105 +
106 +
# Steuersocket fuer mpv.
107 +
MPV_SOCKET="/run/tvwall/mpv.sock"
hinzugefügt install.sh
+340 −0 Datei ansehen
@@ -0,0 +1,340 @@
1 +
#!/usr/bin/env bash
2 +
# ===========================================================================
3 +
#  TV-Wall - Installation auf dem Raspberry Pi (Raspberry Pi OS / Raspbian)
4 +
#
5 +
#  Aufruf:   sudo ./install.sh [Optionen]
6 +
#
7 +
#  Optionen:
8 +
#    --no-ap        WLAN-Access-Point nicht einrichten
9 +
#    --ap-only      nur den Access Point einrichten
10 +
#    --config PFAD  andere Konfigurationsdatei verwenden
11 +
#    --yes          keine Rueckfragen stellen
12 +
# ===========================================================================
13 +
set -euo pipefail
14 +
15 +
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16 +
CONFIG_DST="/etc/tvwall/tvwall.conf"
17 +
BIN_DST="/usr/local/bin/tvwall"
18 +
UNIT_DST="/etc/systemd/system/tvwall.service"
19 +
DOC_DST="/opt/tvwall"
20 +
21 +
WITH_AP=1
22 +
AP_ONLY=0
23 +
ASSUME_YES=0
24 +
CONFIG_SRC=""
25 +
26 +
info()  { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
27 +
ok()    { printf '\033[1;32m  ✓\033[0m %s\n' "$*"; }
28 +
warn()  { printf '\033[1;33m  !\033[0m %s\n' "$*"; }
29 +
die()   { printf '\033[1;31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
30 +
31 +
while [[ $# -gt 0 ]]; do
32 +
  case "$1" in
33 +
    --no-ap)   WITH_AP=0; shift ;;
34 +
    --ap-only) AP_ONLY=1; shift ;;
35 +
    --yes|-y)  ASSUME_YES=1; shift ;;
36 +
    --config)  CONFIG_SRC="${2:-}"; shift 2 ;;
37 +
    -h|--help) sed -n '2,15p' "$0"; exit 0 ;;
38 +
    *)         die "unbekannte Option: $1" ;;
39 +
  esac
40 +
done
41 +
42 +
[[ $EUID -eq 0 ]] || die "bitte mit sudo starten:  sudo $0"
43 +
44 +
# ---------------------------------------------------------------------------
45 +
# Konfiguration einlesen
46 +
# ---------------------------------------------------------------------------
47 +
if [[ -z "$CONFIG_SRC" ]]; then
48 +
  if [[ -f "$CONFIG_DST" ]]; then
49 +
    CONFIG_SRC="$CONFIG_DST"          # bestehende Installation: nicht ueberschreiben
50 +
  else
51 +
    CONFIG_SRC="$SRC_DIR/config/tvwall.conf"
52 +
  fi
53 +
fi
54 +
[[ -f "$CONFIG_SRC" ]] || die "Konfigurationsdatei nicht gefunden: $CONFIG_SRC"
55 +
56 +
set -a
57 +
# shellcheck disable=SC1090
58 +
. "$CONFIG_SRC"
59 +
set +a
60 +
61 +
AP_SSID="${AP_SSID:-TVWall-01}"
62 +
RUN_USER="${RUN_USER:-tvwall}"
63 +
STATE_DIR="${STATE_DIR:-/var/lib/tvwall}"
64 +
DISPLAY_MODE="${DISPLAY_MODE:-auto}"
65 +
HOSTNAME_NEW="${HOSTNAME:-tvwall-01}"
66 +
WEB_PORT="${WEB_PORT:-80}"
67 +
68 +
# --- Bildausgabe bestimmen --------------------------------------------------
69 +
# Der Desktop-Benutzer ist der einzige, der an die Sitzung herankommt.
70 +
detect_desktop_user() {
71 +
  local d u
72 +
  for d in /run/user/*; do
73 +
    [[ -d "$d" ]] || continue
74 +
    compgen -G "$d/wayland-*" >/dev/null 2>&1 || continue
75 +
    u="$(getent passwd "$(basename "$d")" | cut -d: -f1)"
76 +
    [[ -n "$u" ]] && { echo "$u"; return; }
77 +
  done
78 +
  u="$(sed -n 's/^autologin-user=//p' /etc/lightdm/lightdm.conf 2>/dev/null | head -1)"
79 +
  [[ -n "$u" ]] && { echo "$u"; return; }
80 +
  u="$(sed -n 's/.*--autologin \([^ ]*\).*/\1/p' \
81 +
       /etc/systemd/system/getty@tty1.service.d/autologin.conf 2>/dev/null | head -1)"
82 +
  [[ -n "$u" && "$u" != "root" ]] && { echo "$u"; return; }
83 +
  getent passwd 1000 | cut -d: -f1
84 +
}
85 +
86 +
GRAPHICAL=0
87 +
[[ "$(systemctl get-default 2>/dev/null)" == "graphical.target" ]] && GRAPHICAL=1
88 +
pgrep -x labwc  >/dev/null 2>&1 && GRAPHICAL=1
89 +
pgrep -x wayfire >/dev/null 2>&1 && GRAPHICAL=1
90 +
pgrep -x Xorg   >/dev/null 2>&1 && GRAPHICAL=1
91 +
92 +
DISPLAY_MODE_CHANGED=0
93 +
# Eine bestehende Konfiguration wird nicht ueberschrieben - deshalb kann hier
94 +
# noch ein "drm" aus einer frueheren Installation stehen. Auf einem System mit
95 +
# Desktop fuehrt das zwangslaeufig zum schwarzen Bildschirm.
96 +
if [[ "$DISPLAY_MODE" == "drm" && $GRAPHICAL -eq 1 ]]; then
97 +
  echo
98 +
  warn "In der Konfiguration steht DISPLAY_MODE=\"drm\", auf diesem Pi laeuft aber"
99 +
  warn "ein Desktop. mpv kann den Bildschirm so nicht bekommen - er bliebe schwarz."
100 +
  if [[ $ASSUME_YES -eq 1 ]]; then
101 +
    DISPLAY_MODE="auto"; DISPLAY_MODE_CHANGED=1
102 +
  else
103 +
    read -r -p "    Auf DISPLAY_MODE=\"auto\" umstellen? [J/n] " answer
104 +
    if [[ ! "$answer" =~ ^[nN]$ ]]; then
105 +
      DISPLAY_MODE="auto"; DISPLAY_MODE_CHANGED=1
106 +
    fi
107 +
  fi
108 +
  echo
109 +
fi
110 +
111 +
EFFECTIVE_MODE="$DISPLAY_MODE"
112 +
if [[ "$EFFECTIVE_MODE" == "auto" ]]; then
113 +
  if [[ $GRAPHICAL -eq 1 ]]; then
114 +
    EFFECTIVE_MODE="desktop"
115 +
  else
116 +
    EFFECTIVE_MODE="drm"
117 +
  fi
118 +
fi
119 +
120 +
RUN_USER_CHANGED=0
121 +
if [[ "$EFFECTIVE_MODE" == "desktop" && "$RUN_USER" == "tvwall" ]]; then
122 +
  DESKTOP_USER="$(detect_desktop_user || true)"
123 +
  if [[ -n "$DESKTOP_USER" ]]; then
124 +
    RUN_USER="$DESKTOP_USER"
125 +
    RUN_USER_CHANGED=1
126 +
  fi
127 +
fi
128 +
129 +
info "Konfiguration: $CONFIG_SRC"
130 +
echo "    WLAN-Name (SSID) : $AP_SSID"
131 +
echo "    Hostname         : $HOSTNAME_NEW"
132 +
if [[ "$DISPLAY_MODE" == "auto" ]]; then
133 +
  echo "    Bildausgabe      : auto -> $EFFECTIVE_MODE"
134 +
else
135 +
  echo "    Bildausgabe      : $EFFECTIVE_MODE"
136 +
fi
137 +
if [[ $RUN_USER_CHANGED -eq 1 ]]; then
138 +
  echo "    Benutzer         : $RUN_USER   (Desktop-Benutzer, automatisch erkannt)"
139 +
else
140 +
  echo "    Benutzer         : $RUN_USER"
141 +
fi
142 +
echo "    Medienordner     : $STATE_DIR/media"
143 +
echo
144 +
145 +
if [[ $AP_ONLY -eq 1 ]]; then
146 +
  exec "$SRC_DIR/scripts/setup-ap.sh" "$CONFIG_SRC"
147 +
fi
148 +
149 +
# ---------------------------------------------------------------------------
150 +
# Pakete
151 +
# ---------------------------------------------------------------------------
152 +
if command -v apt-get >/dev/null; then
153 +
  MISSING=()
154 +
  command -v mpv    >/dev/null || MISSING+=(mpv)
155 +
  command -v ffmpeg >/dev/null || MISSING+=(ffmpeg)
156 +
  if [[ ${#MISSING[@]} -gt 0 ]]; then
157 +
    info "Installiere Pakete: ${MISSING[*]}"
158 +
    DEBIAN_FRONTEND=noninteractive apt-get update -qq
159 +
    DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${MISSING[@]}"
160 +
  fi
161 +
  ok "mpv und ffmpeg vorhanden"
162 +
else
163 +
  warn "kein apt-get gefunden - mpv und ffmpeg bitte selbst installieren"
164 +
fi
165 +
166 +
# ---------------------------------------------------------------------------
167 +
# Programm installieren
168 +
# ---------------------------------------------------------------------------
169 +
info "Installiere Programm nach $BIN_DST"
170 +
ARCH="$(uname -m)"
171 +
case "$ARCH" in
172 +
  aarch64|arm64) PREBUILT="$SRC_DIR/build/tvwall-linux-arm64" ;;
173 +
  armv7l|armv6l) PREBUILT="$SRC_DIR/build/tvwall-linux-arm" ;;
174 +
  x86_64)        PREBUILT="$SRC_DIR/build/tvwall-linux-amd64" ;;
175 +
  *)             PREBUILT="" ;;
176 +
esac
177 +
178 +
if [[ -n "$PREBUILT" && -f "$PREBUILT" ]]; then
179 +
  install -m 0755 "$PREBUILT" "$BIN_DST"
180 +
  ok "fertiges Programm verwendet ($ARCH)"
181 +
elif command -v go >/dev/null && [[ -f "$SRC_DIR/go.mod" ]]; then
182 +
  info "Baue aus dem Quelltext (das dauert einen Moment)"
183 +
  ( cd "$SRC_DIR" && go build -trimpath -ldflags "-s -w" -o /tmp/tvwall.build ./cmd/tvwall )
184 +
  install -m 0755 /tmp/tvwall.build "$BIN_DST" && rm -f /tmp/tvwall.build
185 +
  ok "aus Quelltext gebaut"
186 +
elif [[ -x "$BIN_DST" ]]; then
187 +
  # Erneuter Aufruf aus /opt/tvwall, nur um die Konfiguration zu uebernehmen.
188 +
  ok "vorhandenes Programm beibehalten ($("$BIN_DST" -version 2>/dev/null || echo unbekannt))"
189 +
else
190 +
  die "kein passendes Programm gefunden ($ARCH) und Go ist nicht installiert.
191 +
       Auf dem Entwicklungsrechner 'make dist' ausfuehren und das Paket hierher kopieren."
192 +
fi
193 +
194 +
# ---------------------------------------------------------------------------
195 +
# Benutzer und Verzeichnisse
196 +
# ---------------------------------------------------------------------------
197 +
if ! id -u "$RUN_USER" >/dev/null 2>&1; then
198 +
  info "Lege Benutzer $RUN_USER an"
199 +
  useradd --system --create-home --home-dir "/home/$RUN_USER" --shell /usr/sbin/nologin "$RUN_USER"
200 +
fi
201 +
for grp in video render audio input tty; do
202 +
  getent group "$grp" >/dev/null && usermod -aG "$grp" "$RUN_USER" || true
203 +
done
204 +
ok "Benutzer $RUN_USER eingerichtet"
205 +
206 +
install -d -m 0775 -o "$RUN_USER" -g "$RUN_USER" "$STATE_DIR" "$STATE_DIR/media" "$STATE_DIR/thumbs"
207 +
chown -R "$RUN_USER":"$RUN_USER" "$STATE_DIR" 2>/dev/null || true
208 +
ok "Medienordner: $STATE_DIR/media"
209 +
210 +
install -d -m 0755 /etc/tvwall
211 +
if [[ -f "$CONFIG_DST" && "$CONFIG_SRC" != "$CONFIG_DST" ]]; then
212 +
  install -m 0644 "$CONFIG_SRC" "$CONFIG_DST.neu"
213 +
  warn "$CONFIG_DST existiert bereits - neue Fassung liegt als $CONFIG_DST.neu daneben"
214 +
elif [[ ! -f "$CONFIG_DST" ]]; then
215 +
  install -m 0644 "$CONFIG_SRC" "$CONFIG_DST"
216 +
  ok "Konfiguration nach $CONFIG_DST kopiert"
217 +
fi
218 +
219 +
if [[ $RUN_USER_CHANGED -eq 1 && -f "$CONFIG_DST" ]]; then
220 +
  sed -i "s|^RUN_USER=.*|RUN_USER=\"$RUN_USER\"|" "$CONFIG_DST"
221 +
  ok "RUN_USER in $CONFIG_DST auf '$RUN_USER' gesetzt (Desktop-Benutzer)"
222 +
fi
223 +
if [[ $DISPLAY_MODE_CHANGED -eq 1 && -f "$CONFIG_DST" ]]; then
224 +
  sed -i "s|^DISPLAY_MODE=.*|DISPLAY_MODE=\"auto\"|" "$CONFIG_DST"
225 +
  ok "DISPLAY_MODE in $CONFIG_DST auf 'auto' gesetzt"
226 +
fi
227 +
228 +
install -d -m 0755 "$DOC_DST"
229 +
[[ -f "$SRC_DIR/README.md" ]] && install -m 0644 "$SRC_DIR/README.md" "$DOC_DST/README.md"
230 +
if [[ -d "$SRC_DIR/scripts" ]]; then
231 +
  install -d -m 0755 "$DOC_DST/scripts"
232 +
  install -m 0755 "$SRC_DIR"/scripts/* "$DOC_DST/scripts/"
233 +
fi
234 +
# Installer und Vorlagen mitnehmen, damit spaetere Aenderungen ohne das
235 +
# Quellpaket auskommen:  sudo /opt/tvwall/install.sh --no-ap
236 +
if [[ "$SRC_DIR" != "$DOC_DST" ]]; then
237 +
  install -m 0755 "$SRC_DIR/install.sh" "$DOC_DST/install.sh"
238 +
  install -d -m 0755 "$DOC_DST/systemd" "$DOC_DST/config"
239 +
  install -m 0644 "$SRC_DIR"/systemd/*.in "$DOC_DST/systemd/"
240 +
  install -m 0644 "$SRC_DIR"/config/tvwall.conf "$DOC_DST/config/tvwall.conf"
241 +
fi
242 +
243 +
# ---------------------------------------------------------------------------
244 +
# systemd-Dienst
245 +
# ---------------------------------------------------------------------------
246 +
info "Richte systemd-Dienst ein"
247 +
if [[ "$EFFECTIVE_MODE" == "desktop" ]]; then
248 +
  TEMPLATE="$SRC_DIR/systemd/tvwall-desktop.service.in"
249 +
  RUN_UID="$(id -u "$RUN_USER")"
250 +
  if [[ "$RUN_USER" == "tvwall" ]]; then
251 +
    warn "Bildausgabe im Desktop, aber der Dienst laeuft als 'tvwall'."
252 +
    warn "Nur der Benutzer, dem die Desktop-Sitzung gehoert, kommt an den Bildschirm."
253 +
    warn "Bitte RUN_USER in $CONFIG_DST auf den Desktop-Benutzer setzen (meist 'pi')."
254 +
  fi
255 +
else
256 +
  TEMPLATE="$SRC_DIR/systemd/tvwall-drm.service.in"
257 +
  RUN_UID=""
258 +
fi
259 +
[[ -f "$TEMPLATE" ]] || die "Vorlage fehlt: $TEMPLATE"
260 +
261 +
sed -e "s|@RUN_USER@|$RUN_USER|g" \
262 +
    -e "s|@RUN_UID@|$RUN_UID|g" \
263 +
    -e "s|@CONFIG@|$CONFIG_DST|g" \
264 +
    "$TEMPLATE" > "$UNIT_DST"
265 +
chmod 0644 "$UNIT_DST"
266 +
267 +
if [[ "$EFFECTIVE_MODE" != "desktop" ]]; then
268 +
  # Auf der Konsole soll kein Login-Prompt um den Bildschirm streiten.
269 +
  systemctl disable --now getty@tty1.service >/dev/null 2>&1 || true
270 +
else
271 +
  # Im Desktop-Betrieb wird tty1 gebraucht - eine frueher im Konsolenmodus
272 +
  # abgeschaltete Konsole hier wieder einschalten.
273 +
  if ! systemctl is-enabled --quiet getty@tty1.service 2>/dev/null; then
274 +
    systemctl enable --now getty@tty1.service >/dev/null 2>&1 || true
275 +
    ok "Konsole auf tty1 wieder aktiviert"
276 +
  fi
277 +
fi
278 +
279 +
systemctl daemon-reload
280 +
systemctl enable tvwall.service >/dev/null
281 +
ok "Dienst aktiviert (startet ab jetzt bei jedem Einschalten mit)"
282 +
283 +
# ---------------------------------------------------------------------------
284 +
# Hostname
285 +
# ---------------------------------------------------------------------------
286 +
if [[ -n "$HOSTNAME_NEW" && "$(hostname)" != "$HOSTNAME_NEW" ]]; then
287 +
  info "Setze Hostname auf $HOSTNAME_NEW"
288 +
  hostnamectl set-hostname "$HOSTNAME_NEW" || true
289 +
  sed -i "s/^127\.0\.1\.1.*/127.0.1.1\t$HOSTNAME_NEW/" /etc/hosts 2>/dev/null || true
290 +
  grep -q "^127.0.1.1" /etc/hosts || printf '127.0.1.1\t%s\n' "$HOSTNAME_NEW" >> /etc/hosts
291 +
fi
292 +
command -v avahi-daemon >/dev/null && systemctl enable --now avahi-daemon >/dev/null 2>&1 || true
293 +
294 +
# ---------------------------------------------------------------------------
295 +
# Access Point
296 +
# ---------------------------------------------------------------------------
297 +
if [[ $WITH_AP -eq 1 ]]; then
298 +
  if [[ $ASSUME_YES -eq 0 ]]; then
299 +
    echo
300 +
    warn "Der Access Point belegt das WLAN des Pi."
301 +
    warn "Bist du gerade per WLAN verbunden, bricht diese Verbindung ab."
302 +
    read -r -p "    Access Point '$AP_SSID' jetzt einrichten? [j/N] " answer
303 +
    [[ "$answer" =~ ^[jJyY]$ ]] || WITH_AP=0
304 +
  fi
305 +
fi
306 +
if [[ $WITH_AP -eq 1 ]]; then
307 +
  "$SRC_DIR/scripts/setup-ap.sh" "$CONFIG_DST" || warn "Access Point konnte nicht eingerichtet werden - siehe Meldungen oben"
308 +
else
309 +
  info "Access Point uebersprungen (spaeter: sudo $DOC_DST/scripts/setup-ap.sh)"
310 +
fi
311 +
312 +
# ---------------------------------------------------------------------------
313 +
# Start
314 +
# ---------------------------------------------------------------------------
315 +
info "Starte Dienst"
316 +
systemctl restart tvwall.service
317 +
sleep 2
318 +
if systemctl is-active --quiet tvwall.service; then
319 +
  ok "tvwall laeuft"
320 +
else
321 +
  warn "Dienst laeuft nicht - Protokoll ansehen mit:  journalctl -u tvwall -n 50"
322 +
fi
323 +
324 +
PORT_SUFFIX=""
325 +
[[ "$WEB_PORT" != "80" ]] && PORT_SUFFIX=":$WEB_PORT"
326 +
cat <<EOF
327 +
328 +
===========================================================================
329 +
 Fertig.
330 +
331 +
   WLAN verbinden mit :  $AP_SSID
332 +
   Webinterface       :  http://${AP_IP:-192.168.4.1}$PORT_SUFFIX
333 +
                         http://$HOSTNAME_NEW.local$PORT_SUFFIX
334 +
   Medienordner       :  $STATE_DIR/media
335 +
336 +
   Protokoll ansehen  :  journalctl -u tvwall -f
337 +
   Dienst neu starten :  sudo systemctl restart tvwall
338 +
   Konfiguration      :  sudo nano $CONFIG_DST
339 +
===========================================================================
340 +
EOF
geändert internal/config/config.go
+41 −7 Datei ansehen
@@ -22,53 +22,66 @@ type Config struct {
22 22
	APIP       string
23 23
	APChannel  string
24 24
	Hostname   string
25 25
26 26
	WebPort     int
27 27
	WebPassword string
28 28
	MaxUploadMB int64
29 29
30 30
	ImageDuration float64
31 31
	Mute          bool
32 +
	Audio         bool // false = Tonausgabe komplett abschalten
32 33
33 -
	DisplayMode  string // "drm" oder "desktop"
34 -
	RunUser      string
35 -
	MpvExtraArgs string
34 +
	ScanInterval int // Sekunden zwischen zwei Durchlaeufen des Medienordners
36 35
37 -
	StateDir string
36 +
	DisplayMode   string // "auto", "drm" oder "desktop"
37 +
	RunUser       string
38 +
	MpvExtraArgs  string
39 +
	MpvHwdec      string
40 +
	FreshForVideo bool // mpv vor jedem Video neu starten
41 +
	StallTimeout  int  // Sekunden ohne Fortschritt, bevor ein Video uebersprungen wird
42 +
43 +
	StateDir   string
44 +
	SocketPath string
38 45
}
39 46
40 47
// Default liefert die Konfiguration mit allen Standardwerten.
41 48
func Default() *Config {
42 49
	return &Config{
43 50
		APSSID:        "TVWall-01",
44 51
		APPassword:    "tvwall1234",
45 52
		APCountry:     "DE",
46 53
		APIP:          "192.168.4.1",
47 54
		APChannel:     "7",
48 55
		Hostname:      "tvwall-01",
49 56
		WebPort:       80,
50 57
		MaxUploadMB:   2048,
51 58
		ImageDuration: 5,
52 -
		DisplayMode:   "drm",
59 +
		Audio:         true,
60 +
		ScanInterval:  10,
61 +
		DisplayMode:   "auto",
62 +
		MpvHwdec:      "no",
63 +
		FreshForVideo: true,
64 +
		StallTimeout:  30,
53 65
		RunUser:       "tvwall",
54 66
		StateDir:      "/var/lib/tvwall",
67 +
		SocketPath:    "/run/tvwall/mpv.sock",
55 68
	}
56 69
}
57 70
58 71
// Abgeleitete Pfade.
59 72
func (c *Config) MediaDir() string     { return filepath.Join(c.StateDir, "media") }
60 73
func (c *Config) ThumbDir() string     { return filepath.Join(c.StateDir, "thumbs") }
61 74
func (c *Config) OrderFile() string    { return filepath.Join(c.StateDir, "order.json") }
62 75
func (c *Config) PlaylistFile() string { return filepath.Join(c.StateDir, "playlist.m3u") }
63 76
func (c *Config) SettingsFile() string { return filepath.Join(c.StateDir, "settings.json") }
64 -
func (c *Config) MpvSocket() string    { return "/run/tvwall/mpv.sock" }
77 +
func (c *Config) MpvSocket() string    { return c.SocketPath }
65 78
66 79
// Load liest die Konfigurationsdatei im Shell-Stil (KEY="value").
67 80
// Eine fehlende Datei ist kein Fehler - dann gelten die Standardwerte.
68 81
func Load(path string) (*Config, error) {
69 82
	c := Default()
70 83
	f, err := os.Open(path)
71 84
	if err != nil {
72 85
		if os.IsNotExist(err) {
73 86
			return c, nil
74 87
		}
@@ -113,28 +126,49 @@ func Load(path string) (*Config, error) {
113 126
		case "MAX_UPLOAD_MB":
114 127
			if n, err := strconv.ParseInt(val, 10, 64); err == nil && n > 0 {
115 128
				c.MaxUploadMB = n
116 129
			}
117 130
		case "IMAGE_DURATION":
118 131
			if f, err := strconv.ParseFloat(val, 64); err == nil && f > 0 {
119 132
				c.ImageDuration = f
120 133
			}
121 134
		case "MUTE":
122 135
			c.Mute = isTrue(val)
136 +
		case "AUDIO":
137 +
			c.Audio = isTrue(val)
138 +
		case "SCAN_INTERVAL":
139 +
			if n, err := strconv.Atoi(val); err == nil && n >= 1 {
140 +
				c.ScanInterval = n
141 +
			}
123 142
		case "DISPLAY_MODE":
124 -
			if val == "drm" || val == "desktop" {
143 +
			switch val {
144 +
			case "auto", "drm", "desktop":
125 145
				c.DisplayMode = val
126 146
			}
127 147
		case "RUN_USER":
128 148
			c.RunUser = val
129 149
		case "MPV_EXTRA_ARGS":
130 150
			c.MpvExtraArgs = val
151 +
		case "FRESH_MPV_FOR_VIDEO":
152 +
			c.FreshForVideo = isTrue(val)
153 +
		case "MPV_HWDEC":
154 +
			if val != "" {
155 +
				c.MpvHwdec = val
156 +
			}
157 +
		case "STALL_TIMEOUT":
158 +
			if n, err := strconv.Atoi(val); err == nil && n >= 0 {
159 +
				c.StallTimeout = n
160 +
			}
161 +
		case "MPV_SOCKET":
162 +
			if val != "" {
163 +
				c.SocketPath = val
164 +
			}
131 165
		case "STATE_DIR":
132 166
			if val != "" {
133 167
				c.StateDir = val
134 168
			}
135 169
		}
136 170
	}
137 171
	return c, sc.Err()
138 172
}
139 173
140 174
func unquote(s string) string {
hinzugefügt internal/mpv/mpv.go
+243 −0 Datei ansehen
@@ -0,0 +1,243 @@
1 +
// Package mpv spricht ueber den JSON-IPC-Socket mit einem laufenden mpv.
2 +
package mpv
3 +
4 +
import (
5 +
	"bufio"
6 +
	"encoding/json"
7 +
	"errors"
8 +
	"fmt"
9 +
	"net"
10 +
	"sync"
11 +
	"time"
12 +
)
13 +
14 +
// ErrClosed wird geliefert, wenn die Verbindung nicht mehr steht.
15 +
var ErrClosed = errors.New("mpv: verbindung geschlossen")
16 +
17 +
// Event ist eine von mpv gesendete Statusmeldung.
18 +
type Event struct {
19 +
	Name  string          `json:"event"`
20 +
	ID    int             `json:"id"`
21 +
	Data  json.RawMessage `json:"data"`
22 +
	Name2 string          `json:"name"` // bei property-change: Name der Eigenschaft
23 +
24 +
	// Raw ist die vollstaendige Meldung - fuer Felder, die je nach Ereignis
25 +
	// unterschiedlich heissen (etwa "reason" bei end-file).
26 +
	Raw json.RawMessage `json:"-"`
27 +
}
28 +
29 +
type response struct {
30 +
	Error     string          `json:"error"`
31 +
	Data      json.RawMessage `json:"data"`
32 +
	RequestID int             `json:"request_id"`
33 +
}
34 +
35 +
// Client ist eine Verbindung zum IPC-Socket von mpv.
36 +
type Client struct {
37 +
	conn net.Conn
38 +
39 +
	mu      sync.Mutex
40 +
	nextID  int
41 +
	pending map[int]chan response
42 +
	closed  bool
43 +
44 +
	events chan Event
45 +
	done   chan struct{}
46 +
}
47 +
48 +
// Dial verbindet sich mit dem Socket und wartet dabei bis zu timeout, weil mpv
49 +
// den Socket erst kurz nach dem Start anlegt.
50 +
func Dial(socketPath string, timeout time.Duration) (*Client, error) {
51 +
	deadline := time.Now().Add(timeout)
52 +
	var lastErr error
53 +
	for {
54 +
		conn, err := net.Dial("unix", socketPath)
55 +
		if err == nil {
56 +
			c := &Client{
57 +
				conn:    conn,
58 +
				pending: make(map[int]chan response),
59 +
				events:  make(chan Event, 64),
60 +
				done:    make(chan struct{}),
61 +
			}
62 +
			go c.readLoop()
63 +
			return c, nil
64 +
		}
65 +
		lastErr = err
66 +
		if time.Now().After(deadline) {
67 +
			return nil, fmt.Errorf("mpv-socket %s nicht erreichbar: %w", socketPath, lastErr)
68 +
		}
69 +
		time.Sleep(150 * time.Millisecond)
70 +
	}
71 +
}
72 +
73 +
// Events liefert den Kanal mit den Statusmeldungen von mpv.
74 +
func (c *Client) Events() <-chan Event { return c.events }
75 +
76 +
// Done wird geschlossen, sobald die Verbindung abbricht.
77 +
func (c *Client) Done() <-chan struct{} { return c.done }
78 +
79 +
// Close beendet die Verbindung.
80 +
func (c *Client) Close() error {
81 +
	c.mu.Lock()
82 +
	if c.closed {
83 +
		c.mu.Unlock()
84 +
		return nil
85 +
	}
86 +
	c.closed = true
87 +
	c.mu.Unlock()
88 +
	return c.conn.Close()
89 +
}
90 +
91 +
func (c *Client) readLoop() {
92 +
	defer func() {
93 +
		c.mu.Lock()
94 +
		c.closed = true
95 +
		for id, ch := range c.pending {
96 +
			close(ch)
97 +
			delete(c.pending, id)
98 +
		}
99 +
		c.mu.Unlock()
100 +
		close(c.done)
101 +
		close(c.events)
102 +
	}()
103 +
104 +
	sc := bufio.NewScanner(c.conn)
105 +
	sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
106 +
	for sc.Scan() {
107 +
		line := sc.Bytes()
108 +
		if len(line) == 0 {
109 +
			continue
110 +
		}
111 +
		// Eine Zeile ist entweder ein Event oder die Antwort auf ein Kommando.
112 +
		var probe struct {
113 +
			Event     string `json:"event"`
114 +
			RequestID *int   `json:"request_id"`
115 +
		}
116 +
		if json.Unmarshal(line, &probe) != nil {
117 +
			continue
118 +
		}
119 +
		if probe.Event != "" {
120 +
			var ev Event
121 +
			if json.Unmarshal(line, &ev) == nil {
122 +
				ev.Raw = append(json.RawMessage(nil), line...)
123 +
				select {
124 +
				case c.events <- ev:
125 +
				default: // Ereignisse sind unkritisch: lieber verwerfen als blockieren
126 +
				}
127 +
			}
128 +
			continue
129 +
		}
130 +
		if probe.RequestID == nil {
131 +
			continue
132 +
		}
133 +
		var resp response
134 +
		if json.Unmarshal(line, &resp) != nil {
135 +
			continue
136 +
		}
137 +
		c.mu.Lock()
138 +
		ch, ok := c.pending[resp.RequestID]
139 +
		if ok {
140 +
			delete(c.pending, resp.RequestID)
141 +
		}
142 +
		c.mu.Unlock()
143 +
		if ok {
144 +
			ch <- resp
145 +
			close(ch)
146 +
		}
147 +
	}
148 +
}
149 +
150 +
// Command schickt ein Kommando an mpv und wartet auf die Antwort.
151 +
func (c *Client) Command(args ...any) (json.RawMessage, error) {
152 +
	c.mu.Lock()
153 +
	if c.closed {
154 +
		c.mu.Unlock()
155 +
		return nil, ErrClosed
156 +
	}
157 +
	c.nextID++
158 +
	id := c.nextID
159 +
	ch := make(chan response, 1)
160 +
	c.pending[id] = ch
161 +
	c.mu.Unlock()
162 +
163 +
	payload, err := json.Marshal(map[string]any{"command": args, "request_id": id})
164 +
	if err != nil {
165 +
		c.forget(id)
166 +
		return nil, err
167 +
	}
168 +
	if err := c.conn.SetWriteDeadline(time.Now().Add(3 * time.Second)); err == nil {
169 +
		defer c.conn.SetWriteDeadline(time.Time{})
170 +
	}
171 +
	if _, err := c.conn.Write(append(payload, '\n')); err != nil {
172 +
		c.forget(id)
173 +
		return nil, err
174 +
	}
175 +
176 +
	select {
177 +
	case resp, ok := <-ch:
178 +
		if !ok {
179 +
			return nil, ErrClosed
180 +
		}
181 +
		if resp.Error != "" && resp.Error != "success" {
182 +
			return resp.Data, fmt.Errorf("mpv: %s", resp.Error)
183 +
		}
184 +
		return resp.Data, nil
185 +
	case <-time.After(5 * time.Second):
186 +
		c.forget(id)
187 +
		return nil, fmt.Errorf("mpv: zeitueberschreitung bei %v", args)
188 +
	}
189 +
}
190 +
191 +
func (c *Client) forget(id int) {
192 +
	c.mu.Lock()
193 +
	delete(c.pending, id)
194 +
	c.mu.Unlock()
195 +
}
196 +
197 +
// SetProperty setzt eine mpv-Eigenschaft.
198 +
func (c *Client) SetProperty(name string, value any) error {
199 +
	_, err := c.Command("set_property", name, value)
200 +
	return err
201 +
}
202 +
203 +
// GetString liest eine Eigenschaft als Zeichenkette.
204 +
func (c *Client) GetString(name string) (string, error) {
205 +
	data, err := c.Command("get_property", name)
206 +
	if err != nil {
207 +
		return "", err
208 +
	}
209 +
	var s string
210 +
	if json.Unmarshal(data, &s) != nil {
211 +
		return string(data), nil
212 +
	}
213 +
	return s, nil
214 +
}
215 +
216 +
// GetBool liest eine Eigenschaft als Wahrheitswert.
217 +
func (c *Client) GetBool(name string) (bool, error) {
218 +
	data, err := c.Command("get_property", name)
219 +
	if err != nil {
220 +
		return false, err
221 +
	}
222 +
	var b bool
223 +
	return b, json.Unmarshal(data, &b)
224 +
}
225 +
226 +
// GetInt liest eine Eigenschaft als Ganzzahl.
227 +
func (c *Client) GetInt(name string) (int, error) {
228 +
	data, err := c.Command("get_property", name)
229 +
	if err != nil {
230 +
		return 0, err
231 +
	}
232 +
	var n float64
233 +
	if err := json.Unmarshal(data, &n); err != nil {
234 +
		return 0, err
235 +
	}
236 +
	return int(n), nil
237 +
}
238 +
239 +
// ObserveProperty laesst mpv Aenderungen an einer Eigenschaft melden.
240 +
func (c *Client) ObserveProperty(id int, name string) error {
241 +
	_, err := c.Command("observe_property", id, name)
242 +
	return err
243 +
}
hinzugefügt internal/player/display.go
+171 −0 Datei ansehen
@@ -0,0 +1,171 @@
1 +
package player
2 +
3 +
import (
4 +
	"fmt"
5 +
	"log"
6 +
	"os"
7 +
	"os/user"
8 +
	"path/filepath"
9 +
	"sort"
10 +
	"strings"
11 +
)
12 +
13 +
// display beschreibt, wohin mpv sein Bild malen soll.
14 +
type display struct {
15 +
	mode string   // "drm", "wayland" oder "x11"
16 +
	env  []string // zusaetzliche Umgebungsvariablen fuer mpv
17 +
	note string   // Hinweis fuers Protokoll, wenn etwas nicht stimmt
18 +
}
19 +
20 +
// resolveDisplay ermittelt die Bildausgabe.
21 +
//
22 +
// DISPLAY_MODE="auto" (Standard) sucht zuerst nach einer laufenden
23 +
// Desktop-Sitzung des eigenen Benutzers und faellt sonst auf die direkte
24 +
// Ausgabe ueber DRM/KMS zurueck. "drm" und "desktop" erzwingen die jeweilige
25 +
// Variante.
26 +
//
27 +
// Bei "desktop" ohne erreichbare Sitzung wird ein Fehler geliefert statt auf
28 +
// DRM auszuweichen: direkt nach dem Einschalten steht die Sitzung oft noch
29 +
// nicht, und ein Ausweichen wuerde dem gerade startenden Desktop den
30 +
// Bildschirm wegnehmen. Der Player versucht es dann einfach spaeter erneut.
31 +
func resolveDisplay(mode string) (display, error) {
32 +
	switch mode {
33 +
	case "drm":
34 +
		d := display{mode: "drm"}
35 +
		if other := sessionHint(); other != "" {
36 +
			d.note = "DISPLAY_MODE=drm ist gesetzt, aber es laeuft ein Desktop. " +
37 +
				"mpv kann den Bildschirm so nicht bekommen. " + other
38 +
		}
39 +
		return d, nil
40 +
41 +
	case "desktop":
42 +
		if d := detectSession(); d.mode != "" {
43 +
			return d, nil
44 +
		}
45 +
		msg := "keine erreichbare Desktop-Sitzung gefunden (DISPLAY_MODE=desktop)"
46 +
		if hint := sessionHint(); hint != "" {
47 +
			msg += ". " + hint
48 +
		}
49 +
		return display{}, fmt.Errorf("%s", msg)
50 +
51 +
	default: // "auto" und alles Unbekannte
52 +
		if d := detectSession(); d.mode != "" {
53 +
			return d, nil
54 +
		}
55 +
		return display{mode: "drm", note: sessionHint()}, nil
56 +
	}
57 +
}
58 +
59 +
// detectSession sucht die Wayland- bzw. X11-Sitzung des eigenen Benutzers.
60 +
func detectSession() display {
61 +
	runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
62 +
	if runtimeDir == "" {
63 +
		runtimeDir = fmt.Sprintf("/run/user/%d", os.Getuid())
64 +
	}
65 +
	var env []string
66 +
	if os.Getenv("XDG_RUNTIME_DIR") == "" {
67 +
		env = append(env, "XDG_RUNTIME_DIR="+runtimeDir)
68 +
	}
69 +
70 +
	// Wayland: labwc, wayfire & Co. legen den Socket im Laufzeitverzeichnis ab.
71 +
	// Je nach System heisst er wayland-0, wayland-1 - deshalb wird gesucht
72 +
	// statt geraten.
73 +
	if name := os.Getenv("WAYLAND_DISPLAY"); name != "" && socketExists(runtimeDir, name) {
74 +
		return display{mode: "wayland", env: env}
75 +
	}
76 +
	if name := findWaylandSocket(runtimeDir); name != "" {
77 +
		return display{mode: "wayland", env: append(env, "WAYLAND_DISPLAY="+name)}
78 +
	}
79 +
80 +
	// X11
81 +
	if d := os.Getenv("DISPLAY"); d != "" {
82 +
		return display{mode: "x11", env: env}
83 +
	}
84 +
	if names, _ := filepath.Glob("/tmp/.X11-unix/X*"); len(names) > 0 {
85 +
		sort.Strings(names)
86 +
		num := strings.TrimPrefix(filepath.Base(names[0]), "X")
87 +
		return display{mode: "x11", env: append(env, "DISPLAY=:"+num)}
88 +
	}
89 +
90 +
	return display{}
91 +
}
92 +
93 +
func socketExists(dir, name string) bool {
94 +
	if filepath.IsAbs(name) {
95 +
		_, err := os.Stat(name)
96 +
		return err == nil
97 +
	}
98 +
	_, err := os.Stat(filepath.Join(dir, name))
99 +
	return err == nil
100 +
}
101 +
102 +
func findWaylandSocket(dir string) string {
103 +
	matches, err := filepath.Glob(filepath.Join(dir, "wayland-*"))
104 +
	if err != nil {
105 +
		return ""
106 +
	}
107 +
	sort.Strings(matches)
108 +
	for _, m := range matches {
109 +
		if strings.HasSuffix(m, ".lock") {
110 +
			continue
111 +
		}
112 +
		if info, err := os.Stat(m); err == nil && info.Mode()&os.ModeSocket != 0 {
113 +
			return filepath.Base(m)
114 +
		}
115 +
	}
116 +
	return ""
117 +
}
118 +
119 +
// sessionHint schaut nach, ob ein ANDERER Benutzer einen Desktop laufen hat.
120 +
// Das ist der haeufigste Stolperstein: der Dienst laeuft als "tvwall", der
121 +
// Desktop gehoert aber "pi" - und auf dessen Sitzung kommt niemand sonst.
122 +
func sessionHint() string {
123 +
	me := os.Getuid()
124 +
	dirs, err := os.ReadDir("/run/user")
125 +
	if err != nil {
126 +
		return ""
127 +
	}
128 +
	for _, d := range dirs {
129 +
		if !d.IsDir() || d.Name() == fmt.Sprint(me) {
130 +
			continue
131 +
		}
132 +
		sockets, _ := filepath.Glob(filepath.Join("/run/user", d.Name(), "wayland-*"))
133 +
		if len(sockets) == 0 {
134 +
			continue
135 +
		}
136 +
		name := d.Name()
137 +
		if u, err := user.LookupId(d.Name()); err == nil {
138 +
			name = u.Username
139 +
		}
140 +
		return fmt.Sprintf(
141 +
			"Es laeuft eine Desktop-Sitzung des Benutzers %q, auf die dieser Dienst "+
142 +
				"keinen Zugriff hat. In /etc/tvwall/tvwall.conf setzen: "+
143 +
				"RUN_USER=%q und DISPLAY_MODE=\"desktop\", danach "+
144 +
				"'sudo /opt/tvwall/install.sh --no-ap'.", name, name)
145 +
	}
146 +
	return ""
147 +
}
148 +
149 +
// logDisplay schreibt die getroffene Wahl ins Protokoll.
150 +
func (d display) logDisplay() {
151 +
	switch d.mode {
152 +
	case "wayland":
153 +
		log.Printf("player: Bildausgabe ueber Wayland (%s)", envValue(d.env, "WAYLAND_DISPLAY", os.Getenv("WAYLAND_DISPLAY")))
154 +
	case "x11":
155 +
		log.Printf("player: Bildausgabe ueber X11 (%s)", envValue(d.env, "DISPLAY", os.Getenv("DISPLAY")))
156 +
	default:
157 +
		log.Printf("player: Bildausgabe direkt ueber DRM/KMS (ohne Desktop)")
158 +
	}
159 +
	if d.note != "" {
160 +
		log.Printf("player: HINWEIS: %s", d.note)
161 +
	}
162 +
}
163 +
164 +
func envValue(env []string, key, fallback string) string {
165 +
	for _, e := range env {
166 +
		if strings.HasPrefix(e, key+"=") {
167 +
			return strings.TrimPrefix(e, key+"=")
168 +
		}
169 +
	}
170 +
	return fallback
171 +
}
hinzugefügt internal/player/player.go
+643 −0 Datei ansehen
@@ -0,0 +1,643 @@
1 +
// Package player startet und ueberwacht mpv und steuert die Wiedergabe.
2 +
//
3 +
// mpv bekommt bewusst KEINE Playliste, sondern immer nur die eine Datei, die
4 +
// gerade laufen soll (loadfile ... replace). Die Reihenfolge fuehrt dieses
5 +
// Programm selbst. Grund: eine gemischte Playliste aus Bildern und Videos
6 +
// bringt den Demuxer durcheinander - eine Videodatei, die allein einwandfrei
7 +
// laeuft, bricht als zweiter Eintrag nach kurzer Zeit mit "partial file" ab.
8 +
// Als jeweils einzige geladene Datei tritt das nicht auf; nebenbei haben wir
9 +
// so die volle Kontrolle ueber Reihenfolge, Weiterschalten und Fehlerfaelle.
10 +
package player
11 +
12 +
import (
13 +
	"context"
14 +
	"encoding/json"
15 +
	"errors"
16 +
	"fmt"
17 +
	"log"
18 +
	"os"
19 +
	"os/exec"
20 +
	"path/filepath"
21 +
	"strconv"
22 +
	"strings"
23 +
	"sync"
24 +
	"time"
25 +
26 +
	"ehc/tvwall/internal/config"
27 +
	"ehc/tvwall/internal/mpv"
28 +
	"ehc/tvwall/internal/playlist"
29 +
)
30 +
31 +
// IDs fuer die beobachteten mpv-Eigenschaften.
32 +
const (
33 +
	propPath = iota + 1
34 +
	propPause
35 +
	propIdle
36 +
	propTimePos
37 +
)
38 +
39 +
// Takt der Ueberwachung: haengende Dateien und leerer Bildschirm.
40 +
const tickInterval = 5 * time.Second
41 +
42 +
// Wartezeit, bevor nach lauter fehlerhaften Dateien erneut versucht wird.
43 +
const retryDelay = 15 * time.Second
44 +
45 +
// errFreshMpv ist kein Fehler, sondern der Wunsch nach einem frischen
46 +
// mpv-Prozess fuer die naechste Datei. Siehe FRESH_MPV_FOR_VIDEO.
47 +
var errFreshMpv = errors.New("frischer mpv-prozess fuer das naechste video")
48 +
49 +
// Status beschreibt den Zustand der Wiedergabe fuer das Webinterface.
50 +
type Status struct {
51 +
	Running       bool   `json:"running"`
52 +
	Idle          bool   `json:"idle"`
53 +
	Paused        bool   `json:"paused"`
54 +
	Current       string `json:"current"`
55 +
	Position      int    `json:"position"`
56 +
	Display       string `json:"display"`
57 +
	PendingReload bool   `json:"pending_reload"`
58 +
	LastError     string `json:"last_error"`
59 +
}
60 +
61 +
// Player haelt genau einen mpv-Prozess am Leben und schaltet die Dateien weiter.
62 +
type Player struct {
63 +
	cfg      *config.Config
64 +
	pl       *playlist.Manager
65 +
	settings *config.Store
66 +
67 +
	mu     sync.RWMutex
68 +
	client *mpv.Client
69 +
	status Status
70 +
71 +
	playing     string    // Datei, die wir mpv zuletzt uebergeben haben
72 +
	index       int       // deren Platz in der Playliste
73 +
	errStreak   int       // Dateien in Folge, die sich nicht abspielen liessen
74 +
	lastAttempt time.Time // wann zuletzt eine Datei geladen wurde
75 +
76 +
	lastTimePos    float64
77 +
	lastProgressAt time.Time
78 +
79 +
	loadedCount int // Dateien, die dieser mpv-Prozess schon geladen hat
80 +
81 +
	advance   chan int
82 +
	reloadNow chan struct{}
83 +
	restart   chan struct{}
84 +
}
85 +
86 +
// New erzeugt den Player.
87 +
func New(cfg *config.Config, pl *playlist.Manager, settings *config.Store) *Player {
88 +
	return &Player{
89 +
		cfg:       cfg,
90 +
		pl:        pl,
91 +
		settings:  settings,
92 +
		advance:   make(chan int, 4),
93 +
		reloadNow: make(chan struct{}, 1),
94 +
		restart:   make(chan struct{}, 1),
95 +
	}
96 +
}
97 +
98 +
// Status liefert den aktuellen Zustand.
99 +
func (p *Player) Status() Status {
100 +
	p.mu.RLock()
101 +
	defer p.mu.RUnlock()
102 +
	return p.status
103 +
}
104 +
105 +
// Run laeuft bis ctx abgebrochen wird und startet mpv bei Bedarf neu.
106 +
func (p *Player) Run(ctx context.Context) {
107 +
	settingsChanged := p.settings.Subscribe()
108 +
	backoff := time.Second
109 +
	for ctx.Err() == nil {
110 +
		start := time.Now()
111 +
		err := p.runOnce(ctx, settingsChanged)
112 +
		p.setRunning(false)
113 +
		if ctx.Err() != nil {
114 +
			return
115 +
		}
116 +
		if errors.Is(err, errFreshMpv) {
117 +
			// Geplanter, kurzer Neustart - kein Grund zu warten.
118 +
			continue
119 +
		}
120 +
		if err != nil {
121 +
			log.Printf("player: %v", err)
122 +
			p.setError(err.Error())
123 +
		}
124 +
		if time.Since(start) > time.Minute {
125 +
			backoff = time.Second
126 +
		} else if backoff < 15*time.Second {
127 +
			backoff *= 2
128 +
		}
129 +
		log.Printf("player: neustart in %s", backoff)
130 +
		select {
131 +
		case <-ctx.Done():
132 +
			return
133 +
		case <-time.After(backoff):
134 +
		}
135 +
	}
136 +
}
137 +
138 +
// runOnce startet mpv, verbindet sich mit dem IPC-Socket und bleibt, bis mpv
139 +
// endet oder ctx abgebrochen wird.
140 +
func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) error {
141 +
	disp, err := resolveDisplay(p.cfg.DisplayMode)
142 +
	if err != nil {
143 +
		return err
144 +
	}
145 +
	disp.logDisplay()
146 +
147 +
	sock := p.cfg.MpvSocket()
148 +
	if err := os.MkdirAll(filepath.Dir(sock), 0o775); err != nil {
149 +
		return fmt.Errorf("socket-verzeichnis: %w", err)
150 +
	}
151 +
	_ = os.Remove(sock)
152 +
153 +
	args := p.mpvArgs(disp)
154 +
	log.Printf("player: starte mpv %s", strings.Join(args, " "))
155 +
156 +
	cmd := exec.Command("mpv", args...)
157 +
	// stdin durchreichen: bei --vo=gpu --gpu-context=drm braucht mpv das
158 +
	// Terminal der Konsole (tty1), um den Bildschirm uebernehmen zu duerfen.
159 +
	cmd.Stdin = os.Stdin
160 +
	cmd.Stdout = os.Stdout
161 +
	cmd.Stderr = os.Stderr
162 +
	cmd.Env = append(os.Environ(), disp.env...)
163 +
	if err := cmd.Start(); err != nil {
164 +
		return fmt.Errorf("mpv starten: %w (ist mpv installiert?)", err)
165 +
	}
166 +
167 +
	var waitErr error
168 +
	exited := make(chan struct{})
169 +
	go func() {
170 +
		waitErr = cmd.Wait()
171 +
		close(exited)
172 +
	}()
173 +
174 +
	defer func() {
175 +
		if cmd.Process != nil {
176 +
			_ = cmd.Process.Kill()
177 +
		}
178 +
		<-exited
179 +
		_ = os.Remove(sock)
180 +
	}()
181 +
182 +
	client, err := mpv.Dial(sock, 15*time.Second)
183 +
	if err != nil {
184 +
		return err
185 +
	}
186 +
	defer client.Close()
187 +
188 +
	p.mu.Lock()
189 +
	p.client = client
190 +
	p.status = Status{Running: true, Display: disp.mode}
191 +
	p.index = 0
192 +
	p.errStreak = 0
193 +
	p.loadedCount = 0
194 +
	p.mu.Unlock()
195 +
	defer func() {
196 +
		p.mu.Lock()
197 +
		p.client = nil
198 +
		p.mu.Unlock()
199 +
	}()
200 +
201 +
	for id, name := range map[int]string{
202 +
		propPath: "path", propPause: "pause",
203 +
		propIdle: "idle-active", propTimePos: "time-pos",
204 +
	} {
205 +
		if err := client.ObserveProperty(id, name); err != nil {
206 +
			log.Printf("player: observe %s: %v", name, err)
207 +
		}
208 +
	}
209 +
210 +
	p.applySettings(client)
211 +
	if err := p.step(client, 0); err != nil { // erste Datei laden
212 +
		return err
213 +
	}
214 +
	p.setError("")
215 +
216 +
	ticker := time.NewTicker(tickInterval)
217 +
	defer ticker.Stop()
218 +
219 +
	for {
220 +
		select {
221 +
		case <-ctx.Done():
222 +
			return nil
223 +
224 +
		case <-exited:
225 +
			return fmt.Errorf("mpv beendet: %v (Meldungen von mpv siehe oben)", waitErr)
226 +
227 +
		case <-client.Done():
228 +
			select {
229 +
			case <-exited:
230 +
				return fmt.Errorf("mpv beendet: %v (Meldungen von mpv siehe oben)", waitErr)
231 +
			case <-time.After(time.Second):
232 +
				return fmt.Errorf("ipc-verbindung verloren")
233 +
			}
234 +
235 +
		case <-p.pl.Changed():
236 +
			// Aenderungen greifen von selbst beim naechsten Dateiwechsel, weil
237 +
			// dort die Playliste neu gelesen wird. Nur wenn gerade nichts
238 +
			// laeuft, muss die Wiedergabe angestossen werden.
239 +
			p.mu.RLock()
240 +
			idleNow := p.playing == ""
241 +
			p.mu.RUnlock()
242 +
			if idleNow {
243 +
				if err := p.step(client, 0); err != nil {
244 +
					return err
245 +
				}
246 +
			}
247 +
248 +
		case delta := <-p.advance:
249 +
			if err := p.step(client, delta); err != nil {
250 +
				return err
251 +
			}
252 +
253 +
		case <-p.reloadNow:
254 +
			p.mu.Lock()
255 +
			p.playing = ""
256 +
			p.index = 0
257 +
			p.errStreak = 0
258 +
			p.mu.Unlock()
259 +
			if err := p.step(client, 0); err != nil {
260 +
				return err
261 +
			}
262 +
263 +
		case <-p.restart:
264 +
			return fmt.Errorf("neustart angefordert")
265 +
266 +
		case <-settingsChanged:
267 +
			p.applySettings(client)
268 +
269 +
		case <-ticker.C:
270 +
			if err := p.tick(client); err != nil {
271 +
				return err
272 +
			}
273 +
274 +
		case ev, ok := <-client.Events():
275 +
			if !ok {
276 +
				return fmt.Errorf("ipc-verbindung beendet")
277 +
			}
278 +
			if err := p.handleEvent(client, ev); err != nil {
279 +
				return err
280 +
			}
281 +
		}
282 +
	}
283 +
}
284 +
285 +
func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) error {
286 +
	switch ev.Name {
287 +
	case "property-change":
288 +
		p.mu.Lock()
289 +
		switch ev.ID {
290 +
		case propPath:
291 +
			var s string
292 +
			if json.Unmarshal(ev.Data, &s) == nil && s != "" {
293 +
				p.status.Current = filepath.Base(s)
294 +
				// Eine Datei laeuft wieder - die Fehlerkette ist unterbrochen.
295 +
				p.errStreak = 0
296 +
			} else {
297 +
				p.status.Current = ""
298 +
			}
299 +
			p.lastTimePos = -1
300 +
			p.lastProgressAt = time.Now()
301 +
		case propPause:
302 +
			var b bool
303 +
			if json.Unmarshal(ev.Data, &b) == nil {
304 +
				p.status.Paused = b
305 +
			}
306 +
		case propIdle:
307 +
			var b bool
308 +
			if json.Unmarshal(ev.Data, &b) == nil {
309 +
				p.status.Idle = b
310 +
			}
311 +
		case propTimePos:
312 +
			var pos float64
313 +
			if json.Unmarshal(ev.Data, &pos) == nil && pos != p.lastTimePos {
314 +
				p.lastTimePos = pos
315 +
				p.lastProgressAt = time.Now()
316 +
			}
317 +
		}
318 +
		p.mu.Unlock()
319 +
320 +
	case "end-file":
321 +
		// "stop" kommt von unserem eigenen loadfile - das darf nicht
322 +
		// weiterschalten, sonst wird jede zweite Datei uebersprungen.
323 +
		var e struct {
324 +
			Reason    string `json:"reason"`
325 +
			FileError string `json:"file_error"`
326 +
		}
327 +
		_ = json.Unmarshal(ev.Raw, &e)
328 +
		switch e.Reason {
329 +
		case "eof":
330 +
			return p.step(client, 1)
331 +
		case "error":
332 +
			p.mu.Lock()
333 +
			bad := p.playing
334 +
			p.errStreak++
335 +
			p.mu.Unlock()
336 +
			log.Printf("player: %q liess sich nicht abspielen (%s) - wird uebersprungen",
337 +
				bad, strings.TrimSpace(e.FileError))
338 +
			p.setError(fmt.Sprintf("%s liess sich nicht abspielen", bad))
339 +
			return p.step(client, 1)
340 +
		}
341 +
	}
342 +
	return nil
343 +
}
344 +
345 +
// step schaltet um delta Eintraege weiter und laedt die Datei in mpv.
346 +
// delta 0 laedt den aktuellen Eintrag (bzw. steigt neu ein).
347 +
func (p *Player) step(client *mpv.Client, delta int) error {
348 +
	entries, _ := p.pl.EntriesWithVersion()
349 +
	if len(entries) == 0 {
350 +
		p.mu.Lock()
351 +
		p.playing, p.index = "", 0
352 +
		p.status.Current = ""
353 +
		p.mu.Unlock()
354 +
		if _, err := client.Command("stop"); err != nil {
355 +
			log.Printf("player: stop: %v", err)
356 +
		}
357 +
		return nil
358 +
	}
359 +
360 +
	p.mu.RLock()
361 +
	playing, index, errStreak := p.playing, p.index, p.errStreak
362 +
	loaded := p.loadedCount
363 +
	p.mu.RUnlock()
364 +
365 +
	// Lieber gar nichts tun als in einer Schleife lauter kaputte Dateien
366 +
	// durchzuhecheln - der naechste Takt versucht es in Ruhe erneut.
367 +
	if errStreak >= len(entries) {
368 +
		log.Printf("player: keine der %d Dateien liess sich abspielen - warte %s",
369 +
			len(entries), retryDelay)
370 +
		p.setError("keine abspielbare Datei in der Playliste")
371 +
		p.mu.Lock()
372 +
		p.playing = ""
373 +
		p.lastAttempt = time.Now()
374 +
		p.mu.Unlock()
375 +
		return nil
376 +
	}
377 +
378 +
	// Position anhand des Dateinamens bestimmen: so bleibt die Reihenfolge
379 +
	// auch dann sinnvoll, wenn zwischendurch etwas hinzugekommen oder
380 +
	// weggefallen ist.
381 +
	base := index
382 +
	if playing != "" {
383 +
		if i := indexOf(entries, playing); i >= 0 {
384 +
			base = i
385 +
		}
386 +
	}
387 +
	next := ((base+delta)%len(entries) + len(entries)) % len(entries)
388 +
389 +
	entry := entries[next]
390 +
	path := filepath.Join(p.pl.MediaDir(), entry.Name)
391 +
392 +
	// Ein Video in einen mpv-Prozess zu laden, der vorher schon etwas anderes
393 +
	// angezeigt hat, endet auf manchen Systemen im Abbruch des Demuxers
394 +
	// ("partial file"). Deshalb bekommt jedes Video einen frischen Prozess.
395 +
	if p.cfg.FreshForVideo && entry.Kind == "video" && loaded > 0 {
396 +
		p.mu.Lock()
397 +
		p.playing = entry.Name // damit der neue Prozess genau hier einsteigt
398 +
		p.index = next
399 +
		p.mu.Unlock()
400 +
		log.Printf("player: starte mpv neu fuer %q", entry.Name)
401 +
		return errFreshMpv
402 +
	}
403 +
404 +
	p.mu.Lock()
405 +
	p.playing = entry.Name
406 +
	p.index = next
407 +
	p.lastAttempt = time.Now()
408 +
	p.lastTimePos = -1
409 +
	p.lastProgressAt = time.Now()
410 +
	p.status.Position = next
411 +
	p.loadedCount++
412 +
	p.mu.Unlock()
413 +
414 +
	if _, err := client.Command("loadfile", path, "replace"); err != nil {
415 +
		log.Printf("player: %q laden: %v", entry.Name, err)
416 +
		p.setError("Laden von " + entry.Name + ": " + err.Error())
417 +
		p.mu.Lock()
418 +
		p.errStreak++
419 +
		p.mu.Unlock()
420 +
		return nil
421 +
	}
422 +
	if err := client.SetProperty("pause", false); err != nil {
423 +
		log.Printf("player: fortsetzen: %v", err)
424 +
	}
425 +
	return nil
426 +
}
427 +
428 +
func indexOf(entries []playlist.Entry, name string) int {
429 +
	for i, e := range entries {
430 +
		if e.Name == name {
431 +
			return i
432 +
		}
433 +
	}
434 +
	return -1
435 +
}
436 +
437 +
// tick prueft regelmaessig, ob die Wiedergabe noch laeuft.
438 +
func (p *Player) tick(client *mpv.Client) error {
439 +
	p.mu.RLock()
440 +
	playing, paused := p.playing, p.status.Paused
441 +
	current := p.status.Current
442 +
	since := time.Since(p.lastProgressAt)
443 +
	sinceAttempt := time.Since(p.lastAttempt)
444 +
	p.mu.RUnlock()
445 +
446 +
	// Nichts geladen (leere Playliste oder lauter kaputte Dateien):
447 +
	// nach einer Weile erneut versuchen.
448 +
	if playing == "" {
449 +
		if len(p.pl.Entries()) > 0 && sinceAttempt > retryDelay {
450 +
			p.mu.Lock()
451 +
			p.errStreak = 0
452 +
			p.mu.Unlock()
453 +
			return p.step(client, 0)
454 +
		}
455 +
		return nil
456 +
	}
457 +
458 +
	// Haengendes Video: ueberspringen, damit nicht die ganze Wand steht.
459 +
	timeout := time.Duration(p.cfg.StallTimeout) * time.Second
460 +
	if timeout <= 0 || paused || current == "" || playlist.Kind(current) != "video" || since < timeout {
461 +
		return nil
462 +
	}
463 +
464 +
	log.Printf("player: %q laeuft seit %s nicht weiter - wird uebersprungen",
465 +
		current, since.Round(time.Second))
466 +
	p.setError(fmt.Sprintf("%s bleibt haengen und wurde uebersprungen", current))
467 +
	p.mu.Lock()
468 +
	p.lastProgressAt = time.Now()
469 +
	p.mu.Unlock()
470 +
471 +
	// Reagiert mpv nicht mehr, hilft nur noch ein Neustart des Prozesses.
472 +
	if _, err := client.Command("playlist-remove", "current"); err != nil {
473 +
		// playlist-remove ist nur ein billiger Lebenstest; scheitert er,
474 +
		// steht mpv wirklich.
475 +
		return fmt.Errorf("mpv reagiert nicht mehr: %w", err)
476 +
	}
477 +
	return p.step(client, 1)
478 +
}
479 +
480 +
// applySettings uebernimmt Anzeigedauer und Stummschaltung ohne mpv-Neustart.
481 +
func (p *Player) applySettings(client *mpv.Client) {
482 +
	s := p.settings.Get()
483 +
	if err := client.SetProperty("image-display-duration", s.ImageDuration); err != nil {
484 +
		log.Printf("player: anzeigedauer setzen: %v", err)
485 +
	}
486 +
	if p.cfg.Audio {
487 +
		if err := client.SetProperty("mute", s.Mute); err != nil {
488 +
			log.Printf("player: stummschaltung setzen: %v", err)
489 +
		}
490 +
	}
491 +
}
492 +
493 +
func (p *Player) mpvArgs(disp display) []string {
494 +
	s := p.settings.Get()
495 +
	args := []string{
496 +
		"--input-ipc-server=" + p.cfg.MpvSocket(),
497 +
		"--fullscreen",
498 +
		"--idle=yes",
499 +
		"--force-window=yes",
500 +
		"--keep-open=no",
501 +
		"--image-display-duration=" + strconv.FormatFloat(s.ImageDuration, 'f', -1, 64),
502 +
		"--hwdec=" + p.cfg.MpvHwdec,
503 +
		"--no-osc",
504 +
		"--osd-level=0",
505 +
		"--cursor-autohide=always",
506 +
		"--no-input-default-bindings",
507 +
		"--input-vo-keyboard=no",
508 +
		"--stop-screensaver=yes",
509 +
		// Kein --no-terminal: sonst verschluckt mpv seine eigenen
510 +
		// Fehlermeldungen, und im Journal steht nur, dass es beendet wurde.
511 +
		"--msg-level=all=warn",
512 +
		"--no-input-terminal",
513 +
	}
514 +
	if p.cfg.Audio {
515 +
		args = append(args, "--mute="+yesNo(s.Mute))
516 +
	} else {
517 +
		// Ohne Tonausgabe kann auch kein blockierendes Audiogeraet die
518 +
		// Wiedergabe anhalten - fuer eine reine Anzeigetafel der sichere Weg.
519 +
		args = append(args, "--no-audio")
520 +
	}
521 +
	if disp.mode == "drm" {
522 +
		args = append(args, "--vo=gpu", "--gpu-context=drm")
523 +
	} else {
524 +
		args = append(args, "--vo=gpu")
525 +
	}
526 +
	args = append(args, splitArgs(p.cfg.MpvExtraArgs)...)
527 +
	return args
528 +
}
529 +
530 +
func yesNo(b bool) string {
531 +
	if b {
532 +
		return "yes"
533 +
	}
534 +
	return "no"
535 +
}
536 +
537 +
// splitArgs zerlegt eine Optionszeile und respektiert dabei Anfuehrungszeichen.
538 +
func splitArgs(s string) []string {
539 +
	var out []string
540 +
	var cur strings.Builder
541 +
	var quote rune
542 +
	for _, r := range s {
543 +
		switch {
544 +
		case quote != 0:
545 +
			if r == quote {
546 +
				quote = 0
547 +
			} else {
548 +
				cur.WriteRune(r)
549 +
			}
550 +
		case r == '"' || r == '\'':
551 +
			quote = r
552 +
		case r == ' ' || r == '\t':
553 +
			if cur.Len() > 0 {
554 +
				out = append(out, cur.String())
555 +
				cur.Reset()
556 +
			}
557 +
		default:
558 +
			cur.WriteRune(r)
559 +
		}
560 +
	}
561 +
	if cur.Len() > 0 {
562 +
		out = append(out, cur.String())
563 +
	}
564 +
	return out
565 +
}
566 +
567 +
// ---------------------------------------------------------------------------
568 +
// Steuerung aus dem Webinterface
569 +
// ---------------------------------------------------------------------------
570 +
571 +
func (p *Player) running() bool {
572 +
	p.mu.RLock()
573 +
	defer p.mu.RUnlock()
574 +
	return p.client != nil
575 +
}
576 +
577 +
func (p *Player) send(ch chan int, delta int) error {
578 +
	if !p.running() {
579 +
		return fmt.Errorf("player laeuft gerade nicht")
580 +
	}
581 +
	select {
582 +
	case ch <- delta:
583 +
		return nil
584 +
	default:
585 +
		return fmt.Errorf("player ist beschaeftigt")
586 +
	}
587 +
}
588 +
589 +
// Next springt zum naechsten Eintrag.
590 +
func (p *Player) Next() error { return p.send(p.advance, 1) }
591 +
592 +
// Prev springt zum vorherigen Eintrag.
593 +
func (p *Player) Prev() error { return p.send(p.advance, -1) }
594 +
595 +
// TogglePause haelt die Wiedergabe an oder setzt sie fort.
596 +
func (p *Player) TogglePause() error {
597 +
	p.mu.RLock()
598 +
	c := p.client
599 +
	p.mu.RUnlock()
600 +
	if c == nil {
601 +
		return fmt.Errorf("player laeuft gerade nicht")
602 +
	}
603 +
	_, err := c.Command("cycle", "pause")
604 +
	return err
605 +
}
606 +
607 +
// ReloadNow beginnt die Playliste sofort von vorn.
608 +
func (p *Player) ReloadNow() error {
609 +
	if !p.running() {
610 +
		return fmt.Errorf("player laeuft gerade nicht")
611 +
	}
612 +
	select {
613 +
	case p.reloadNow <- struct{}{}:
614 +
	default:
615 +
	}
616 +
	return nil
617 +
}
618 +
619 +
// Restart startet den mpv-Prozess komplett neu.
620 +
func (p *Player) Restart() error {
621 +
	select {
622 +
	case p.restart <- struct{}{}:
623 +
	default:
624 +
	}
625 +
	return nil
626 +
}
627 +
628 +
func (p *Player) setRunning(v bool) {
629 +
	p.mu.Lock()
630 +
	p.status.Running = v
631 +
	if !v {
632 +
		// p.playing bleibt stehen: Daran erkennt der naechste mpv-Prozess,
633 +
		// wo er wieder einsteigen soll.
634 +
		p.status.Current = ""
635 +
	}
636 +
	p.mu.Unlock()
637 +
}
638 +
639 +
func (p *Player) setError(msg string) {
640 +
	p.mu.Lock()
641 +
	p.status.LastError = msg
642 +
	p.mu.Unlock()
643 +
}
hinzugefügt internal/player/segment.go
+90 −0 Datei ansehen
@@ -0,0 +1,90 @@
1 +
package player
2 +
3 +
import (
4 +
	"path/filepath"
5 +
	"strings"
6 +
7 +
	"ehc/tvwall/internal/config"
8 +
	"ehc/tvwall/internal/playlist"
9 +
)
10 +
11 +
// segment ist ein Stueck Playliste, das ein mpv-Prozess am Stueck abspielt:
12 +
// entweder eine Folge aufeinanderfolgender Bilder oder genau ein Video.
13 +
//
14 +
// Diese Aufteilung loest zwei beobachtete Fehler auf einmal:
15 +
//
16 +
//   - Ein Video, das in einen mpv-Prozess geladen wird, der vorher schon etwas
17 +
//     angezeigt hat, bricht mit "partial file" ab. Jedes Video bekommt deshalb
18 +
//     einen eigenen, frischen Prozess.
19 +
//   - Wird dagegen jedes einzelne Bild per loadfile nachgeladen, bleibt die
20 +
//     Bildausgabe nach etwa neun Wechseln stehen (mpv meldet weiter Wechsel,
21 +
//     der Bildschirm friert ein). Bilderfolgen laufen deshalb als Playliste
22 +
//     innerhalb eines Prozesses - mpv schaltet selbst weiter.
23 +
type segment struct {
24 +
	kind  string // "images" oder "video"
25 +
	names []string
26 +
}
27 +
28 +
// buildSegments fasst aufeinanderfolgende Bilder zusammen; jedes Video wird
29 +
// ein eigener Abschnitt.
30 +
func buildSegments(entries []playlist.Entry) []segment {
31 +
	var segs []segment
32 +
	for _, e := range entries {
33 +
		if e.Kind == "video" {
34 +
			segs = append(segs, segment{kind: "video", names: []string{e.Name}})
35 +
			continue
36 +
		}
37 +
		if n := len(segs); n > 0 && segs[n-1].kind == "images" {
38 +
			segs[n-1].names = append(segs[n-1].names, e.Name)
39 +
			continue
40 +
		}
41 +
		segs = append(segs, segment{kind: "images", names: []string{e.Name}})
42 +
	}
43 +
	return segs
44 +
}
45 +
46 +
func (s segment) equal(o segment) bool {
47 +
	if s.kind != o.kind || len(s.names) != len(o.names) {
48 +
		return false
49 +
	}
50 +
	for i := range s.names {
51 +
		if s.names[i] != o.names[i] {
52 +
			return false
53 +
		}
54 +
	}
55 +
	return true
56 +
}
57 +
58 +
func (s segment) label() string {
59 +
	if s.kind == "video" && len(s.names) == 1 {
60 +
		return "Video " + s.names[0]
61 +
	}
62 +
	return strings.Join(s.names, ", ")
63 +
}
64 +
65 +
// writePlaylist schreibt die Dateien des Abschnitts als m3u fuer mpv.
66 +
func (s segment) writePlaylist(cfg *config.Config, mediaDir string) (string, error) {
67 +
	var b strings.Builder
68 +
	b.WriteString("#EXTM3U\n")
69 +
	for _, n := range s.names {
70 +
		b.WriteString(filepath.Join(mediaDir, n))
71 +
		b.WriteByte('\n')
72 +
	}
73 +
	path := filepath.Join(cfg.StateDir, "segment.m3u")
74 +
	if err := config.WriteFileAtomic(path, []byte(b.String()), 0o664); err != nil {
75 +
		return "", err
76 +
	}
77 +
	return path, nil
78 +
}
79 +
80 +
func sameSegments(a, b []segment) bool {
81 +
	if len(a) != len(b) {
82 +
		return false
83 +
	}
84 +
	for i := range a {
85 +
		if !a[i].equal(b[i]) {
86 +
			return false
87 +
		}
88 +
	}
89 +
	return true
90 +
}
geändert internal/playlist/playlist.go
+33 −19 Datei ansehen
@@ -20,20 +20,33 @@ var (
20 20
		".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".bmp": true,
21 21
		".webp": true, ".tif": true, ".tiff": true,
22 22
	}
23 23
	videoExt = map[string]bool{
24 24
		".mp4": true, ".m4v": true, ".mov": true, ".mkv": true, ".avi": true,
25 25
		".webm": true, ".mpg": true, ".mpeg": true, ".wmv": true, ".flv": true,
26 26
		".ts": true, ".3gp": true, ".ogv": true,
27 27
	}
28 28
)
29 29
30 +
// Formate, die mpv nicht zuverlaessig anzeigt, die aber beim Hochladen
31 +
// angenommen und in JPEG umgewandelt werden. iPhones liefern HEIC.
32 +
var convertExt = map[string]bool{".heic": true, ".heif": true}
33 +
34 +
// Convertible meldet, ob die Datei beim Hochladen umgewandelt werden muss.
35 +
func Convertible(name string) bool {
36 +
	return convertExt[strings.ToLower(filepath.Ext(name))]
37 +
}
38 +
39 +
// UploadAllowed meldet, ob die Datei hochgeladen werden darf - entweder direkt
40 +
// abspielbar oder umwandelbar.
41 +
func UploadAllowed(name string) bool { return Supported(name) || Convertible(name) }
42 +
30 43
// Kind liefert "image", "video" oder "" fuer nicht unterstuetzte Dateien.
31 44
func Kind(name string) string {
32 45
	ext := strings.ToLower(filepath.Ext(name))
33 46
	switch {
34 47
	case imageExt[ext]:
35 48
		return "image"
36 49
	case videoExt[ext]:
37 50
		return "video"
38 51
	}
39 52
	return ""
@@ -82,20 +95,28 @@ func (m *Manager) PlaylistFile() string { return m.playlistFile }
82 95
// Changed liefert den Kanal, ueber den Aenderungen gemeldet werden.
83 96
func (m *Manager) Changed() <-chan struct{} { return m.changed }
84 97
85 98
// Entries liefert eine Kopie der aktuellen Playliste.
86 99
func (m *Manager) Entries() []Entry {
87 100
	m.mu.RLock()
88 101
	defer m.mu.RUnlock()
89 102
	return append([]Entry(nil), m.entries...)
90 103
}
91 104
105 +
// EntriesWithVersion liefert Playliste und Version in einem Rutsch, damit der
106 +
// Player beides konsistent zueinander sieht.
107 +
func (m *Manager) EntriesWithVersion() ([]Entry, uint64) {
108 +
	m.mu.RLock()
109 +
	defer m.mu.RUnlock()
110 +
	return append([]Entry(nil), m.entries...), m.version
111 +
}
112 +
92 113
// Version steigt bei jeder Aenderung - praktisch fuer das Polling im Webinterface.
93 114
func (m *Manager) Version() uint64 {
94 115
	m.mu.RLock()
95 116
	defer m.mu.RUnlock()
96 117
	return m.version
97 118
}
98 119
99 120
// Scan liest den Medienordner neu ein, mischt die gespeicherte Sortierung
100 121
// hinein und schreibt bei Aenderungen die m3u-Datei. Neue Dateien landen
101 122
// (nach Aenderungsdatum sortiert) am Ende der Playliste.
@@ -141,21 +162,21 @@ func (m *Manager) Scan() (bool, error) {
141 162
			ordered = append(ordered, e)
142 163
		}
143 164
	}
144 165
	for _, e := range fresh {
145 166
		if !seen[e.Name] {
146 167
			seen[e.Name] = true
147 168
			ordered = append(ordered, e)
148 169
		}
149 170
	}
150 171
151 -
	return m.commit(ordered, len(ordered) != len(stored))
172 +
	return m.commit(ordered)
152 173
}
153 174
154 175
// SetOrder setzt die Reihenfolge neu. Nicht genannte (aber vorhandene) Dateien
155 176
// werden hinten angehaengt, damit nie etwas aus der Playliste verschwindet.
156 177
func (m *Manager) SetOrder(names []string) error {
157 178
	m.mu.RLock()
158 179
	current := append([]Entry(nil), m.entries...)
159 180
	m.mu.RUnlock()
160 181
161 182
	byName := make(map[string]Entry, len(current))
@@ -170,71 +191,64 @@ func (m *Manager) SetOrder(names []string) error {
170 191
			seen[n] = true
171 192
			ordered = append(ordered, e)
172 193
		}
173 194
	}
174 195
	for _, e := range current {
175 196
		if !seen[e.Name] {
176 197
			ordered = append(ordered, e)
177 198
		}
178 199
	}
179 200
180 -
	_, err := m.commit(ordered, true)
201 +
	_, err := m.commit(ordered)
181 202
	return err
182 203
}
183 204
184 205
// Delete entfernt eine Datei aus dem Medienordner und der Playliste.
185 206
func (m *Manager) Delete(name string) error {
186 207
	safe, err := SafeName(name)
187 208
	if err != nil {
188 209
		return err
189 210
	}
190 211
	if err := os.Remove(filepath.Join(m.mediaDir, safe)); err != nil && !os.IsNotExist(err) {
191 212
		return err
192 213
	}
193 214
	_, err = m.Scan()
194 215
	return err
195 216
}
196 217
197 218
// commit uebernimmt die neue Reihenfolge, schreibt order.json + playlist.m3u
198 -
// und meldet die Aenderung. force erzwingt das Schreiben auch ohne Diff.
199 -
func (m *Manager) commit(ordered []Entry, force bool) (bool, error) {
219 +
// und meldet die Aenderung an den Player.
220 +
func (m *Manager) commit(ordered []Entry) (bool, error) {
200 221
	m.mu.Lock()
201 -
	changed := force || !sameEntries(m.entries, ordered)
202 -
	if !changed {
222 +
	if sameEntries(m.entries, ordered) {
203 223
		m.mu.Unlock()
204 224
		return false, nil
205 225
	}
206 -
	// Auch bei force pruefen, ob sich inhaltlich wirklich etwas geaendert hat.
207 -
	realChange := !sameEntries(m.entries, ordered)
208 226
	m.entries = ordered
209 -
	if realChange {
210 -
		m.version++
211 -
	}
227 +
	m.version++
212 228
	m.mu.Unlock()
213 229
214 -
	if !realChange {
215 -
		return false, nil
216 -
	}
217 -
218 230
	names := make([]string, len(ordered))
219 231
	var m3u strings.Builder
220 232
	m3u.WriteString("#EXTM3U\n")
221 233
	for i, e := range ordered {
222 234
		names[i] = e.Name
223 235
		m3u.WriteString(filepath.Join(m.mediaDir, e.Name))
224 236
		m3u.WriteByte('\n')
225 237
	}
226 238
227 -
	if data, err := json.MarshalIndent(names, "", "  "); err == nil {
228 -
		if err := config.WriteFileAtomic(m.orderFile, append(data, '\n'), 0o664); err != nil {
229 -
			return true, fmt.Errorf("order.json schreiben: %w", err)
230 -
		}
239 +
	data, err := json.MarshalIndent(names, "", "  ")
240 +
	if err != nil {
241 +
		return true, err
242 +
	}
243 +
	if err := config.WriteFileAtomic(m.orderFile, append(data, '\n'), 0o664); err != nil {
244 +
		return true, fmt.Errorf("order.json schreiben: %w", err)
231 245
	}
232 246
	if err := config.WriteFileAtomic(m.playlistFile, []byte(m3u.String()), 0o664); err != nil {
233 247
		return true, fmt.Errorf("playlist.m3u schreiben: %w", err)
234 248
	}
235 249
236 250
	select {
237 251
	case m.changed <- struct{}{}:
238 252
	default:
239 253
	}
240 254
	return true, nil
hinzugefügt internal/web/assets.go
+35 −0 Datei ansehen
@@ -0,0 +1,35 @@
1 +
package web
2 +
3 +
import (
4 +
	"embed"
5 +
	"io/fs"
6 +
	"log"
7 +
	"net/http"
8 +
	"os/exec"
9 +
)
10 +
11 +
//go:embed assets
12 +
var assetsFS embed.FS
13 +
14 +
// staticHandler liefert die eingebetteten CSS-/JS-Dateien aus.
15 +
func staticHandler() http.Handler {
16 +
	sub, err := fs.Sub(assetsFS, "assets")
17 +
	if err != nil {
18 +
		log.Fatalf("web: eingebettete dateien: %v", err)
19 +
	}
20 +
	return http.FileServer(http.FS(sub))
21 +
}
22 +
23 +
// writeAsset schreibt eine eingebettete Datei direkt in die Antwort.
24 +
func writeAsset(w http.ResponseWriter, name string) {
25 +
	data, err := assetsFS.ReadFile("assets/" + name)
26 +
	if err != nil {
27 +
		http.Error(w, "Datei fehlt: "+name, http.StatusInternalServerError)
28 +
		return
29 +
	}
30 +
	if _, err := w.Write(data); err != nil {
31 +
		log.Printf("web: %s ausliefern: %v", name, err)
32 +
	}
33 +
}
34 +
35 +
func lookPath(name string) (string, error) { return exec.LookPath(name) }
hinzugefügt internal/web/assets/app.js
+362 −0 Datei ansehen
@@ -0,0 +1,362 @@
1 +
/* TV-Wall Webinterface. Reines Vanilla-JS ohne externe Bibliotheken, damit die
2 +
   Oberflaeche auch ohne Internetverbindung (Pi als Access Point) funktioniert. */
3 +
'use strict';
4 +
5 +
const $ = (id) => document.getElementById(id);
6 +
7 +
const el = {
8 +
  deviceLine: $('deviceLine'),
9 +
  nowText: $('nowText'),
10 +
  statusDot: $('statusDot'),
11 +
  pauseBtn: $('pauseBtn'),
12 +
  list: $('list'),
13 +
  empty: $('empty'),
14 +
  counter: $('counter'),
15 +
  drop: $('drop'),
16 +
  fileInput: $('fileInput'),
17 +
  dropHint: $('dropHint'),
18 +
  progressWrap: $('progressWrap'),
19 +
  progressFill: $('progressFill'),
20 +
  progressLabel: $('progressLabel'),
21 +
  durationInput: $('durationInput'),
22 +
  muteInput: $('muteInput'),
23 +
  saveSettings: $('saveSettings'),
24 +
  diskLine: $('diskLine'),
25 +
  toasts: $('toasts'),
26 +
};
27 +
28 +
let state = null;
29 +
let renderedVersion = -1;
30 +
let busy = false;          // laeuft gerade ein Upload?
31 +
let dragging = false;      // wird gerade sortiert?
32 +
let settingsTouched = false;
33 +
34 +
/* ------------------------------------------------------------------ Helfer */
35 +
36 +
function toast(msg, kind) {
37 +
  const node = document.createElement('div');
38 +
  node.className = 'toast' + (kind ? ' ' + kind : '');
39 +
  node.textContent = msg;
40 +
  el.toasts.appendChild(node);
41 +
  setTimeout(() => node.remove(), kind === 'err' ? 6000 : 3200);
42 +
}
43 +
44 +
function formatSize(bytes) {
45 +
  if (!bytes) return '0 B';
46 +
  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
47 +
  let i = 0, n = bytes;
48 +
  while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
49 +
  return (n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)) + ' ' + units[i];
50 +
}
51 +
52 +
async function api(path, body) {
53 +
  const opts = body === undefined
54 +
    ? {}
55 +
    : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
56 +
  const res = await fetch(path, opts);
57 +
  let data = null;
58 +
  try { data = await res.json(); } catch (e) { /* leere Antwort ist ok */ }
59 +
  if (!res.ok) throw new Error((data && data.error) || ('HTTP ' + res.status));
60 +
  return data;
61 +
}
62 +
63 +
/* -------------------------------------------------------------- Darstellung */
64 +
65 +
async function refresh() {
66 +
  if (busy || dragging) return;
67 +
  try {
68 +
    state = await api('/api/state');
69 +
  } catch (e) {
70 +
    el.statusDot.className = 'dot err';
71 +
    el.nowText.textContent = 'keine Verbindung';
72 +
    return;
73 +
  }
74 +
  renderStatus();
75 +
  if (state.version !== renderedVersion) {
76 +
    renderList();
77 +
    renderedVersion = state.version;
78 +
  } else {
79 +
    markPlaying();
80 +
  }
81 +
}
82 +
83 +
function renderStatus() {
84 +
  const d = state.device, s = state.status;
85 +
  el.deviceLine.textContent = d.ssid + ' · ' + d.hostname + '.local';
86 +
87 +
  if (!s.running) {
88 +
    el.statusDot.className = 'dot err';
89 +
    el.nowText.textContent = s.last_error ? 'Player-Fehler' : 'Player startet …';
90 +
  } else if (s.current) {
91 +
    el.statusDot.className = 'dot on';
92 +
    el.nowText.textContent = (s.paused ? 'Pause: ' : 'Läuft: ') + s.current;
93 +
  } else {
94 +
    el.statusDot.className = 'dot';
95 +
    el.nowText.textContent = state.entries.length ? 'wird geladen …' : 'Playliste leer';
96 +
  }
97 +
  el.pauseBtn.textContent = s.paused ? '▶ Weiter' : '⏸ Pause';
98 +
99 +
  el.counter.textContent = state.entries.length
100 +
    ? `${state.entries.length} Einträge · ${d.images} Bilder · ${d.videos} Videos`
101 +
    : '';
102 +
103 +
  if (!settingsTouched) {
104 +
    el.durationInput.value = state.settings.image_duration;
105 +
    el.muteInput.checked = state.settings.mute;
106 +
  }
107 +
108 +
  el.dropHint.textContent = 'Bilder und Videos · max. ' + d.max_upload_mb + ' MB pro Vorgang';
109 +
  const parts = [];
110 +
  if (state.disk.total) parts.push(formatSize(state.disk.free) + ' von ' + formatSize(state.disk.total) + ' frei');
111 +
  if (s.display) parts.push('Bildausgabe: ' + s.display);
112 +
  el.diskLine.textContent = parts.join(' · ');
113 +
}
114 +
115 +
function renderList() {
116 +
  el.list.textContent = '';
117 +
  el.empty.hidden = state.entries.length > 0;
118 +
119 +
  for (const entry of state.entries) {
120 +
    const li = document.createElement('li');
121 +
    li.className = 'item';
122 +
    li.dataset.name = entry.name;
123 +
124 +
    const grip = document.createElement('div');
125 +
    grip.className = 'grip';
126 +
    grip.textContent = '⠿';
127 +
    grip.title = 'Zum Sortieren ziehen';
128 +
    grip.addEventListener('pointerdown', (ev) => startDrag(ev, li));
129 +
130 +
    const img = document.createElement('img');
131 +
    img.className = 'thumb';
132 +
    img.loading = 'lazy';
133 +
    img.alt = '';
134 +
    img.src = '/thumb/' + encodeURIComponent(entry.name);
135 +
136 +
    const meta = document.createElement('div');
137 +
    meta.className = 'meta';
138 +
    const name = document.createElement('div');
139 +
    name.className = 'name';
140 +
    name.textContent = entry.name;
141 +
    name.title = entry.name;
142 +
    const tags = document.createElement('div');
143 +
    tags.className = 'tags';
144 +
    const badge = document.createElement('span');
145 +
    badge.className = 'badge' + (entry.kind === 'video' ? ' video' : '');
146 +
    badge.textContent = entry.kind === 'video' ? 'Video' : 'Bild';
147 +
    const size = document.createElement('span');
148 +
    size.textContent = formatSize(entry.size);
149 +
    tags.append(badge, size);
150 +
    meta.append(name, tags);
151 +
152 +
    const actions = document.createElement('div');
153 +
    actions.className = 'actions';
154 +
    actions.append(
155 +
      iconButton('↑', 'Nach oben', () => move(entry.name, -1)),
156 +
      iconButton('↓', 'Nach unten', () => move(entry.name, 1)),
157 +
      iconButton('🗑', 'Löschen', () => remove(entry.name), 'del'),
158 +
    );
159 +
160 +
    li.append(grip, img, meta, actions);
161 +
    el.list.appendChild(li);
162 +
  }
163 +
  markPlaying();
164 +
}
165 +
166 +
function iconButton(label, title, onClick, extra) {
167 +
  const b = document.createElement('button');
168 +
  b.className = 'icon-btn' + (extra ? ' ' + extra : '');
169 +
  b.type = 'button';
170 +
  b.textContent = label;
171 +
  b.title = title;
172 +
  b.setAttribute('aria-label', title);
173 +
  b.addEventListener('click', onClick);
174 +
  return b;
175 +
}
176 +
177 +
function markPlaying() {
178 +
  const current = state && state.status ? state.status.current : '';
179 +
  for (const li of el.list.children) {
180 +
    li.classList.toggle('playing', !!current && li.dataset.name === current);
181 +
  }
182 +
}
183 +
184 +
/* ------------------------------------------------------------- Sortieren */
185 +
186 +
function currentOrder() {
187 +
  return Array.from(el.list.children).map((li) => li.dataset.name);
188 +
}
189 +
190 +
async function saveOrder() {
191 +
  const order = currentOrder();
192 +
  try {
193 +
    await api('/api/order', { order });
194 +
    renderedVersion = -1; // beim naechsten Abruf frisch zeichnen
195 +
    toast('Reihenfolge gespeichert', 'ok');
196 +
  } catch (e) {
197 +
    toast('Reihenfolge speichern fehlgeschlagen: ' + e.message, 'err');
198 +
  }
199 +
}
200 +
201 +
function move(name, delta) {
202 +
  const items = Array.from(el.list.children);
203 +
  const idx = items.findIndex((li) => li.dataset.name === name);
204 +
  const target = idx + delta;
205 +
  if (idx < 0 || target < 0 || target >= items.length) return;
206 +
  if (delta < 0) el.list.insertBefore(items[idx], items[target]);
207 +
  else el.list.insertBefore(items[target], items[idx]);
208 +
  saveOrder();
209 +
}
210 +
211 +
// Sortieren per Zeigergeraet - funktioniert mit Maus und Finger gleichermassen.
212 +
let dragItem = null;
213 +
214 +
function startDrag(ev, li) {
215 +
  if (ev.button !== undefined && ev.button !== 0) return;
216 +
  ev.preventDefault();
217 +
  dragging = true;
218 +
  dragItem = li;
219 +
  li.classList.add('dragging');
220 +
  ev.target.setPointerCapture(ev.pointerId);
221 +
  ev.target.addEventListener('pointermove', onDragMove);
222 +
  ev.target.addEventListener('pointerup', endDrag);
223 +
  ev.target.addEventListener('pointercancel', endDrag);
224 +
}
225 +
226 +
function onDragMove(ev) {
227 +
  if (!dragItem) return;
228 +
  ev.preventDefault();
229 +
  const over = document.elementFromPoint(ev.clientX, ev.clientY);
230 +
  const target = over && over.closest ? over.closest('.item') : null;
231 +
  if (!target || target === dragItem || target.parentElement !== el.list) return;
232 +
  const rect = target.getBoundingClientRect();
233 +
  const after = ev.clientY > rect.top + rect.height / 2;
234 +
  el.list.insertBefore(dragItem, after ? target.nextSibling : target);
235 +
}
236 +
237 +
function endDrag(ev) {
238 +
  if (!dragItem) return;
239 +
  ev.target.removeEventListener('pointermove', onDragMove);
240 +
  ev.target.removeEventListener('pointerup', endDrag);
241 +
  ev.target.removeEventListener('pointercancel', endDrag);
242 +
  dragItem.classList.remove('dragging');
243 +
  dragItem = null;
244 +
  dragging = false;
245 +
  saveOrder();
246 +
}
247 +
248 +
async function remove(name) {
249 +
  if (!confirm('"' + name + '" wirklich löschen?')) return;
250 +
  try {
251 +
    await api('/api/delete', { name });
252 +
    renderedVersion = -1;
253 +
    await refresh();
254 +
    toast('Gelöscht: ' + name, 'ok');
255 +
  } catch (e) {
256 +
    toast('Löschen fehlgeschlagen: ' + e.message, 'err');
257 +
  }
258 +
}
259 +
260 +
/* --------------------------------------------------------------- Hochladen */
261 +
262 +
function upload(files) {
263 +
  const list = Array.from(files || []);
264 +
  if (!list.length) {
265 +
    toast('Es wurde keine Datei ausgewählt', 'err');
266 +
    return;
267 +
  }
268 +
269 +
  const form = new FormData();
270 +
  for (const f of list) form.append('files', f, f.name);
271 +
272 +
  busy = true;
273 +
  el.progressWrap.hidden = false;
274 +
  el.progressFill.style.width = '0%';
275 +
  el.progressLabel.textContent = '0 %';
276 +
277 +
  const xhr = new XMLHttpRequest();
278 +
  xhr.open('POST', '/api/upload');
279 +
  xhr.upload.addEventListener('progress', (ev) => {
280 +
    if (!ev.lengthComputable) return;
281 +
    const pct = Math.round((ev.loaded / ev.total) * 100);
282 +
    el.progressFill.style.width = pct + '%';
283 +
    el.progressLabel.textContent = pct + ' %';
284 +
  });
285 +
  xhr.addEventListener('load', () => {
286 +
    busy = false;
287 +
    el.progressWrap.hidden = true;
288 +
    // Erst jetzt zuruecksetzen: iOS gibt die ausgewaehlten Dateien frei,
289 +
    // sobald das Feld geleert wird - vorher waere der Upload leer.
290 +
    el.fileInput.value = '';
291 +
    let data = null;
292 +
    try { data = JSON.parse(xhr.responseText); } catch (e) { /* egal */ }
293 +
    if (xhr.status >= 200 && xhr.status < 300 && data) {
294 +
      if (data.uploaded.length) toast(data.uploaded.length + ' Datei(en) hinzugefügt', 'ok');
295 +
      for (const s of data.skipped || []) toast('Übersprungen: ' + s, 'err');
296 +
    } else {
297 +
      toast('Upload fehlgeschlagen: ' + ((data && data.error) || ('HTTP ' + xhr.status)), 'err');
298 +
    }
299 +
    renderedVersion = -1;
300 +
    refresh();
301 +
  });
302 +
  xhr.addEventListener('error', () => {
303 +
    busy = false;
304 +
    el.progressWrap.hidden = true;
305 +
    el.fileInput.value = '';
306 +
    toast('Upload fehlgeschlagen (Verbindung)', 'err');
307 +
  });
308 +
  xhr.send(form);
309 +
}
310 +
311 +
/* ----------------------------------------------------------- Verdrahtung */
312 +
313 +
// Kein click()-Aufruf auf das Feld: das Label oeffnet die Auswahl bereits
314 +
// selbst. Beides zusammen laesst die Fotoauswahl auf dem iPhone ohne Ergebnis
315 +
// wieder zugehen.
316 +
el.fileInput.addEventListener('change', () => upload(el.fileInput.files));
317 +
318 +
for (const type of ['dragenter', 'dragover']) {
319 +
  el.drop.addEventListener(type, (ev) => { ev.preventDefault(); el.drop.classList.add('over'); });
320 +
}
321 +
for (const type of ['dragleave', 'drop']) {
322 +
  el.drop.addEventListener(type, (ev) => { ev.preventDefault(); el.drop.classList.remove('over'); });
323 +
}
324 +
el.drop.addEventListener('drop', (ev) => {
325 +
  if (ev.dataTransfer && ev.dataTransfer.files) upload(ev.dataTransfer.files);
326 +
});
327 +
// Verhindert, dass der Browser eine daneben abgelegte Datei einfach oeffnet.
328 +
window.addEventListener('dragover', (ev) => ev.preventDefault());
329 +
window.addEventListener('drop', (ev) => ev.preventDefault());
330 +
331 +
document.querySelectorAll('[data-action]').forEach((btn) => {
332 +
  btn.addEventListener('click', async () => {
333 +
    try {
334 +
      await api('/api/control', { action: btn.dataset.action });
335 +
      setTimeout(refresh, 250);
336 +
    } catch (e) {
337 +
      toast('Aktion fehlgeschlagen: ' + e.message, 'err');
338 +
    }
339 +
  });
340 +
});
341 +
342 +
el.durationInput.addEventListener('input', () => { settingsTouched = true; });
343 +
el.muteInput.addEventListener('change', () => { settingsTouched = true; });
344 +
345 +
el.saveSettings.addEventListener('click', async () => {
346 +
  const value = parseFloat(el.durationInput.value);
347 +
  if (!(value >= 0.5 && value <= 3600)) {
348 +
    toast('Anzeigedauer muss zwischen 0,5 und 3600 Sekunden liegen', 'err');
349 +
    return;
350 +
  }
351 +
  try {
352 +
    await api('/api/settings', { image_duration: value, mute: el.muteInput.checked });
353 +
    settingsTouched = false;
354 +
    toast('Einstellungen gespeichert', 'ok');
355 +
    refresh();
356 +
  } catch (e) {
357 +
    toast('Speichern fehlgeschlagen: ' + e.message, 'err');
358 +
  }
359 +
});
360 +
361 +
refresh();
362 +
setInterval(refresh, 3000);
hinzugefügt internal/web/assets/index.html
+90 −0 Datei ansehen
@@ -0,0 +1,90 @@
1 +
<!doctype html>
2 +
<html lang="de">
3 +
<head>
4 +
<meta charset="utf-8">
5 +
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6 +
<meta name="theme-color" content="#11161f">
7 +
<title>TV-Wall</title>
8 +
<link rel="stylesheet" href="/static/style.css">
9 +
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%23111820'/><rect x='2.5' y='4' width='11' height='7' rx='1' fill='%2352b6ff'/><rect x='6' y='12' width='4' height='1.2' rx='.6' fill='%2352b6ff'/></svg>">
10 +
</head>
11 +
<body>
12 +
13 +
<header class="top">
14 +
  <div class="brand">
15 +
    <span class="logo" aria-hidden="true"></span>
16 +
    <div>
17 +
      <h1>TV-Wall</h1>
18 +
      <p class="sub" id="deviceLine">wird geladen …</p>
19 +
    </div>
20 +
  </div>
21 +
  <div class="now" id="nowPlaying">
22 +
    <span class="dot" id="statusDot"></span>
23 +
    <span id="nowText">…</span>
24 +
  </div>
25 +
</header>
26 +
27 +
<main>
28 +
29 +
  <section class="card" id="uploadCard">
30 +
    <h2>Hochladen</h2>
31 +
    <!-- Das Feld steht bewusst AUSSERHALB des Labels und ist nicht per
32 +
         hidden-Attribut versteckt: iOS Safari oeffnet die Fotoauswahl sonst
33 +
         zweimal (einmal ueber das Label, einmal per JavaScript) und verwirft
34 +
         das Ergebnis. -->
35 +
    <input type="file" id="fileInput" class="visually-hidden" multiple accept="image/*,video/*">
36 +
    <label class="drop" id="drop" for="fileInput">
37 +
      <span class="drop-icon" aria-hidden="true">+</span>
38 +
      <span class="drop-text">Dateien hierher ziehen<br><b>oder tippen zum Auswählen</b></span>
39 +
      <span class="drop-hint" id="dropHint">Bilder und Videos</span>
40 +
    </label>
41 +
    <div class="progress" id="progressWrap" hidden>
42 +
      <div class="bar"><div class="fill" id="progressFill"></div></div>
43 +
      <span class="progress-label" id="progressLabel">0 %</span>
44 +
    </div>
45 +
  </section>
46 +
47 +
  <section class="card">
48 +
    <h2>Wiedergabe</h2>
49 +
    <div class="controls">
50 +
      <button class="btn" data-action="prev" title="Vorheriger Eintrag">⏮ Zurück</button>
51 +
      <button class="btn" data-action="pause" id="pauseBtn">⏸ Pause</button>
52 +
      <button class="btn" data-action="next" title="Nächster Eintrag">⏭ Weiter</button>
53 +
      <button class="btn ghost" data-action="reload" title="Playliste sofort neu laden">↻ Neu laden</button>
54 +
    </div>
55 +
    <div class="settings">
56 +
      <label class="field">
57 +
        <span>Anzeigedauer Bilder</span>
58 +
        <span class="input-unit">
59 +
          <input type="number" id="durationInput" min="0.5" max="3600" step="0.5" inputmode="decimal">
60 +
          <span>Sek.</span>
61 +
        </span>
62 +
      </label>
63 +
      <label class="switch">
64 +
        <input type="checkbox" id="muteInput">
65 +
        <span>Ton der Videos stummschalten</span>
66 +
      </label>
67 +
      <button class="btn small" id="saveSettings">Speichern</button>
68 +
    </div>
69 +
  </section>
70 +
71 +
  <section class="card">
72 +
    <div class="card-head">
73 +
      <h2>Playliste</h2>
74 +
      <span class="counter" id="counter"></span>
75 +
    </div>
76 +
    <p class="hint">Reihenfolge per Ziehen ändern – oder mit den Pfeiltasten. Änderungen wirken sofort.</p>
77 +
    <ul class="list" id="list"></ul>
78 +
    <p class="empty" id="empty" hidden>Noch nichts da. Lade oben Bilder oder Videos hoch.</p>
79 +
  </section>
80 +
81 +
  <footer class="foot">
82 +
    <span id="diskLine"></span>
83 +
  </footer>
84 +
</main>
85 +
86 +
<div class="toasts" id="toasts" aria-live="polite"></div>
87 +
88 +
<script src="/static/app.js"></script>
89 +
</body>
90 +
</html>
hinzugefügt internal/web/assets/style.css
+184 −0 Datei ansehen
@@ -0,0 +1,184 @@
1 +
/* TV-Wall Webinterface - bewusst ohne externe Abhaengigkeiten, da der Pi als
2 +
   Access Point keine Internetverbindung hat. */
3 +
4 +
:root {
5 +
  --bg: #0d1117;
6 +
  --bg-card: #161c26;
7 +
  --bg-soft: #1e2632;
8 +
  --line: #2a3442;
9 +
  --fg: #e7edf5;
10 +
  --fg-dim: #94a3b8;
11 +
  --accent: #52b6ff;
12 +
  --accent-dark: #1d84cc;
13 +
  --danger: #ff6b6b;
14 +
  --ok: #4ade80;
15 +
  --radius: 14px;
16 +
}
17 +
18 +
* { box-sizing: border-box; }
19 +
20 +
html { -webkit-text-size-adjust: 100%; }
21 +
22 +
body {
23 +
  margin: 0;
24 +
  padding: 0 0 3rem;
25 +
  background: var(--bg);
26 +
  color: var(--fg);
27 +
  font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
28 +
}
29 +
30 +
h1, h2 { margin: 0; font-weight: 650; }
31 +
h1 { font-size: 1.15rem; letter-spacing: .02em; }
32 +
h2 { font-size: .95rem; text-transform: uppercase; letter-spacing: .08em; color: var(--fg-dim); }
33 +
34 +
/* ---------- Kopfzeile ---------- */
35 +
.top {
36 +
  position: sticky; top: 0; z-index: 20;
37 +
  display: flex; flex-wrap: wrap; gap: .75rem;
38 +
  align-items: center; justify-content: space-between;
39 +
  padding: .85rem 1rem;
40 +
  padding-top: max(.85rem, env(safe-area-inset-top));
41 +
  background: rgba(13, 17, 23, .92);
42 +
  backdrop-filter: blur(8px);
43 +
  border-bottom: 1px solid var(--line);
44 +
}
45 +
.brand { display: flex; align-items: center; gap: .7rem; }
46 +
.logo {
47 +
  width: 34px; height: 34px; border-radius: 9px; flex: none;
48 +
  background: linear-gradient(150deg, var(--accent), var(--accent-dark));
49 +
  box-shadow: 0 0 0 1px rgba(255,255,255,.08) inset;
50 +
}
51 +
.sub { margin: 0; font-size: .78rem; color: var(--fg-dim); }
52 +
.now {
53 +
  display: flex; align-items: center; gap: .5rem;
54 +
  font-size: .82rem; color: var(--fg-dim);
55 +
  max-width: 100%; overflow: hidden;
56 +
}
57 +
.now span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
58 +
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--fg-dim); flex: none; }
59 +
.dot.on { background: var(--ok); box-shadow: 0 0 8px rgba(74,222,128,.7); }
60 +
.dot.err { background: var(--danger); }
61 +
62 +
/* ---------- Layout ---------- */
63 +
main { max-width: 780px; margin: 0 auto; padding: 1rem; display: grid; gap: 1rem; }
64 +
.card { background: var(--bg-card); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; }
65 +
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
66 +
.counter { font-size: .8rem; color: var(--fg-dim); }
67 +
.hint { margin: .5rem 0 .9rem; font-size: .8rem; color: var(--fg-dim); }
68 +
69 +
/* ---------- Upload ---------- */
70 +
/* Nicht display:none - sonst laesst sich das Feld auf manchen Browsern nicht
71 +
   ueber das Label ansprechen. */
72 +
.visually-hidden {
73 +
  position: absolute; width: 1px; height: 1px;
74 +
  padding: 0; margin: -1px; overflow: hidden;
75 +
  clip-path: inset(50%); white-space: nowrap; border: 0;
76 +
}
77 +
78 +
.drop {
79 +
  margin-top: .8rem;
80 +
  display: flex; flex-direction: column; align-items: center; justify-content: center;
81 +
  gap: .4rem; text-align: center;
82 +
  min-height: 140px; padding: 1.2rem;
83 +
  border: 2px dashed var(--line); border-radius: var(--radius);
84 +
  background: var(--bg-soft); cursor: pointer;
85 +
  transition: border-color .15s, background .15s;
86 +
}
87 +
.drop:active, .drop.over { border-color: var(--accent); background: #1b2939; }
88 +
.drop-icon { font-size: 1.8rem; color: var(--accent); line-height: 1; }
89 +
.drop-text { font-size: .92rem; color: var(--fg-dim); }
90 +
.drop-text b { color: var(--fg); font-weight: 600; }
91 +
.drop-hint { font-size: .74rem; color: var(--fg-dim); opacity: .75; }
92 +
93 +
.progress { display: flex; align-items: center; gap: .7rem; margin-top: .8rem; }
94 +
.bar { flex: 1; height: 8px; border-radius: 99px; background: var(--bg-soft); overflow: hidden; }
95 +
.fill { height: 100%; width: 0; background: linear-gradient(90deg, var(--accent-dark), var(--accent)); transition: width .2s; }
96 +
.progress-label { font-size: .78rem; color: var(--fg-dim); min-width: 3.2rem; text-align: right; }
97 +
98 +
/* ---------- Buttons ---------- */
99 +
.controls { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .8rem; }
100 +
.btn {
101 +
  flex: 1 1 auto; min-width: 6.5rem; min-height: 44px;
102 +
  padding: .55rem .9rem;
103 +
  border: 1px solid var(--line); border-radius: 10px;
104 +
  background: var(--bg-soft); color: var(--fg);
105 +
  font-size: .9rem; font-weight: 550; cursor: pointer;
106 +
  transition: background .12s, border-color .12s;
107 +
}
108 +
.btn:hover { border-color: var(--accent); }
109 +
.btn:active { background: #253242; }
110 +
.btn.ghost { background: transparent; color: var(--fg-dim); }
111 +
.btn.small { flex: 0 0 auto; min-width: auto; min-height: 38px; font-size: .82rem; }
112 +
.btn[disabled] { opacity: .45; cursor: default; }
113 +
114 +
/* ---------- Einstellungen ---------- */
115 +
.settings { display: flex; flex-wrap: wrap; align-items: center; gap: .9rem; margin-top: 1rem;
116 +
  padding-top: 1rem; border-top: 1px solid var(--line); }
117 +
.field { display: flex; flex-direction: column; gap: .3rem; font-size: .82rem; color: var(--fg-dim); }
118 +
.input-unit { display: flex; align-items: center; gap: .4rem; }
119 +
.input-unit span { font-size: .8rem; }
120 +
input[type="number"] {
121 +
  width: 5.5rem; min-height: 40px; padding: .35rem .6rem;
122 +
  border: 1px solid var(--line); border-radius: 9px;
123 +
  background: var(--bg-soft); color: var(--fg); font-size: .95rem;
124 +
}
125 +
.switch { display: flex; align-items: center; gap: .5rem; font-size: .82rem; color: var(--fg-dim); cursor: pointer; }
126 +
.switch input { width: 20px; height: 20px; accent-color: var(--accent); }
127 +
128 +
/* ---------- Playliste ---------- */
129 +
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
130 +
.item {
131 +
  display: grid;
132 +
  grid-template-columns: auto 84px 1fr auto;
133 +
  align-items: center; gap: .7rem;
134 +
  padding: .5rem; border: 1px solid var(--line); border-radius: 12px;
135 +
  background: var(--bg-soft);
136 +
}
137 +
.item.playing { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(82,182,255,.25); }
138 +
.item.dragging { opacity: .4; }
139 +
.item.drop-target { border-color: var(--accent); border-style: dashed; }
140 +
.grip { cursor: grab; padding: .4rem .2rem; color: var(--fg-dim); font-size: 1.1rem; line-height: 1; touch-action: none; user-select: none; }
141 +
.grip:active { cursor: grabbing; }
142 +
.thumb {
143 +
  width: 84px; height: 56px; border-radius: 8px; object-fit: cover;
144 +
  background: #0b1220; display: block;
145 +
}
146 +
.meta { min-width: 0; }
147 +
.name { font-size: .88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
148 +
.tags { display: flex; gap: .4rem; align-items: center; margin-top: .2rem; font-size: .72rem; color: var(--fg-dim); }
149 +
.badge { padding: .1rem .4rem; border-radius: 5px; background: #223247; color: #9dc7ee; font-size: .68rem; letter-spacing: .03em; }
150 +
.badge.video { background: #33244a; color: #c6a8f0; }
151 +
.actions { display: flex; gap: .25rem; }
152 +
.icon-btn {
153 +
  width: 38px; height: 38px; display: grid; place-items: center;
154 +
  border: 1px solid transparent; border-radius: 9px;
155 +
  background: transparent; color: var(--fg-dim);
156 +
  font-size: 1rem; cursor: pointer;
157 +
}
158 +
.icon-btn:hover { background: #22303f; color: var(--fg); }
159 +
.icon-btn.del:hover { color: var(--danger); }
160 +
.empty { color: var(--fg-dim); font-size: .85rem; text-align: center; padding: 1.2rem 0; }
161 +
162 +
.foot { text-align: center; font-size: .74rem; color: var(--fg-dim); opacity: .8; }
163 +
164 +
/* ---------- Meldungen ---------- */
165 +
.toasts {
166 +
  position: fixed; left: 50%; transform: translateX(-50%);
167 +
  bottom: max(1rem, env(safe-area-inset-bottom)); z-index: 50;
168 +
  display: grid; gap: .4rem; width: min(90vw, 460px);
169 +
}
170 +
.toast {
171 +
  padding: .6rem .9rem; border-radius: 10px;
172 +
  background: #223142; border: 1px solid var(--line);
173 +
  color: var(--fg); font-size: .85rem;
174 +
  box-shadow: 0 8px 24px rgba(0,0,0,.45);
175 +
  animation: pop .2s ease-out;
176 +
}
177 +
.toast.err { background: #3a2028; border-color: #6b2b36; }
178 +
.toast.ok { background: #1c3527; border-color: #2f6b47; }
179 +
@keyframes pop { from { opacity: 0; transform: translateY(8px); } }
180 +
181 +
@media (max-width: 420px) {
182 +
  .item { grid-template-columns: auto 64px 1fr auto; }
183 +
  .thumb { width: 64px; height: 44px; }
184 +
}
hinzugefügt internal/web/disk_linux.go
+14 −0 Datei ansehen
@@ -0,0 +1,14 @@
1 +
//go:build linux
2 +
3 +
package web
4 +
5 +
import "syscall"
6 +
7 +
// diskUsage liefert freien und gesamten Speicherplatz des Datentraegers.
8 +
func diskUsage(path string) (free, total int64) {
9 +
	var st syscall.Statfs_t
10 +
	if err := syscall.Statfs(path, &st); err != nil {
11 +
		return 0, 0
12 +
	}
13 +
	return int64(st.Bavail) * int64(st.Bsize), int64(st.Blocks) * int64(st.Bsize)
14 +
}
hinzugefügt internal/web/disk_other.go
+7 −0 Datei ansehen
@@ -0,0 +1,7 @@
1 +
//go:build !linux
2 +
3 +
package web
4 +
5 +
// diskUsage ist nur unter Linux implementiert - auf anderen Systemen wird die
6 +
// Anzeige im Webinterface einfach ausgeblendet.
7 +
func diskUsage(string) (free, total int64) { return 0, 0 }
hinzugefügt internal/web/thumb.go
+79 −0 Datei ansehen
@@ -0,0 +1,79 @@
1 +
package web
2 +
3 +
import (
4 +
	"bytes"
5 +
	"context"
6 +
	"fmt"
7 +
	"os"
8 +
	"os/exec"
9 +
	"path/filepath"
10 +
	"time"
11 +
)
12 +
13 +
type diskInfo struct {
14 +
	Free  int64 `json:"free"`
15 +
	Total int64 `json:"total"`
16 +
}
17 +
18 +
// makeThumb erzeugt mit ffmpeg ein Vorschaubild. Bei Videos wird ein Einzelbild
19 +
// aus der ersten Sekunde genommen.
20 +
func makeThumb(src, dst, kind string) error {
21 +
	if err := os.MkdirAll(filepath.Dir(dst), 0o775); err != nil {
22 +
		return err
23 +
	}
24 +
	tmp := dst + ".tmp.jpg"
25 +
	defer os.Remove(tmp)
26 +
27 +
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
28 +
	defer cancel()
29 +
30 +
	seek := "1"
31 +
	if kind != "video" {
32 +
		seek = ""
33 +
	}
34 +
	err := runFFmpeg(ctx, src, tmp, seek)
35 +
	if err != nil && seek != "" {
36 +
		// Sehr kurze Videos haben nach einer Sekunde nichts mehr: von vorne.
37 +
		err = runFFmpeg(ctx, src, tmp, "")
38 +
	}
39 +
	if err != nil {
40 +
		return err
41 +
	}
42 +
	return os.Rename(tmp, dst)
43 +
}
44 +
45 +
// convertToJPEG wandelt ein Standbild (etwa HEIC vom iPhone) in JPEG um.
46 +
func convertToJPEG(src, dst string) error {
47 +
	ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
48 +
	defer cancel()
49 +
	out, err := exec.CommandContext(ctx, "ffmpeg",
50 +
		"-nostdin", "-loglevel", "error", "-y",
51 +
		"-i", src,
52 +
		"-frames:v", "1",
53 +
		"-q:v", "2",
54 +
		"-f", "image2", dst,
55 +
	).CombinedOutput()
56 +
	if err != nil {
57 +
		return fmt.Errorf("ffmpeg: %v: %s", err, bytes.TrimSpace(out))
58 +
	}
59 +
	return nil
60 +
}
61 +
62 +
func runFFmpeg(ctx context.Context, src, dst, seek string) error {
63 +
	args := []string{"-nostdin", "-loglevel", "error", "-y"}
64 +
	if seek != "" {
65 +
		args = append(args, "-ss", seek)
66 +
	}
67 +
	args = append(args,
68 +
		"-i", src,
69 +
		"-frames:v", "1",
70 +
		"-vf", "scale=480:-2:force_original_aspect_ratio=decrease",
71 +
		"-q:v", "5",
72 +
		dst,
73 +
	)
74 +
	out, err := exec.CommandContext(ctx, "ffmpeg", args...).CombinedOutput()
75 +
	if err != nil {
76 +
		return fmt.Errorf("ffmpeg: %v: %s", err, bytes.TrimSpace(out))
77 +
	}
78 +
	return nil
79 +
}
hinzugefügt internal/web/web.go
+474 −0 Datei ansehen
@@ -0,0 +1,474 @@
1 +
// Package web stellt das Upload- und Sortier-Interface bereit.
2 +
package web
3 +
4 +
import (
5 +
	"crypto/sha1"
6 +
	"crypto/subtle"
7 +
	"encoding/hex"
8 +
	"encoding/json"
9 +
	"errors"
10 +
	"fmt"
11 +
	"io"
12 +
	"log"
13 +
	"mime/multipart"
14 +
	"net/http"
15 +
	"os"
16 +
	"path/filepath"
17 +
	"strings"
18 +
	"sync"
19 +
	"time"
20 +
21 +
	"ehc/tvwall/internal/config"
22 +
	"ehc/tvwall/internal/player"
23 +
	"ehc/tvwall/internal/playlist"
24 +
)
25 +
26 +
// Server buendelt alles, was die Handler brauchen.
27 +
type Server struct {
28 +
	cfg      *config.Config
29 +
	pl       *playlist.Manager
30 +
	player   *player.Player
31 +
	settings *config.Store
32 +
33 +
	thumbMu sync.Mutex
34 +
	hasFF   bool
35 +
}
36 +
37 +
// New erzeugt den Webserver.
38 +
func New(cfg *config.Config, pl *playlist.Manager, pp *player.Player, settings *config.Store) *Server {
39 +
	s := &Server{cfg: cfg, pl: pl, player: pp, settings: settings}
40 +
	if _, err := lookPath("ffmpeg"); err == nil {
41 +
		s.hasFF = true
42 +
	} else {
43 +
		log.Printf("web: ffmpeg nicht gefunden - Vorschaubilder fuer Videos sind deaktiviert")
44 +
	}
45 +
	return s
46 +
}
47 +
48 +
// Handler liefert den fertig verdrahteten HTTP-Handler.
49 +
func (s *Server) Handler() http.Handler {
50 +
	mux := http.NewServeMux()
51 +
52 +
	mux.HandleFunc("/", s.handleIndex)
53 +
	mux.Handle("/static/", http.StripPrefix("/static/", staticHandler()))
54 +
	mux.HandleFunc("/api/state", s.handleState)
55 +
	mux.HandleFunc("/api/upload", s.handleUpload)
56 +
	mux.HandleFunc("/api/order", s.handleOrder)
57 +
	mux.HandleFunc("/api/delete", s.handleDelete)
58 +
	mux.HandleFunc("/api/settings", s.handleSettings)
59 +
	mux.HandleFunc("/api/control", s.handleControl)
60 +
	mux.HandleFunc("/thumb/", s.handleThumb)
61 +
	mux.HandleFunc("/media/", s.handleMedia)
62 +
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
63 +
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
64 +
		io.WriteString(w, "ok\n")
65 +
	})
66 +
67 +
	return s.withAuth(mux)
68 +
}
69 +
70 +
// withAuth schuetzt alles per HTTP-Basic-Auth, sofern ein Passwort gesetzt ist.
71 +
func (s *Server) withAuth(next http.Handler) http.Handler {
72 +
	if s.cfg.WebPassword == "" {
73 +
		return next
74 +
	}
75 +
	want := []byte(s.cfg.WebPassword)
76 +
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77 +
		if r.URL.Path == "/healthz" {
78 +
			next.ServeHTTP(w, r)
79 +
			return
80 +
		}
81 +
		_, pass, ok := r.BasicAuth()
82 +
		if !ok || subtle.ConstantTimeCompare([]byte(pass), want) != 1 {
83 +
			w.Header().Set("WWW-Authenticate", `Basic realm="TV-Wall"`)
84 +
			http.Error(w, "Anmeldung erforderlich", http.StatusUnauthorized)
85 +
			return
86 +
		}
87 +
		next.ServeHTTP(w, r)
88 +
	})
89 +
}
90 +
91 +
// ---------------------------------------------------------------------------
92 +
// Handler
93 +
// ---------------------------------------------------------------------------
94 +
95 +
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
96 +
	if r.URL.Path != "/" {
97 +
		http.NotFound(w, r)
98 +
		return
99 +
	}
100 +
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
101 +
	w.Header().Set("Cache-Control", "no-store")
102 +
	writeAsset(w, "index.html")
103 +
}
104 +
105 +
type stateResponse struct {
106 +
	Entries  []playlist.Entry `json:"entries"`
107 +
	Version  uint64           `json:"version"`
108 +
	Status   player.Status    `json:"status"`
109 +
	Settings config.Settings  `json:"settings"`
110 +
	Device   deviceInfo       `json:"device"`
111 +
	Disk     diskInfo         `json:"disk"`
112 +
}
113 +
114 +
type deviceInfo struct {
115 +
	SSID      string `json:"ssid"`
116 +
	Hostname  string `json:"hostname"`
117 +
	Images    int    `json:"images"`
118 +
	Videos    int    `json:"videos"`
119 +
	Thumbs    bool   `json:"thumbs"`
120 +
	MaxUpload int64  `json:"max_upload_mb"`
121 +
}
122 +
123 +
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
124 +
	entries := s.pl.Entries()
125 +
	var images, videos int
126 +
	for _, e := range entries {
127 +
		if e.Kind == "video" {
128 +
			videos++
129 +
		} else {
130 +
			images++
131 +
		}
132 +
	}
133 +
	if entries == nil {
134 +
		entries = []playlist.Entry{}
135 +
	}
136 +
	free, total := diskUsage(s.cfg.MediaDir())
137 +
138 +
	writeJSON(w, http.StatusOK, stateResponse{
139 +
		Entries:  entries,
140 +
		Version:  s.pl.Version(),
141 +
		Status:   s.player.Status(),
142 +
		Settings: s.settings.Get(),
143 +
		Device: deviceInfo{
144 +
			SSID:      s.cfg.APSSID,
145 +
			Hostname:  s.cfg.Hostname,
146 +
			Images:    images,
147 +
			Videos:    videos,
148 +
			Thumbs:    s.hasFF,
149 +
			MaxUpload: s.cfg.MaxUploadMB,
150 +
		},
151 +
		Disk: diskInfo{Free: free, Total: total},
152 +
	})
153 +
}
154 +
155 +
type uploadResult struct {
156 +
	Uploaded []string `json:"uploaded"`
157 +
	Skipped  []string `json:"skipped"`
158 +
}
159 +
160 +
func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
161 +
	if r.Method != http.MethodPost {
162 +
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
163 +
		return
164 +
	}
165 +
	maxBytes := s.cfg.MaxUploadMB << 20
166 +
	r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
167 +
168 +
	mr, err := r.MultipartReader()
169 +
	if err != nil {
170 +
		httpError(w, http.StatusBadRequest, "kein gueltiger Upload: "+err.Error())
171 +
		return
172 +
	}
173 +
174 +
	res := uploadResult{Uploaded: []string{}, Skipped: []string{}}
175 +
	for {
176 +
		part, err := mr.NextPart()
177 +
		if errors.Is(err, io.EOF) {
178 +
			break
179 +
		}
180 +
		if err != nil {
181 +
			httpError(w, http.StatusBadRequest, "Upload abgebrochen: "+err.Error())
182 +
			return
183 +
		}
184 +
		name := playlist.CleanUploadName(part.FileName())
185 +
		if name == "" {
186 +
			part.Close()
187 +
			continue
188 +
		}
189 +
		if !playlist.UploadAllowed(name) {
190 +
			res.Skipped = append(res.Skipped, name+" (Dateityp wird nicht unterstuetzt)")
191 +
			part.Close()
192 +
			continue
193 +
		}
194 +
		if playlist.Convertible(name) && !s.hasFF {
195 +
			res.Skipped = append(res.Skipped, name+" (HEIC braucht ffmpeg auf dem Pi)")
196 +
			part.Close()
197 +
			continue
198 +
		}
199 +
		saved, err := s.saveUpload(part, name)
200 +
		part.Close()
201 +
		if err != nil {
202 +
			log.Printf("web: upload %q: %v", name, err)
203 +
			res.Skipped = append(res.Skipped, name+" ("+err.Error()+")")
204 +
			continue
205 +
		}
206 +
		res.Uploaded = append(res.Uploaded, saved)
207 +
	}
208 +
209 +
	if len(res.Uploaded) > 0 {
210 +
		if _, err := s.pl.Scan(); err != nil {
211 +
			log.Printf("web: scan nach upload: %v", err)
212 +
		}
213 +
	}
214 +
	writeJSON(w, http.StatusOK, res)
215 +
}
216 +
217 +
// saveUpload schreibt den Datenstrom in eine temporaere Datei und benennt sie
218 +
// erst nach vollstaendigem Empfang um - so landen keine halben Dateien in der
219 +
// Playliste.
220 +
func (s *Server) saveUpload(part *multipart.Part, name string) (string, error) {
221 +
	dir := s.pl.MediaDir()
222 +
	if err := os.MkdirAll(dir, 0o775); err != nil {
223 +
		return "", err
224 +
	}
225 +
	tmp, err := os.CreateTemp(dir, ".upload-*")
226 +
	if err != nil {
227 +
		return "", err
228 +
	}
229 +
	tmpName := tmp.Name()
230 +
	defer os.Remove(tmpName)
231 +
232 +
	if _, err := io.Copy(tmp, part); err != nil {
233 +
		tmp.Close()
234 +
		return "", err
235 +
	}
236 +
	if err := tmp.Close(); err != nil {
237 +
		return "", err
238 +
	}
239 +
	// iPhone-Fotos kommen als HEIC - daraus wird ein JPEG, das mpv anzeigen kann.
240 +
	if playlist.Convertible(name) {
241 +
		converted := tmpName + ".jpg"
242 +
		if err := convertToJPEG(tmpName, converted); err != nil {
243 +
			return "", fmt.Errorf("HEIC umwandeln: %w", err)
244 +
		}
245 +
		defer os.Remove(converted)
246 +
		tmpName = converted
247 +
		name = strings.TrimSuffix(name, filepath.Ext(name)) + ".jpg"
248 +
	}
249 +
250 +
	if err := os.Chmod(tmpName, 0o664); err != nil {
251 +
		return "", err
252 +
	}
253 +
254 +
	final := playlist.UniqueName(dir, name)
255 +
	if err := os.Rename(tmpName, filepath.Join(dir, final)); err != nil {
256 +
		return "", err
257 +
	}
258 +
	return final, nil
259 +
}
260 +
261 +
func (s *Server) handleOrder(w http.ResponseWriter, r *http.Request) {
262 +
	if r.Method != http.MethodPost {
263 +
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
264 +
		return
265 +
	}
266 +
	var req struct {
267 +
		Order []string `json:"order"`
268 +
	}
269 +
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
270 +
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
271 +
		return
272 +
	}
273 +
	if err := s.pl.SetOrder(req.Order); err != nil {
274 +
		httpError(w, http.StatusInternalServerError, err.Error())
275 +
		return
276 +
	}
277 +
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
278 +
}
279 +
280 +
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
281 +
	if r.Method != http.MethodPost {
282 +
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
283 +
		return
284 +
	}
285 +
	var req struct {
286 +
		Name string `json:"name"`
287 +
	}
288 +
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
289 +
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
290 +
		return
291 +
	}
292 +
	if err := s.pl.Delete(req.Name); err != nil {
293 +
		httpError(w, http.StatusBadRequest, err.Error())
294 +
		return
295 +
	}
296 +
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
297 +
}
298 +
299 +
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
300 +
	if r.Method != http.MethodPost {
301 +
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
302 +
		return
303 +
	}
304 +
	cur := s.settings.Get()
305 +
	var req struct {
306 +
		ImageDuration *float64 `json:"image_duration"`
307 +
		Mute          *bool    `json:"mute"`
308 +
	}
309 +
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
310 +
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
311 +
		return
312 +
	}
313 +
	if req.ImageDuration != nil {
314 +
		cur.ImageDuration = *req.ImageDuration
315 +
	}
316 +
	if req.Mute != nil {
317 +
		cur.Mute = *req.Mute
318 +
	}
319 +
	saved, err := s.settings.Set(cur)
320 +
	if err != nil {
321 +
		httpError(w, http.StatusInternalServerError, err.Error())
322 +
		return
323 +
	}
324 +
	writeJSON(w, http.StatusOK, saved)
325 +
}
326 +
327 +
func (s *Server) handleControl(w http.ResponseWriter, r *http.Request) {
328 +
	if r.Method != http.MethodPost {
329 +
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
330 +
		return
331 +
	}
332 +
	var req struct {
333 +
		Action string `json:"action"`
334 +
	}
335 +
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
336 +
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
337 +
		return
338 +
	}
339 +
340 +
	var err error
341 +
	switch req.Action {
342 +
	case "next":
343 +
		err = s.player.Next()
344 +
	case "prev":
345 +
		err = s.player.Prev()
346 +
	case "pause":
347 +
		err = s.player.TogglePause()
348 +
	case "reload":
349 +
		err = s.player.ReloadNow()
350 +
	case "restart":
351 +
		err = s.player.Restart()
352 +
	case "rescan":
353 +
		_, err = s.pl.Scan()
354 +
	default:
355 +
		httpError(w, http.StatusBadRequest, "unbekannte Aktion")
356 +
		return
357 +
	}
358 +
	if err != nil {
359 +
		httpError(w, http.StatusConflict, err.Error())
360 +
		return
361 +
	}
362 +
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
363 +
}
364 +
365 +
func (s *Server) handleMedia(w http.ResponseWriter, r *http.Request) {
366 +
	name, err := playlist.SafeName(strings.TrimPrefix(r.URL.Path, "/media/"))
367 +
	if err != nil {
368 +
		http.NotFound(w, r)
369 +
		return
370 +
	}
371 +
	path := filepath.Join(s.pl.MediaDir(), name)
372 +
	if !playlist.Supported(name) {
373 +
		http.NotFound(w, r)
374 +
		return
375 +
	}
376 +
	http.ServeFile(w, r, path)
377 +
}
378 +
379 +
// handleThumb liefert ein Vorschaubild und legt es dabei im Cache ab.
380 +
func (s *Server) handleThumb(w http.ResponseWriter, r *http.Request) {
381 +
	name, err := playlist.SafeName(strings.TrimPrefix(r.URL.Path, "/thumb/"))
382 +
	if err != nil {
383 +
		http.NotFound(w, r)
384 +
		return
385 +
	}
386 +
	kind := playlist.Kind(name)
387 +
	if kind == "" {
388 +
		http.NotFound(w, r)
389 +
		return
390 +
	}
391 +
	src := filepath.Join(s.pl.MediaDir(), name)
392 +
	info, err := os.Stat(src)
393 +
	if err != nil {
394 +
		http.NotFound(w, r)
395 +
		return
396 +
	}
397 +
398 +
	if !s.hasFF {
399 +
		if kind == "image" {
400 +
			http.ServeFile(w, r, src)
401 +
			return
402 +
		}
403 +
		s.servePlaceholder(w)
404 +
		return
405 +
	}
406 +
407 +
	sum := sha1.Sum([]byte(fmt.Sprintf("%s|%d|%d", name, info.ModTime().UnixNano(), info.Size())))
408 +
	thumb := filepath.Join(s.cfg.ThumbDir(), hex.EncodeToString(sum[:])+".jpg")
409 +
410 +
	if _, err := os.Stat(thumb); err != nil {
411 +
		s.thumbMu.Lock()
412 +
		_, statErr := os.Stat(thumb)
413 +
		if statErr != nil {
414 +
			err = makeThumb(src, thumb, kind)
415 +
		} else {
416 +
			err = nil
417 +
		}
418 +
		s.thumbMu.Unlock()
419 +
		if err != nil {
420 +
			log.Printf("web: vorschaubild %q: %v", name, err)
421 +
			if kind == "image" {
422 +
				http.ServeFile(w, r, src)
423 +
			} else {
424 +
				s.servePlaceholder(w)
425 +
			}
426 +
			return
427 +
		}
428 +
	}
429 +
430 +
	w.Header().Set("Cache-Control", "public, max-age=86400")
431 +
	http.ServeFile(w, r, thumb)
432 +
}
433 +
434 +
func (s *Server) servePlaceholder(w http.ResponseWriter) {
435 +
	w.Header().Set("Content-Type", "image/svg+xml")
436 +
	w.Header().Set("Cache-Control", "public, max-age=86400")
437 +
	io.WriteString(w, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3">`+
438 +
		`<rect width="4" height="3" fill="#243040"/>`+
439 +
		`<path d="M1.6 1 2.8 1.5 1.6 2z" fill="#6f8cad"/></svg>`)
440 +
}
441 +
442 +
// ---------------------------------------------------------------------------
443 +
// Server starten
444 +
// ---------------------------------------------------------------------------
445 +
446 +
// ListenAndServe startet den HTTP-Server auf dem konfigurierten Port.
447 +
func (s *Server) ListenAndServe(addr string) error {
448 +
	srv := &http.Server{
449 +
		Addr:    addr,
450 +
		Handler: s.Handler(),
451 +
		// Kein ReadTimeout: grosse Videouploads duerfen lange dauern.
452 +
		ReadHeaderTimeout: 30 * time.Second,
453 +
		IdleTimeout:       120 * time.Second,
454 +
	}
455 +
	log.Printf("web: hoere auf %s", addr)
456 +
	return srv.ListenAndServe()
457 +
}
458 +
459 +
// ---------------------------------------------------------------------------
460 +
// Hilfsfunktionen
461 +
// ---------------------------------------------------------------------------
462 +
463 +
func writeJSON(w http.ResponseWriter, code int, v any) {
464 +
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
465 +
	w.Header().Set("Cache-Control", "no-store")
466 +
	w.WriteHeader(code)
467 +
	if err := json.NewEncoder(w).Encode(v); err != nil {
468 +
		log.Printf("web: antwort schreiben: %v", err)
469 +
	}
470 +
}
471 +
472 +
func httpError(w http.ResponseWriter, code int, msg string) {
473 +
	writeJSON(w, code, map[string]string{"error": msg})
474 +
}
hinzugefügt scripts/bisect-mpv.sh
+187 −0 Datei ansehen
@@ -0,0 +1,187 @@
1 +
#!/usr/bin/env bash
2 +
# ===========================================================================
3 +
#  Findet heraus, WELCHE mpv-Option (oder welcher Ablauf) einen Haenger
4 +
#  ausloest. Statt zu raten wird jede Variante gemessen.
5 +
#
6 +
#  Aufruf:  sudo /opt/tvwall/scripts/bisect-mpv.sh /var/lib/tvwall/media/<video>
7 +
#
8 +
#  Der Bildschirm flackert waehrenddessen - das ist normal, jede Variante
9 +
#  spielt acht Sekunden. Der Dienst wird angehalten und danach neu gestartet.
10 +
# ===========================================================================
11 +
set -uo pipefail
12 +
13 +
CONF="/etc/tvwall/tvwall.conf"
14 +
LIMIT=25          # Sekunden, nach denen eine Variante als "haengt" gilt
15 +
PLAYSEC=8         # so lange soll jede Variante spielen
16 +
17 +
hdr()  { printf '\n\033[1;36m── %s ──────────────────────────\033[0m\n' "$*"; }
18 +
ok()   { printf '  \033[1;32mLAEUFT\033[0m  %-46s %ss\n' "$1" "$2"; }
19 +
bad()  { printf '  \033[1;31mHAENGT\033[0m  %-46s Abbruch nach %ss\n' "$1" "$2"; }
20 +
odd()  { printf '  \033[1;33mFEHLER\033[0m  %-46s Code %s\n' "$1" "$2"; }
21 +
note() { printf '    %s\n' "$*"; }
22 +
die()  { printf '\033[1;31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
23 +
24 +
[[ $EUID -eq 0 ]] || die "bitte mit sudo starten"
25 +
26 +
VIDEO="${1:-}"
27 +
[[ -n "$VIDEO" && -f "$VIDEO" ]] || die "Bitte eine Videodatei angeben:
28 +
       sudo $0 /var/lib/tvwall/media/<datei>.mp4"
29 +
30 +
RUN_USER="tvwall"; STATE_DIR="/var/lib/tvwall"; DISPLAY_MODE="auto"
31 +
[[ -f "$CONF" ]] && { set -a; . "$CONF" 2>/dev/null; set +a; }
32 +
RUN_USER="${RUN_USER:-tvwall}"; STATE_DIR="${STATE_DIR:-/var/lib/tvwall}"
33 +
RUN_UID="$(id -u "$RUN_USER" 2>/dev/null)" || die "Benutzer $RUN_USER gibt es nicht"
34 +
35 +
# --- Umgebung wie beim Dienst ---------------------------------------------
36 +
ENV_ARGS=(XDG_RUNTIME_DIR="/run/user/$RUN_UID")
37 +
VO_ARGS=(--vo=gpu)
38 +
if ! ls "/run/user/$RUN_UID"/wayland-* >/dev/null 2>&1 && [[ -z "${DISPLAY:-}" ]]; then
39 +
  VO_ARGS=(--vo=gpu --gpu-context=drm)      # kein Desktop -> direkt auf den Schirm
40 +
fi
41 +
42 +
# Die Optionen, die der Dienst setzt (ohne --idle/--loop, sonst endet nichts).
43 +
SERVICE_ARGS=(
44 +
  --fullscreen --force-window=yes --keep-open=no
45 +
  --image-display-duration=2
46 +
  --no-osc --osd-level=0 --cursor-autohide=always
47 +
  --no-input-default-bindings --input-vo-keyboard=no
48 +
  --stop-screensaver=yes --no-input-terminal
49 +
  "${VO_ARGS[@]}"
50 +
)
51 +
52 +
# --- Testmaterial ----------------------------------------------------------
53 +
IMAGE="$(find "$STATE_DIR/media" -maxdepth 1 -type f \
54 +
         \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) | sort | head -1)"
55 +
TMP="$(mktemp -d)"
56 +
trap 'rm -rf "$TMP"' EXIT
57 +
printf '%s\n' "$VIDEO" > "$TMP/nur-video.m3u"
58 +
if [[ -n "$IMAGE" ]]; then
59 +
  printf '%s\n%s\n' "$IMAGE" "$VIDEO" > "$TMP/bild-video.m3u"
60 +
fi
61 +
chmod -R a+rX "$TMP"
62 +
63 +
LOGDIR="$TMP/logs"; mkdir -p "$LOGDIR"
64 +
FAILED=()
65 +
declare -A RESULT
66 +
67 +
# Ein Durchlauf. LEN=0 laesst die Datei komplett laufen (ohne --length).
68 +
attempt() {
69 +
  local log="$1" limit="$2" len="$3"; shift 3
70 +
  local extra=()
71 +
  [[ "$len" != "0" ]] && extra=(--length="$len")
72 +
  sudo -u "$RUN_USER" env "${ENV_ARGS[@]}" \
73 +
    timeout -k 3 "$limit" mpv --no-config --msg-level=all=error "${extra[@]}" "$@" \
74 +
    >"$log" 2>&1
75 +
}
76 +
77 +
run_as() {
78 +
  local id="$1" label="$2" limit="$3" len="$4"; shift 4
79 +
  local log="$LOGDIR/$id.log"
80 +
  local start rc dur
81 +
  start=$(date +%s)
82 +
  attempt "$log" "$limit" "$len" "$@"
83 +
  rc=$?
84 +
  dur=$(( $(date +%s) - start ))
85 +
86 +
  # Fehlschlaege einmal wiederholen: der Compositor verweigert zwischen zwei
87 +
  # Laeufen gelegentlich kurz die Verbindung - das ist kein echter Befund.
88 +
  if [[ $rc -ne 0 ]]; then
89 +
    sleep 3
90 +
    attempt "$log" "$limit" "$len" "$@"
91 +
    local rc2=$?
92 +
    if [[ $rc2 -eq 0 ]]; then
93 +
      RESULT[$id]=ok
94 +
      printf '  \033[1;32mLAEUFT\033[0m  %-46s %ss (erster Versuch einmalig gescheitert)\n' "$id  $label" "$dur"
95 +
      return
96 +
    fi
97 +
    rc=$rc2
98 +
  fi
99 +
100 +
  case $rc in
101 +
    0)       RESULT[$id]=ok;   ok  "$id  $label" "$dur" ;;
102 +
    124|137) RESULT[$id]=hang; bad "$id  $label" "$dur"; FAILED+=("$id  $label|$log") ;;
103 +
    *)       RESULT[$id]=err;  odd "$id  $label" "$rc";  FAILED+=("$id  $label|$log") ;;
104 +
  esac
105 +
  sleep 2
106 +
}
107 +
108 +
run() { run_as "$1" "$2" "$LIMIT" "$PLAYSEC" "${@:3}"; }
109 +
110 +
hdr "Vorbereitung"
111 +
note "Datei:    $(basename "$VIDEO")"
112 +
note "Benutzer: $RUN_USER (uid $RUN_UID)"
113 +
note "Ausgabe:  ${VO_ARGS[*]}"
114 +
[[ -n "$IMAGE" ]] && note "Bild:     $(basename "$IMAGE")" || note "Bild:     keines gefunden - Uebergangstest entfaellt"
115 +
systemctl stop tvwall 2>/dev/null && note "Dienst angehalten"
116 +
sleep 1
117 +
118 +
hdr "Ist die Datei ueberhaupt vollstaendig?"
119 +
if command -v ffmpeg >/dev/null; then
120 +
  FFOUT="$(ffmpeg -v error -i "$VIDEO" -f null - 2>&1)"
121 +
  if [[ -z "$FFOUT" ]]; then
122 +
    printf '  \033[1;32mIN ORDNUNG\033[0m  komplett dekodierbar\n'
123 +
  else
124 +
    printf '  \033[1;31mBESCHAEDIGT\033[0m  ffmpeg meldet beim Durchlesen:\n'
125 +
    echo "$FFOUT" | head -6 | sed 's/^/      /'
126 +
    note "-> Dann hilft kein Einstellen: die Datei bitte neu hochladen."
127 +
  fi
128 +
  note "Groesse: $(stat -c %s "$VIDEO" 2>/dev/null) Bytes, md5: $(md5sum "$VIDEO" | cut -d" " -f1)"
129 +
fi
130 +
131 +
hdr "Einzeldatei"
132 +
run 1 "nur --fullscreen (mpv-Standard)"          --fullscreen "$VIDEO"
133 +
run 2 "Dienst-Optionen"                          "${SERVICE_ARGS[@]}" "$VIDEO"
134 +
run 3 "Dienst-Optionen + --hwdec=auto-safe"      "${SERVICE_ARGS[@]}" --hwdec=auto-safe "$VIDEO"
135 +
run 4 "Dienst-Optionen + --no-audio"             "${SERVICE_ARGS[@]}" --no-audio "$VIDEO"
136 +
run 5 "Dienst-Optionen + --vo=gpu-next"          "${SERVICE_ARGS[@]/--vo=gpu/--vo=gpu-next}" "$VIDEO"
137 +
138 +
hdr "Als Playliste (so macht es der Dienst)"
139 +
run 6 "Playliste mit nur dem Video"              "${SERVICE_ARGS[@]}" --playlist="$TMP/nur-video.m3u"
140 +
if [[ -n "$IMAGE" ]]; then
141 +
  run 7 "Playliste Bild -> Video"                "${SERVICE_ARGS[@]}" --playlist="$TMP/bild-video.m3u"
142 +
  run 8 "Bild -> Video, ohne --force-window"     "${SERVICE_ARGS[@]/--force-window=yes/--force-window=no}" \
143 +
                                                 --playlist="$TMP/bild-video.m3u"
144 +
  run 9 "Bild -> Video, mit --gpu-context=auto"  "${SERVICE_ARGS[@]}" --gpu-context=auto \
145 +
                                                 --playlist="$TMP/bild-video.m3u"
146 +
  # Ohne --length, damit ausgeschlossen ist, dass diese Testoption stoert.
147 +
  run_as 10 "Bild -> Video, komplett bis zum Ende" 120 0 \
148 +
            "${SERVICE_ARGS[@]}" --playlist="$TMP/bild-video.m3u"
149 +
fi
150 +
151 +
hdr "Ergebnis"
152 +
r() { echo "${RESULT[$1]:-}"; }
153 +
154 +
if [[ ${#FAILED[@]} -gt 0 ]]; then
155 +
  note "Haengen geblieben:"
156 +
  for entry in "${FAILED[@]}"; do
157 +
    printf '      %s\n' "${entry%%|*}"
158 +
    tail -5 "${entry##*|}" 2>/dev/null | sed 's/^/          /'
159 +
  done
160 +
  echo
161 +
fi
162 +
163 +
note "Deutung:"
164 +
if [[ "$(r 1)" != "ok" ]]; then
165 +
  note "  Schon der nackte mpv-Aufruf haengt. Dann liegt es an der Datei oder"
166 +
  note "  am Grafiktreiber, nicht an den Einstellungen dieses Werkzeugs."
167 +
elif [[ "$(r 2)" != "ok" ]]; then
168 +
  note "  Der nackte Aufruf laeuft, die Dienst-Optionen nicht. Die Varianten 3-5"
169 +
  note "  zeigen, welche Option es ist."
170 +
elif [[ -n "$IMAGE" && "$(r 7)" != "ok" && "$(r 6)" == "ok" ]]; then
171 +
  note "  Die Einzeldatei laeuft, der Wechsel Bild -> Video nicht."
172 +
  if [[ "$(r 10)" == "ok" ]]; then
173 +
    note "  Variante 10 (ohne --length) laeuft aber durch - dann stoert nur die"
174 +
    note "  Testoption --length, und der Dienst selbst ist nicht betroffen."
175 +
  else
176 +
    note "  Auch ohne Testoptionen (Variante 10) scheitert es - echter Befund."
177 +
    note "  Meldet ffmpeg oben \"BESCHAEDIGT\", ist die Datei die Ursache."
178 +
  fi
179 +
  [[ "$(r 8)" == "ok" ]] && note "  --force-window=no behebt es -> genau das laesst sich einstellen."
180 +
  [[ "$(r 9)" == "ok" ]] && note "  --gpu-context=auto behebt es -> genau das laesst sich einstellen."
181 +
elif [[ ${#FAILED[@]} -eq 0 ]]; then
182 +
  note "  Keine Variante haengt. Dann liegt es nicht an mpv, sondern am"
183 +
  note "  Zusammenspiel mit dem Dienst - bitte 'journalctl -u tvwall -n 60' schicken."
184 +
fi
185 +
186 +
systemctl start tvwall 2>/dev/null
187 +
hdr "Dienst wieder gestartet"
hinzugefügt scripts/diagnose.sh
+181 −0 Datei ansehen
@@ -0,0 +1,181 @@
1 +
#!/usr/bin/env bash
2 +
# ===========================================================================
3 +
#  TV-Wall - Selbsttest. Sammelt alles, was man zur Fehlersuche braucht.
4 +
#
5 +
#  Aufruf:  sudo /opt/tvwall/scripts/diagnose.sh
6 +
# ===========================================================================
7 +
set -uo pipefail
8 +
9 +
CONF="${1:-/etc/tvwall/tvwall.conf}"
10 +
11 +
hdr()  { printf '\n\033[1;36m── %s ─────────────────────────────────────\033[0m\n' "$*"; }
12 +
ok()   { printf '\033[1;32m  ✓\033[0m %s\n' "$*"; }
13 +
bad()  { printf '\033[1;31m  ✗\033[0m %s\n' "$*"; }
14 +
warn() { printf '\033[1;33m  !\033[0m %s\n' "$*"; }
15 +
note() { printf '    %s\n' "$*"; }
16 +
17 +
RUN_USER="tvwall"; DISPLAY_MODE="auto"; STATE_DIR="/var/lib/tvwall"; AP_SSID="?"
18 +
if [[ -f "$CONF" ]]; then
19 +
  set -a; . "$CONF" 2>/dev/null; set +a
20 +
fi
21 +
RUN_USER="${RUN_USER:-tvwall}"
22 +
DISPLAY_MODE="${DISPLAY_MODE:-auto}"
23 +
STATE_DIR="${STATE_DIR:-/var/lib/tvwall}"
24 +
25 +
hdr "System"
26 +
note "$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")"
27 +
note "Kernel: $(uname -srm)"
28 +
note "Modell: $(tr -d '\0' < /proc/device-tree/model 2>/dev/null || echo unbekannt)"
29 +
note "Standard-Ziel: $(systemctl get-default 2>/dev/null)"
30 +
31 +
hdr "Konfiguration ($CONF)"
32 +
if [[ -f "$CONF" ]]; then
33 +
  note "DISPLAY_MODE = $DISPLAY_MODE"
34 +
  note "RUN_USER     = $RUN_USER"
35 +
  note "STATE_DIR    = $STATE_DIR"
36 +
  note "AP_SSID      = ${AP_SSID:-?}"
37 +
else
38 +
  bad "Konfigurationsdatei fehlt"
39 +
fi
40 +
41 +
hdr "Programme"
42 +
for p in mpv ffmpeg nmcli; do
43 +
  if command -v "$p" >/dev/null; then ok "$p: $(command -v "$p")"; else bad "$p fehlt"; fi
44 +
done
45 +
if [[ -x /usr/local/bin/tvwall ]]; then
46 +
  ok "tvwall: $(/usr/local/bin/tvwall -version 2>/dev/null || echo vorhanden)"
47 +
else
48 +
  bad "/usr/local/bin/tvwall fehlt"
49 +
fi
50 +
51 +
hdr "Dienst"
52 +
if systemctl is-active --quiet tvwall; then ok "tvwall laeuft"; else bad "tvwall laeuft NICHT"; fi
53 +
if systemctl is-enabled --quiet tvwall 2>/dev/null; then ok "Autostart aktiv"; else warn "Autostart nicht aktiv"; fi
54 +
note "Unit-Benutzer: $(systemctl show -p User --value tvwall 2>/dev/null)"
55 +
note "Neustarts:     $(systemctl show -p NRestarts --value tvwall 2>/dev/null)"
56 +
57 +
hdr "Benutzer $RUN_USER"
58 +
if id "$RUN_USER" >/dev/null 2>&1; then
59 +
  ok "$(id "$RUN_USER")"
60 +
  for g in video render input; do
61 +
    id -nG "$RUN_USER" | tr ' ' '\n' | grep -qx "$g" \
62 +
      && ok "in Gruppe $g" || bad "NICHT in Gruppe $g"
63 +
  done
64 +
else
65 +
  bad "Benutzer existiert nicht"
66 +
fi
67 +
68 +
hdr "Bildschirm"
69 +
COMPOSITOR="$(pgrep -a -x 'labwc|wayfire|Xorg|Xwayland|weston|kwin_wayland' 2>/dev/null | head -3)"
70 +
if [[ -n "$COMPOSITOR" ]]; then
71 +
  warn "Es laeuft eine Desktop-Oberflaeche:"
72 +
  echo "$COMPOSITOR" | sed 's/^/      /'
73 +
  note "-> DISPLAY_MODE muss 'desktop' oder 'auto' sein, NICHT 'drm'."
74 +
else
75 +
  ok "keine Desktop-Oberflaeche aktiv (DRM/Konsole moeglich)"
76 +
fi
77 +
78 +
FOUND_SESSION=0
79 +
for d in /run/user/*; do
80 +
  [[ -d "$d" ]] || continue
81 +
  socks="$(ls "$d"/wayland-* 2>/dev/null | grep -v '\.lock$' | tr '\n' ' ')"
82 +
  [[ -z "$socks" ]] && continue
83 +
  FOUND_SESSION=1
84 +
  u="$(getent passwd "$(basename "$d")" | cut -d: -f1)"
85 +
  ok "Wayland-Sitzung von '$u': $socks"
86 +
  if [[ "$u" != "$RUN_USER" ]]; then
87 +
    bad "Der Dienst laeuft als '$RUN_USER', die Sitzung gehoert aber '$u'."
88 +
    note "-> In $CONF setzen:  RUN_USER=\"$u\"  und  DISPLAY_MODE=\"desktop\""
89 +
    note "-> danach:  sudo /opt/tvwall/install.sh --no-ap"
90 +
  fi
91 +
done
92 +
ls /tmp/.X11-unix/X* >/dev/null 2>&1 && { ok "X11-Sitzung vorhanden: $(ls /tmp/.X11-unix/)"; FOUND_SESSION=1; }
93 +
[[ $FOUND_SESSION -eq 0 ]] && note "keine Desktop-Sitzung gefunden"
94 +
95 +
for dev in /dev/dri/card*; do
96 +
  [[ -e "$dev" ]] && note "$(ls -l "$dev")"
97 +
done
98 +
99 +
hdr "Medien"
100 +
if [[ -d "$STATE_DIR/media" ]]; then
101 +
  COUNT="$(find "$STATE_DIR/media" -maxdepth 1 -type f | wc -l | tr -d ' ')"
102 +
  ok "$COUNT Datei(en) in $STATE_DIR/media"
103 +
  ls -lh "$STATE_DIR/media" 2>/dev/null | tail -n +2 | head -10 | sed 's/^/      /'
104 +
  [[ "$COUNT" == "0" ]] && warn "Ordner ist leer - dann bleibt der Bildschirm schwarz."
105 +
else
106 +
  bad "$STATE_DIR/media fehlt"
107 +
fi
108 +
[[ -f "$STATE_DIR/playlist.m3u" ]] && note "Playliste: $(($(wc -l < "$STATE_DIR/playlist.m3u") - 1)) Eintraege"
109 +
110 +
if command -v ffprobe >/dev/null; then
111 +
  echo
112 +
  note "Videodateien im Detail:"
113 +
  shopt -s nullglob nocaseglob
114 +
  for f in "$STATE_DIR"/media/*.{mp4,m4v,mov,mkv,avi,webm,mpg,mpeg,wmv,flv,ts,3gp,ogv}; do
115 +
    V="$(ffprobe -v error -select_streams v:0 \
116 +
         -show_entries stream=codec_name,width,height,avg_frame_rate \
117 +
         -of csv=p=0 "$f" 2>/dev/null)"
118 +
    D="$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f" 2>/dev/null)"
119 +
    printf '      %-28s %s  %ss\n' "$(basename "$f")" "${V:-?}" "${D%%.*}"
120 +
    W="$(echo "$V" | cut -d, -f2)"
121 +
    C="$(echo "$V" | cut -d, -f1)"
122 +
    if [[ -n "$W" ]] && (( W > 1920 )); then
123 +
      warn "  ${W}px breit - fuer den Pi zu gross, bitte auf 1920 herunterrechnen"
124 +
    fi
125 +
    if [[ "$C" == "hevc" || "$C" == "h264" ]] && [[ "${MPV_HWDEC:-no}" != "no" ]]; then
126 +
      warn "  MPV_HWDEC=${MPV_HWDEC} - auf dem Pi 5 bleibt $C damit haengen."
127 +
      note "  -> MPV_HWDEC=\"no\" in $CONF setzen"
128 +
    fi
129 +
  done
130 +
  shopt -u nullglob nocaseglob
131 +
fi
132 +
133 +
hdr "Ton"
134 +
if command -v mpv >/dev/null && id "$RUN_USER" >/dev/null 2>&1; then
135 +
  RUN_UID="$(id -u "$RUN_USER")"
136 +
  DEVS="$(sudo -u "$RUN_USER" XDG_RUNTIME_DIR="/run/user/$RUN_UID" \
137 +
          timeout 10 mpv --audio-device=help 2>/dev/null | grep -v "^ *'auto'" | head -8)"
138 +
  if [[ -n "$DEVS" ]]; then
139 +
    ok "mpv sieht diese Audiogeraete:"
140 +
    echo "$DEVS" | sed 's/^/      /'
141 +
  else
142 +
    warn "mpv findet kein Audiogeraet - Videos mit Tonspur koennen dadurch haengen bleiben."
143 +
    note "-> In $CONF setzen:  AUDIO=\"no\""
144 +
  fi
145 +
  note "Aktuell eingestellt: AUDIO=${AUDIO:-yes}  MUTE=${MUTE:-no}"
146 +
fi
147 +
148 +
hdr "Webinterface"
149 +
PORT="${WEB_PORT:-80}"
150 +
CODE="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT/healthz" 2>/dev/null)"
151 +
if [[ "$CODE" == "200" ]]; then
152 +
  ok "erreichbar auf Port $PORT"
153 +
  curl -s "http://127.0.0.1:$PORT/api/state" 2>/dev/null \
154 +
    | tr ',' '\n' | grep -E '"(running|display|current|last_error)"' | sed 's/^/      /'
155 +
else
156 +
  bad "nicht erreichbar auf Port $PORT (HTTP $CODE)"
157 +
fi
158 +
159 +
hdr "Access Point"
160 +
if command -v nmcli >/dev/null; then
161 +
  nmcli -t -f NAME,DEVICE,STATE connection show --active 2>/dev/null | sed 's/^/      /'
162 +
  nmcli -t -f NAME connection show --active 2>/dev/null | grep -qx tvwall-ap \
163 +
    && ok "tvwall-ap aktiv" || warn "tvwall-ap nicht aktiv"
164 +
fi
165 +
166 +
hdr "Letzte Protokollzeilen"
167 +
journalctl -u tvwall -n 40 --no-pager 2>/dev/null | sed 's/^/  /'
168 +
169 +
hdr "Direkter mpv-Test"
170 +
FIRST="$(find "$STATE_DIR/media" -maxdepth 1 -type f 2>/dev/null | head -1)"
171 +
if [[ -n "$FIRST" ]]; then
172 +
  note "So laesst sich mpv unabhaengig vom Dienst pruefen:"
173 +
  note "  sudo systemctl stop tvwall"
174 +
  if [[ -n "$COMPOSITOR" ]]; then
175 +
    note "  sudo -u $RUN_USER XDG_RUNTIME_DIR=/run/user/\$(id -u $RUN_USER) mpv --fullscreen '$FIRST'"
176 +
  else
177 +
    note "  sudo -u $RUN_USER mpv --vo=gpu --gpu-context=drm --fullscreen '$FIRST'"
178 +
  fi
179 +
  note "  sudo systemctl start tvwall"
180 +
fi
181 +
echo
hinzugefügt scripts/probe-transition.py
+170 −0 Datei ansehen
@@ -0,0 +1,170 @@
1 +
#!/usr/bin/env python3
2 +
"""
3 +
Prueft, ob ein Video in einem mpv-Prozess laeuft, der vorher ein Bild
4 +
angezeigt hat - und welche mpv-Option das gegebenenfalls behebt.
5 +
6 +
Genau dieser Uebergang ist der Grund, warum tvwall vor jedem Video einen
7 +
frischen mpv-Prozess startet (FRESH_MPV_FOR_VIDEO). Findet dieses Skript eine
8 +
Option, die den Uebergang heil macht, kann der Neustart entfallen.
9 +
10 +
Aufruf (Dienst vorher anhalten):
11 +
12 +
    sudo systemctl stop tvwall
13 +
    sudo -u rk XDG_RUNTIME_DIR=/run/user/1000 \\
14 +
         python3 probe-transition.py <bild> <video>
15 +
    sudo systemctl start tvwall
16 +
"""
17 +
import json
18 +
import os
19 +
import socket
20 +
import subprocess
21 +
import sys
22 +
import tempfile
23 +
import time
24 +
25 +
BASE = [
26 +
    "--fullscreen", "--idle=yes", "--force-window=yes", "--keep-open=no",
27 +
    "--image-display-duration=2", "--hwdec=no", "--no-osc", "--osd-level=0",
28 +
    "--cursor-autohide=always", "--no-input-default-bindings",
29 +
    "--input-vo-keyboard=no", "--no-input-terminal", "--msg-level=all=error",
30 +
    "--vo=gpu",
31 +
]
32 +
33 +
VARIANTEN = [
34 +
    ("Grundzustand (wie der Dienst)",        []),
35 +
    ("--demuxer-thread=no",                  ["--demuxer-thread=no"]),
36 +
    ("--cache=no",                           ["--cache=no"]),
37 +
    ("--cache=yes",                          ["--cache=yes"]),
38 +
    ("ohne Zusatzdateien-Suche",             ["--audio-file-auto=no", "--sub-auto=no",
39 +
                                              "--cover-art-auto=no"]),
40 +
    ("--vd-lavc-dr=no",                      ["--vd-lavc-dr=no"]),
41 +
    ("--demuxer-lavf-probe-info=nostreams",  ["--demuxer-lavf-probe-info=nostreams"]),
42 +
    ("--gapless-audio=no",                   ["--gapless-audio=no"]),
43 +
    ("--force-window=no",                    ["--force-window=no"]),
44 +
    ("--no-audio",                           ["--no-audio"]),
45 +
]
46 +
47 +
48 +
class Mpv:
49 +
    """Minimaler Client fuer den JSON-IPC-Socket von mpv."""
50 +
51 +
    def __init__(self, path):
52 +
        self.sock = socket.socket(socket.AF_UNIX)
53 +
        for _ in range(80):
54 +
            try:
55 +
                self.sock.connect(path)
56 +
                break
57 +
            except OSError:
58 +
                time.sleep(0.25)
59 +
        else:
60 +
            raise RuntimeError("mpv-Socket nicht erreichbar")
61 +
        self.buf = b""
62 +
        self.rid = 0
63 +
64 +
    def command(self, *args):
65 +
        self.rid += 1
66 +
        rid = self.rid
67 +
        self.sock.sendall(
68 +
            (json.dumps({"command": list(args), "request_id": rid}) + "\n").encode())
69 +
        deadline = time.time() + 5
70 +
        while time.time() < deadline:
71 +
            self.sock.settimeout(max(0.1, deadline - time.time()))
72 +
            try:
73 +
                chunk = self.sock.recv(65536)
74 +
            except (socket.timeout, OSError):
75 +
                break
76 +
            if not chunk:
77 +
                break
78 +
            self.buf += chunk
79 +
            while b"\n" in self.buf:
80 +
                line, self.buf = self.buf.split(b"\n", 1)
81 +
                try:
82 +
                    msg = json.loads(line)
83 +
                except ValueError:
84 +
                    continue
85 +
                if msg.get("request_id") == rid:
86 +
                    return msg.get("data")
87 +
        return None
88 +
89 +
    def close(self):
90 +
        try:
91 +
            self.sock.close()
92 +
        except OSError:
93 +
            pass
94 +
95 +
96 +
def probe(extra, image, video):
97 +
    """Bild laden, dann Video - und schauen, ob das Video wirklich laeuft."""
98 +
    tmp = tempfile.mkdtemp()
99 +
    sock = os.path.join(tmp, "s")
100 +
    proc = subprocess.Popen(
101 +
        ["mpv", "--input-ipc-server=" + sock] + BASE + extra,
102 +
        stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
103 +
    try:
104 +
        m = Mpv(sock)
105 +
        m.command("loadfile", image, "replace")
106 +
        time.sleep(2.5)
107 +
        m.command("loadfile", video, "replace")
108 +
        time.sleep(2.0)
109 +
        erst = m.command("get_property", "time-pos")
110 +
        time.sleep(3.0)
111 +
        spaeter = m.command("get_property", "time-pos")
112 +
        m.close()
113 +
114 +
        if not isinstance(erst, (int, float)) or not isinstance(spaeter, (int, float)):
115 +
            return False, "Video wurde gar nicht erst geladen"
116 +
        if spaeter - erst < 1.0:
117 +
            return False, "steht bei %.1f s" % spaeter
118 +
        return True, "laeuft bis %.1f s" % spaeter
119 +
    finally:
120 +
        proc.kill()
121 +
        try:
122 +
            err = proc.stderr.read().decode(errors="replace").strip()
123 +
        except Exception:
124 +
            err = ""
125 +
        proc.wait()
126 +
        if err:
127 +
            probe.last_error = err.splitlines()[-3:]
128 +
        else:
129 +
            probe.last_error = []
130 +
131 +
132 +
def main():
133 +
    if len(sys.argv) != 3:
134 +
        print(__doc__)
135 +
        sys.exit(1)
136 +
    image, video = sys.argv[1], sys.argv[2]
137 +
    for f in (image, video):
138 +
        if not os.path.isfile(f):
139 +
            print("Datei fehlt:", f)
140 +
            sys.exit(1)
141 +
142 +
    print("\nBild : %s\nVideo: %s\n" % (image, video))
143 +
    print("Uebergang Bild -> Video im selben mpv-Prozess:\n")
144 +
145 +
    gut = []
146 +
    for name, extra in VARIANTEN:
147 +
        ok, info = probe(extra, image, video)
148 +
        print("  %-38s %s  (%s)" % (name, "LAEUFT" if ok else "HAENGT", info))
149 +
        for zeile in getattr(probe, "last_error", []):
150 +
            print("      %s" % zeile)
151 +
        if ok:
152 +
            gut.append(name)
153 +
154 +
    print()
155 +
    if not gut:
156 +
        print("Keine Option hilft. Der Neustart vor jedem Video")
157 +
        print("(FRESH_MPV_FOR_VIDEO=\"yes\") bleibt die richtige Loesung.")
158 +
    elif gut == [n for n, _ in VARIANTEN]:
159 +
        print("Alle Varianten laufen - der Uebergang ist auf diesem System heil.")
160 +
        print("Dann kann FRESH_MPV_FOR_VIDEO=\"no\" gesetzt werden.")
161 +
    else:
162 +
        print("Diese Optionen beheben den Uebergang:")
163 +
        for n in gut:
164 +
            print("   ", n)
165 +
        print("\nTrag die passende in MPV_EXTRA_ARGS ein und setze")
166 +
        print("FRESH_MPV_FOR_VIDEO=\"no\" - dann entfaellt der Neustart.")
167 +
168 +
169 +
if __name__ == "__main__":
170 +
    main()
hinzugefügt scripts/setup-ap.sh
+126 −0 Datei ansehen
@@ -0,0 +1,126 @@
1 +
#!/usr/bin/env bash
2 +
# ===========================================================================
3 +
#  Richtet den Raspberry Pi als WLAN-Access-Point ein (NetworkManager/nmcli).
4 +
#
5 +
#  Aufruf:  sudo ./setup-ap.sh [/etc/tvwall/tvwall.conf]
6 +
#
7 +
#  Der Name des WLANs kommt aus AP_SSID in der Konfigurationsdatei - so
8 +
#  bekommt jeder Pi seinen eigenen, unterscheidbaren Netznamen.
9 +
# ===========================================================================
10 +
set -euo pipefail
11 +
12 +
CONF="${1:-/etc/tvwall/tvwall.conf}"
13 +
CON_NAME="tvwall-ap"
14 +
15 +
info() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
16 +
ok()   { printf '\033[1;32m  ✓\033[0m %s\n' "$*"; }
17 +
warn() { printf '\033[1;33m  !\033[0m %s\n' "$*"; }
18 +
die()  { printf '\033[1;31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
19 +
20 +
[[ $EUID -eq 0 ]] || die "bitte mit sudo starten:  sudo $0"
21 +
[[ -f "$CONF" ]]  || die "Konfigurationsdatei nicht gefunden: $CONF"
22 +
23 +
set -a
24 +
# shellcheck disable=SC1090
25 +
. "$CONF"
26 +
set +a
27 +
28 +
AP_SSID="${AP_SSID:-TVWall-01}"
29 +
AP_PASSWORD="${AP_PASSWORD:-tvwall1234}"
30 +
AP_COUNTRY="${AP_COUNTRY:-DE}"
31 +
AP_IP="${AP_IP:-192.168.4.1}"
32 +
AP_CHANNEL="${AP_CHANNEL:-7}"
33 +
CAPTIVE_PORTAL="${CAPTIVE_PORTAL:-no}"
34 +
HOSTNAME_NEW="${HOSTNAME:-tvwall}"
35 +
36 +
[[ ${#AP_PASSWORD} -ge 8 ]] || die "AP_PASSWORD muss mindestens 8 Zeichen haben"
37 +
38 +
command -v nmcli >/dev/null || die "nmcli fehlt. Bitte NetworkManager installieren:
39 +
       sudo apt install network-manager && sudo systemctl enable --now NetworkManager"
40 +
41 +
systemctl is-active --quiet NetworkManager || systemctl start NetworkManager
42 +
43 +
# --- WLAN-Schnittstelle finden --------------------------------------------
44 +
IFACE="${AP_IFACE:-}"
45 +
if [[ -z "$IFACE" ]]; then
46 +
  IFACE="$(nmcli -t -f DEVICE,TYPE device status | awk -F: '$2=="wifi"{print $1; exit}')"
47 +
fi
48 +
[[ -n "$IFACE" ]] || die "keine WLAN-Schnittstelle gefunden"
49 +
info "WLAN-Schnittstelle: $IFACE"
50 +
51 +
# --- Funk freischalten und Land setzen ------------------------------------
52 +
command -v rfkill >/dev/null && rfkill unblock wifi || true
53 +
if command -v raspi-config >/dev/null; then
54 +
  raspi-config nonint do_wifi_country "$AP_COUNTRY" >/dev/null 2>&1 || true
55 +
fi
56 +
command -v iw >/dev/null && iw reg set "$AP_COUNTRY" >/dev/null 2>&1 || true
57 +
nmcli radio wifi on >/dev/null 2>&1 || true
58 +
ok "Funk aktiv, Land: $AP_COUNTRY"
59 +
60 +
# --- Verbindung anlegen ----------------------------------------------------
61 +
info "Richte Access Point '$AP_SSID' ein"
62 +
nmcli connection delete "$CON_NAME" >/dev/null 2>&1 || true
63 +
64 +
nmcli connection add \
65 +
  type wifi \
66 +
  ifname "$IFACE" \
67 +
  con-name "$CON_NAME" \
68 +
  autoconnect yes \
69 +
  ssid "$AP_SSID" >/dev/null
70 +
71 +
nmcli connection modify "$CON_NAME" \
72 +
  802-11-wireless.mode ap \
73 +
  802-11-wireless.band bg \
74 +
  802-11-wireless.channel "$AP_CHANNEL" \
75 +
  802-11-wireless.hidden no \
76 +
  wifi-sec.key-mgmt wpa-psk \
77 +
  wifi-sec.proto rsn \
78 +
  wifi-sec.pairwise ccmp \
79 +
  wifi-sec.group ccmp \
80 +
  wifi-sec.psk "$AP_PASSWORD" \
81 +
  ipv4.method shared \
82 +
  ipv4.addresses "$AP_IP/24" \
83 +
  ipv6.method disabled \
84 +
  connection.autoconnect yes \
85 +
  connection.autoconnect-priority 100
86 +
87 +
# --- Namensaufloesung im eigenen Netz -------------------------------------
88 +
# NetworkManager startet im Modus "shared" ein eigenes dnsmasq. Damit ist der
89 +
# Pi auch unter einem einfachen Namen statt nur ueber die IP erreichbar.
90 +
install -d -m 0755 /etc/NetworkManager/dnsmasq-shared.d
91 +
{
92 +
  echo "# von TV-Wall setup-ap.sh erzeugt"
93 +
  echo "address=/tvwall/$AP_IP"
94 +
  echo "address=/$HOSTNAME_NEW/$AP_IP"
95 +
  echo "address=/$HOSTNAME_NEW.local/$AP_IP"
96 +
  if [[ "$CAPTIVE_PORTAL" == "yes" ]]; then
97 +
    # Alle Namen zeigen auf den Pi - Handys oeffnen die Seite dann von selbst.
98 +
    echo "address=/#/$AP_IP"
99 +
  fi
100 +
} > /etc/NetworkManager/dnsmasq-shared.d/tvwall.conf
101 +
ok "Namensaufloesung eingerichtet (http://tvwall)"
102 +
103 +
# --- Starten ---------------------------------------------------------------
104 +
info "Aktiviere Access Point"
105 +
if ! nmcli connection up "$CON_NAME" >/dev/null 2>&1; then
106 +
  warn "Access Point liess sich nicht sofort starten. Details:"
107 +
  nmcli connection up "$CON_NAME" || true
108 +
  warn "Haeufigste Ursache: das WLAN ist noch mit einem anderen Netz verbunden."
109 +
  warn "Andere WLAN-Verbindungen abschalten:  nmcli connection show"
110 +
fi
111 +
112 +
sleep 2
113 +
if nmcli -t -f NAME connection show --active | grep -qx "$CON_NAME"; then
114 +
  ok "Access Point '$AP_SSID' laeuft auf $IFACE ($AP_IP)"
115 +
else
116 +
  warn "Access Point ist noch nicht aktiv - nach einem Neustart versucht NetworkManager es erneut"
117 +
fi
118 +
119 +
cat <<EOF
120 +
121 +
   WLAN-Name  : $AP_SSID
122 +
   Passwort   : $AP_PASSWORD
123 +
   Adresse    : http://$AP_IP   (oder http://tvwall)
124 +
125 +
   Namen aendern: AP_SSID in $CONF anpassen und dieses Skript erneut ausfuehren.
126 +
EOF
hinzugefügt scripts/uninstall.sh
+29 −0 Datei ansehen
@@ -0,0 +1,29 @@
1 +
#!/usr/bin/env bash
2 +
# Entfernt TV-Wall wieder. Medien bleiben erhalten, sofern nicht --purge.
3 +
set -euo pipefail
4 +
[[ $EUID -eq 0 ]] || { echo "bitte mit sudo starten" >&2; exit 1; }
5 +
6 +
PURGE=0
7 +
[[ "${1:-}" == "--purge" ]] && PURGE=1
8 +
9 +
systemctl disable --now tvwall.service 2>/dev/null || true
10 +
rm -f /etc/systemd/system/tvwall.service
11 +
systemctl daemon-reload
12 +
13 +
nmcli connection delete tvwall-ap 2>/dev/null || true
14 +
rm -f /etc/NetworkManager/dnsmasq-shared.d/tvwall.conf
15 +
16 +
rm -f /usr/local/bin/tvwall
17 +
rm -rf /opt/tvwall
18 +
19 +
# Login auf der Konsole wieder einschalten.
20 +
systemctl enable --now getty@tty1.service 2>/dev/null || true
21 +
22 +
if [[ $PURGE -eq 1 ]]; then
23 +
  rm -rf /var/lib/tvwall /etc/tvwall
24 +
  userdel -r tvwall 2>/dev/null || true
25 +
  echo "TV-Wall vollstaendig entfernt (inklusive Medien)."
26 +
else
27 +
  echo "TV-Wall entfernt. Medien und Konfiguration bleiben erhalten:"
28 +
  echo "  /var/lib/tvwall  und  /etc/tvwall"
29 +
fi
hinzugefügt systemd/tvwall-desktop.service.in
+30 −0 Datei ansehen
@@ -0,0 +1,30 @@
1 +
# TV-Wall - Wiedergabe im laufenden Desktop (Raspberry Pi OS mit Desktop).
2 +
# Wird von install.sh nach /etc/systemd/system/tvwall.service geschrieben.
3 +
[Unit]
4 +
Description=TV-Wall Media-Player und Webinterface
5 +
Documentation=file:/opt/tvwall/README.md
6 +
After=network.target graphical.target
7 +
8 +
[Service]
9 +
Type=simple
10 +
User=@RUN_USER@
11 +
SupplementaryGroups=video render audio input
12 +
13 +
# Der Player haengt sich an die Desktop-Sitzung des Benutzers. Welcher
14 +
# Wayland- bzw. X11-Zugang das genau ist, sucht tvwall beim Start selbst.
15 +
Environment=XDG_RUNTIME_DIR=/run/user/@RUN_UID@
16 +
17 +
ExecStart=/usr/local/bin/tvwall -config @CONFIG@
18 +
Restart=always
19 +
RestartSec=3
20 +
21 +
AmbientCapabilities=CAP_NET_BIND_SERVICE
22 +
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
23 +
24 +
RuntimeDirectory=tvwall
25 +
RuntimeDirectoryMode=0775
26 +
27 +
NoNewPrivileges=yes
28 +
29 +
[Install]
30 +
WantedBy=graphical.target
hinzugefügt systemd/tvwall-drm.service.in
+38 −0 Datei ansehen
@@ -0,0 +1,38 @@
1 +
# TV-Wall - Wiedergabe direkt auf der Konsole (Raspberry Pi OS Lite).
2 +
# Wird von install.sh nach /etc/systemd/system/tvwall.service geschrieben.
3 +
[Unit]
4 +
Description=TV-Wall Media-Player und Webinterface
5 +
Documentation=file:/opt/tvwall/README.md
6 +
After=network.target getty@tty1.service
7 +
Conflicts=getty@tty1.service
8 +
9 +
[Service]
10 +
Type=simple
11 +
User=@RUN_USER@
12 +
SupplementaryGroups=video render audio input tty
13 +
14 +
# Bildschirm gehoert uns: Konsole tty1, kein Bildschirmschoner, kein Cursor.
15 +
TTYPath=/dev/tty1
16 +
StandardInput=tty-force
17 +
StandardOutput=journal
18 +
StandardError=journal
19 +
TTYVTDisallocate=yes
20 +
ExecStartPre=/bin/sh -c '/usr/bin/setterm --blank 0 --powerdown 0 --cursor off > /dev/tty1 || true'
21 +
22 +
ExecStart=/usr/local/bin/tvwall -config @CONFIG@
23 +
Restart=always
24 +
RestartSec=3
25 +
26 +
# Damit der Webserver Port 80 verwenden darf, ohne als root zu laufen.
27 +
AmbientCapabilities=CAP_NET_BIND_SERVICE
28 +
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
29 +
30 +
# /run/tvwall fuer den mpv-Steuersocket
31 +
RuntimeDirectory=tvwall
32 +
RuntimeDirectoryMode=0775
33 +
34 +
NoNewPrivileges=yes
35 +
ProtectSystem=full
36 +
37 +
[Install]
38 +
WantedBy=multi-user.target