fix: v1.0.0
R
Rüdiger Küpper
<rpr@9it.de>
committete am 21.08.2026 01:27
14518d057bcd34fd95e411d13ea882ffd2148167
Binärdatei – kein Textvergleich möglich.
Binärdatei – kein Textvergleich möglich.
Binärdatei – kein Textvergleich möglich.
| @@ -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 |
| @@ -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 | +``` |
| @@ -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 | +} |
| @@ -1,24 +1,36 @@ | ||
| 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" |
| @@ -26,27 +38,62 @@ HOSTNAME="tvwall-01" | ||
| 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="" |
| @@ -54,2 +101,7 @@ MPV_EXTRA_ARGS="" | ||
| 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" |
| @@ -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 |
| @@ -31,8 +31,15 @@ type Config struct { | ||
| 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 | } |
| @@ -51,5 +58,11 @@ func Default() *Config { | ||
| 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 | } |
| @@ -63,3 +76,3 @@ func (c *Config) PlaylistFile() string { return filepath.Join(c.StateDir, "playl | ||
| 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 | |
| @@ -122,4 +135,11 @@ func Load(path string) (*Config, error) { | ||
| 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 |
| @@ -130,2 +150,16 @@ func Load(path string) (*Config, error) { | ||
| 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": |
| @@ -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 | +} |
| @@ -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 | +} |
| @@ -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 | +} |
| @@ -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 | +} |
| @@ -29,2 +29,15 @@ var ( | ||
| 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. |
| @@ -91,2 +104,10 @@ func (m *Manager) Entries() []Entry { | ||
| 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. |
| @@ -150,3 +171,3 @@ func (m *Manager) Scan() (bool, error) { | ||
| 150 | 171 | |
| 151 | -return m.commit(ordered, len(ordered) != len(stored)) |
|
| 172 | +return m.commit(ordered) |
|
| 152 | 173 | } |
| @@ -179,3 +200,3 @@ func (m *Manager) SetOrder(names []string) error { | ||
| 179 | 200 | |
| 180 | -_, err := m.commit(ordered, true) |
|
| 201 | +_, err := m.commit(ordered) |
|
| 181 | 202 | return err |
| @@ -197,7 +218,6 @@ func (m *Manager) Delete(name string) error { | ||
| 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() |
| @@ -205,14 +225,6 @@ func (m *Manager) commit(ordered []Entry, force bool) (bool, error) { | ||
| 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)) |
| @@ -226,6 +238,8 @@ func (m *Manager) commit(ordered []Entry, force bool) (bool, error) { | ||
| 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 | } |
| @@ -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) } |
| @@ -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); |
| @@ -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> |
| @@ -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 | +} |
| @@ -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 | +} |
| @@ -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 } |
| @@ -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 | +} |
| @@ -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 | +} |
| @@ -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" |
| @@ -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 |
| @@ -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() |
| @@ -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 |
| @@ -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 |
| @@ -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 |
| @@ -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 |