Compare commits
6 Commits
99a1aa6460
...
feature/da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fec4e4635c | ||
|
|
8192388c5d | ||
|
|
e236e1f673 | ||
|
|
beeee82896 | ||
|
|
0b70891bca | ||
|
|
4fab26106c |
356
AUDIO_SETUP_REAL.md
Normal file
356
AUDIO_SETUP_REAL.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# 🎵 Echtes Audio Setup - Spotify & AirPlay
|
||||
|
||||
## ✅ FIXES ANGEWENDET
|
||||
|
||||
### 1. VITE_USE_MOCK = false
|
||||
- Dashboard nutzt jetzt **echte Services**
|
||||
- Keine Mock-Daten mehr
|
||||
- Verbindet zu Mopidy, Snapcast, SignalK
|
||||
|
||||
### 2. Karte crasht nicht mehr
|
||||
- Simple Navigation Map (ohne MapLibre)
|
||||
- Zeigt Position, Kurs, Geschwindigkeit
|
||||
- Kein externer Tile-Server nötig
|
||||
|
||||
---
|
||||
|
||||
## 🎯 WICHTIG: Audio im Dev-Mode
|
||||
|
||||
### Warum hörst du NICHTS?
|
||||
|
||||
**Im Dev-Mode:**
|
||||
```
|
||||
Spotify/AirPlay/Mopidy
|
||||
↓
|
||||
/tmp/audio/*.pcm (Named Pipes)
|
||||
↓
|
||||
Snapserver (Docker)
|
||||
↓
|
||||
Snapclients (Docker mit --player file:filename=null)
|
||||
↓
|
||||
🔇 Audio wird VERWORFEN (null player)
|
||||
```
|
||||
|
||||
**Warum?**
|
||||
- Docker kann nicht direkt zu Windows-Lautsprechern
|
||||
- Audio-Pipes funktionieren nicht über Docker Desktop VM-Grenze
|
||||
- Die Zonen nutzen `--player file:filename=null` = stumm
|
||||
|
||||
---
|
||||
|
||||
## 🔊 3 WEGE UM AUDIO ZU HÖREN
|
||||
|
||||
### Option 1: Snapcast Web Audio (Browser) ✅ EINFACHST
|
||||
|
||||
**So gehts:**
|
||||
1. Öffne: http://localhost:1780
|
||||
2. Klicke auf 🔊 Symbol (oben rechts)
|
||||
3. Wähle eine Zone (z.B. "zone-salon")
|
||||
4. **Browser fragt nach Mikrofon-Berechtigung** → Erlaube (nutzt nur Audio OUT!)
|
||||
5. Musik in Mopidy starten → http://localhost:6680/iris/
|
||||
6. **Audio läuft über deinen Browser!**
|
||||
|
||||
**Vorteile:**
|
||||
- ✅ Keine Installation
|
||||
- ✅ Funktioniert sofort
|
||||
- ✅ Web Audio API (Chrome, Firefox, Edge)
|
||||
|
||||
**Nachteile:**
|
||||
- ⚠️ Tab muss offen bleiben
|
||||
- ⚠️ Kann latency haben (~500ms)
|
||||
|
||||
---
|
||||
|
||||
### Option 2: Snapclient auf Windows (Native) ✅ BESTE QUALITÄT
|
||||
|
||||
**Installation:**
|
||||
1. Download: https://github.com/badaix/snapcast/releases/latest
|
||||
2. Datei: `snapclient-X.XX.X-win64.zip`
|
||||
3. Entpacken nach `C:\Program Files\Snapcast\`
|
||||
|
||||
**Starten:**
|
||||
```powershell
|
||||
# Terminal öffnen
|
||||
snapclient --host localhost --port 1704 --hostID windows-pc --player wasapi
|
||||
```
|
||||
|
||||
**Oder mit make (falls installiert):**
|
||||
```powershell
|
||||
make windows-audio
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- ✅ Perfekte Audio-Qualität
|
||||
- ✅ Niedrige Latenz (~20ms)
|
||||
- ✅ Native Windows Audio (WASAPI)
|
||||
|
||||
**Nachteile:**
|
||||
- ⚠️ Muss manuell gestartet werden
|
||||
- ⚠️ Extra Terminal-Fenster
|
||||
|
||||
---
|
||||
|
||||
### Option 3: Docker Zone mit PulseAudio (WSL2) ⚠️ NUR LINUX
|
||||
|
||||
**Nur wenn du WSL2 mit PulseAudio hast:**
|
||||
```bash
|
||||
# In WSL2 Terminal
|
||||
pulseaudio --start
|
||||
```
|
||||
|
||||
Dann in `docker-compose.dev.yml`:
|
||||
```yaml
|
||||
zone-windows:
|
||||
build: ./docker/snapclient
|
||||
command: ["--hostID", "zone-windows", "--player", "pulse"]
|
||||
environment:
|
||||
- PULSE_SERVER=unix:/run/user/1000/pulse/native
|
||||
volumes:
|
||||
- /run/user/1000/pulse:/run/user/1000/pulse
|
||||
```
|
||||
|
||||
**Nicht empfohlen für Windows!**
|
||||
|
||||
---
|
||||
|
||||
## 🎵 MUSIK QUELLEN AKTIVIEREN
|
||||
|
||||
### Spotify Connect ✅
|
||||
|
||||
**Im Dev-Mode läuft librespot als Stub (dummy).**
|
||||
|
||||
**Um echtes Spotify zu nutzen:**
|
||||
|
||||
**Option A: Librespot auf Windows (EMPFOHLEN)**
|
||||
```powershell
|
||||
# Download: https://github.com/librespot-org/librespot/releases
|
||||
# Oder mit Rust installieren:
|
||||
cargo install librespot
|
||||
|
||||
# Starten:
|
||||
librespot --name "Bordanlage" --backend rodio
|
||||
```
|
||||
|
||||
Dann in Spotify App → Geräte → "Bordanlage" auswählen
|
||||
|
||||
**Option B: Docker librespot (Production Mode)**
|
||||
```powershell
|
||||
# Nutze docker-compose.yml (ohne .dev overlay)
|
||||
docker compose up -d librespot
|
||||
```
|
||||
|
||||
⚠️ Aber: Audio geht nach `/tmp/audio/spotify.pcm` → Du hörst nichts!
|
||||
|
||||
---
|
||||
|
||||
### AirPlay ✅
|
||||
|
||||
**Shairport-Sync läuft im Docker.**
|
||||
|
||||
**Von iPhone/Mac:**
|
||||
1. Control Center öffnen
|
||||
2. AirPlay Symbol
|
||||
3. "Bordanlage AirPlay" auswählen
|
||||
4. Musik abspielen
|
||||
|
||||
⚠️ **Problem:** Audio geht nach `/tmp/audio/airplay.pcm` → Du hörst nichts!
|
||||
|
||||
**Lösung:**
|
||||
- Nutze Snapcast Web Audio (Option 1)
|
||||
- Oder native Snapclient (Option 2)
|
||||
- AirPlay → Pipe → Snapserver → Snapclient → Windows!
|
||||
|
||||
---
|
||||
|
||||
### Mopidy (Local Files + Radio) ✅
|
||||
|
||||
**Läuft bereits!**
|
||||
|
||||
**Musik abspielen:**
|
||||
1. Öffne: http://localhost:6680/iris/
|
||||
2. Klicke "Browse" → "TuneIn"
|
||||
3. Suche "BBC Radio" oder "NDR"
|
||||
4. Klicke auf Sender → Play
|
||||
|
||||
**Local Files:**
|
||||
1. Kopiere MP3/FLAC nach `./music/`
|
||||
2. Library Scan triggern:
|
||||
```powershell
|
||||
curl -X POST http://localhost:6680/mopidy/rpc `
|
||||
-H "Content-Type: application/json" `
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"library.refresh"}'
|
||||
```
|
||||
3. Warte ~30 Sek
|
||||
4. Reload Mopidy UI → Files sind da!
|
||||
|
||||
---
|
||||
|
||||
## 🧪 AUDIO TEST WORKFLOW
|
||||
|
||||
### Test 1: Mopidy → Snapcast → Browser
|
||||
|
||||
```
|
||||
1. Öffne: http://localhost:6680/iris/
|
||||
→ Klicke Browse → TuneIn
|
||||
→ Wähle "BBC Radio 1"
|
||||
→ Klicke Play
|
||||
|
||||
2. Öffne: http://localhost:1780
|
||||
→ Klicke 🔊 Symbol
|
||||
→ Wähle "zone-salon"
|
||||
→ Erlaube Browser Audio
|
||||
|
||||
3. Musik sollte jetzt laufen! 🎵
|
||||
```
|
||||
|
||||
**Falls nicht:**
|
||||
- F12 → Console → Fehler?
|
||||
- Snapcast Web zeigt "zone-salon" connected?
|
||||
- Mopidy zeigt "Playing"?
|
||||
|
||||
---
|
||||
|
||||
### Test 2: Dashboard Player
|
||||
|
||||
```
|
||||
1. Öffne: http://localhost:8090
|
||||
|
||||
2. Klicke Tab "Audio"
|
||||
→ Klicke "Radio" Sub-Tab
|
||||
→ Suche "BBC" oder "NDR"
|
||||
→ Klicke auf Sender
|
||||
|
||||
3. Zurück zu "Overview" Tab
|
||||
→ Player zeigt Track
|
||||
→ [▶] Play/Pause funktioniert
|
||||
|
||||
4. Browser Audio aktivieren (Snapcast Web)
|
||||
→ http://localhost:1780
|
||||
→ 🔊 Symbol → Zone wählen
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 TROUBLESHOOTING
|
||||
|
||||
### Problem: "Dashboard zeigt 'Audio not connected'"
|
||||
|
||||
**Ursache:** Mopidy WebSocket Connection failed
|
||||
|
||||
**Fix:**
|
||||
```powershell
|
||||
# Mopidy Logs prüfen
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs mopidy
|
||||
|
||||
# Mopidy neu starten
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart mopidy
|
||||
|
||||
# Browser neu laden (Ctrl+R)
|
||||
```
|
||||
|
||||
### Problem: "Zones zeigen alle 'offline'"
|
||||
|
||||
**Ursache:** Snapcast JSON-RPC Connection failed
|
||||
|
||||
**Fix:**
|
||||
```powershell
|
||||
# Snapserver Logs prüfen
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs snapserver
|
||||
|
||||
# Snapserver neu starten
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart snapserver
|
||||
|
||||
# Alle Zonen neu starten
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart zone-salon zone-cockpit zone-bug zone-heck
|
||||
```
|
||||
|
||||
### Problem: "Snapcast Web Audio funktioniert nicht"
|
||||
|
||||
**Check:**
|
||||
1. Nutzt du Chrome/Firefox/Edge? (Safari hat Probleme)
|
||||
2. Hast du Mikrofon-Berechtigung erteilt? (wird für Web Audio benötigt)
|
||||
3. Tab bleibt offen?
|
||||
4. F12 → Console → Web Audio Fehler?
|
||||
|
||||
**Fix:**
|
||||
```javascript
|
||||
// Browser Console:
|
||||
navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
.then(() => console.log("✅ Audio erlaubt"))
|
||||
.catch(err => console.log("❌ Audio blockiert:", err))
|
||||
```
|
||||
|
||||
### Problem: "Spotify Device wird nicht gefunden"
|
||||
|
||||
**Im Dev-Mode:**
|
||||
- librespot läuft als Stub (dummy)
|
||||
- Nutze native librespot auf Windows (siehe oben)
|
||||
|
||||
**Production Mode:**
|
||||
- `docker-compose.yml` nutzen (ohne .dev)
|
||||
- Aber: Audio geht nach Pipe, du hörst nichts!
|
||||
- → Nutze native librespot
|
||||
|
||||
---
|
||||
|
||||
## 📊 SERVICE STATUS PRÜFEN
|
||||
|
||||
```powershell
|
||||
# Alle Audio Services
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml ps | findstr "mopidy snapserver zone"
|
||||
|
||||
# Mopidy API Test
|
||||
curl http://localhost:6680/mopidy/rpc -X POST -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"core.get_version"}'
|
||||
|
||||
# Snapcast Status (WebSocket, schwieriger zu testen)
|
||||
# → Nutze http://localhost:1780 (Web UI)
|
||||
|
||||
# SignalK API Test
|
||||
curl http://localhost:3000/signalk/v1/api/vessels/self
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ ZUSAMMENFASSUNG
|
||||
|
||||
### Was funktioniert JETZT:
|
||||
|
||||
✅ **Dashboard:**
|
||||
- Echte Services (VITE_USE_MOCK=false)
|
||||
- Kein Karten-Crash mehr (Simple Map)
|
||||
- Audio Player zeigt echte Mopidy-Daten
|
||||
|
||||
✅ **Audio System:**
|
||||
- Mopidy läuft (Port 6680)
|
||||
- Snapserver läuft (Port 1704, 1705, 1780)
|
||||
- 4 Zonen connected (aber stumm)
|
||||
|
||||
✅ **Musik Quellen:**
|
||||
- Mopidy + Iris (Local Files + TuneIn Radio)
|
||||
- Shairport (AirPlay Receiver)
|
||||
- librespot (Spotify Connect - als Stub)
|
||||
|
||||
### Was du noch tun musst:
|
||||
|
||||
❗ **Audio hören:**
|
||||
1. **Einfach:** Snapcast Web Audio (http://localhost:1780 → 🔊)
|
||||
2. **Best:** Native Snapclient auf Windows
|
||||
|
||||
❗ **Spotify aktivieren:**
|
||||
- Native librespot auf Windows starten (siehe Anleitung oben)
|
||||
|
||||
❗ **AirPlay testen:**
|
||||
- iPhone → AirPlay → "Bordanlage AirPlay"
|
||||
- Audio hören → Snapcast Web Audio aktivieren
|
||||
|
||||
---
|
||||
|
||||
## 🚢 AUF DEM BOOT (später)
|
||||
|
||||
**Dann funktioniert ALLES automatisch:**
|
||||
- Echte ALSA-Lautsprecher in Zonen
|
||||
- Spotify Connect über Boot-Netzwerk
|
||||
- AirPlay über Boot-Netzwerk
|
||||
- Alles läuft ohne Windows
|
||||
|
||||
**Setup:** Siehe `KIOSK_SETUP.md`
|
||||
38
CHANGES_SUMMARY.md
Normal file
38
CHANGES_SUMMARY.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# boWave Project Updates & Fixes
|
||||
|
||||
This document summarizes the changes made to the boWave project to improve NMEA 2000 data visibility, dashboard aesthetics, and Windows compatibility.
|
||||
|
||||
## 🚀 Key Improvements
|
||||
|
||||
### 1. Unified Dashboard Access (Port 8090)
|
||||
- **Problem:** Accessing different services required multiple ports (3000, 1780, 6680, etc.), causing CORS and firewall issues.
|
||||
- **Solution:** Configured Nginx and Vite proxies to tunnel all backend services through the main dashboard port (8090).
|
||||
- **Paths:**
|
||||
- SignalK: `/signalk`
|
||||
- Snapcast: `/snapcast-ws`
|
||||
- Mopidy: `/mopidy`
|
||||
- Jellyfin: `/jellyfin`
|
||||
|
||||
### 2. "Ship Control" Dashboard Design
|
||||
- **New UI:** Implemented a minimalist, high-end yacht aesthetic based on provided photo references.
|
||||
- **BoatControl Component:** Added a central interactive boat profile with circular category icons (Lights, Climate, Nav, Music, Energy).
|
||||
- **FloorPlan Component:** Created a visual deck plan for controlling lighting and climate in specific zones (Salon, Cabins, etc.).
|
||||
- **Central Instrument:** Added a floating gauge over the boat for immediate SOG and Heading monitoring.
|
||||
|
||||
### 3. NMEA 2000 Data Fixes
|
||||
- **SignalK Proxy:** Fixed a bug in the API router that prevented listeners from re-attaching when switching from real to mock data.
|
||||
- **Auto-Fallback:** The dashboard now waits 5 seconds for real SignalK data; if none is received, it automatically starts the mock data stream so the dashboard never looks "empty".
|
||||
|
||||
### 4. Windows Compatibility & Ease of Use
|
||||
- **Self-Healing Pipes:** Named pipes (`.pcm` files) are now created automatically inside the Docker containers during startup. No more manual `mkfifo` needed on the host.
|
||||
- **Batch Scripts:** Added `dev.bat` and `stop.bat` for Windows users who don't have `make` installed.
|
||||
- **Docker Fixes:** Corrected `librespot` execution errors by using the official image and proper command list formatting.
|
||||
|
||||
### 5. Audio System Enhancements
|
||||
- **Spotify & AirPlay Visibility:** Added these services to the health check monitor and updated the `useZones` hook to track their stream status via Snapcast.
|
||||
- **Unified API:** The `createApi` factory now automatically uses the dashboard host, making it fully portable across different network setups.
|
||||
|
||||
## 🛠️ How to start (Windows)
|
||||
1. Ensure Docker Desktop is running.
|
||||
2. Run `.\dev.bat` in the project root.
|
||||
3. Open **http://localhost:8090** in your browser.
|
||||
317
DASHBOARD_ANLEITUNG.md
Normal file
317
DASHBOARD_ANLEITUNG.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# 🎯 Dashboard Bedienung - Alles auf einen Blick
|
||||
|
||||
## ✅ GELÖSTE PROBLEME
|
||||
|
||||
### 1. Karten-Crash behoben
|
||||
- ✅ **Error Handling** eingebaut
|
||||
- ✅ **Loading-Spinner** beim Laden
|
||||
- ✅ **Fehleranzeige** wenn Karte nicht lädt (z.B. kein Internet)
|
||||
- ✅ **"Neu laden" Button** bei Fehlern
|
||||
|
||||
### 2. Alles im Dashboard vereint
|
||||
- ✅ **Kein App-Wechsel mehr nötig**
|
||||
- ✅ **Overview Tab zeigt alles:** Navigation + Audio + Instrumente
|
||||
- ✅ **Music Player direkt eingebaut** (Play/Pause/Skip)
|
||||
- ✅ **Audio-Zonen direkt sichtbar** (Lautstärke/Mute)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 So benutzt du das Dashboard
|
||||
|
||||
### Öffne: http://localhost:8090
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW TAB (Haupt-Screen)
|
||||
|
||||
**Das ist der Tab, den du auf dem Boot im Kiosk-Modus siehst!**
|
||||
|
||||
### 🗺️ Karte / Instrumente Toggle
|
||||
|
||||
**Oben siehst du zwei Buttons:**
|
||||
```
|
||||
[📊 Instrumente] [🗺️ Karte]
|
||||
```
|
||||
|
||||
**Klick auf "📊 Instrumente":**
|
||||
- Zeigt: Kompass, Geschwindigkeit, Tiefe, Wind
|
||||
- Ideal zum Segeln: Alle wichtigen Werte auf einen Blick
|
||||
|
||||
**Klick auf "🗺️ Karte":**
|
||||
- Zeigt: Interaktive Seekarte mit deiner Position
|
||||
- Dein Boot (blaues Dreieck) bewegt sich live
|
||||
- OpenSeaMap Overlay (Tonnen, Fahrwasser, etc.)
|
||||
- Zoom +/- Buttons
|
||||
- "⊙" Button → Zentriert auf Boot
|
||||
|
||||
**Wenn Karte nicht lädt:**
|
||||
- Zeigt: "⚠️ Karte konnte nicht geladen werden"
|
||||
- Klicke: "Neu laden" Button
|
||||
- **KEIN Crash mehr!** Dashboard läuft weiter.
|
||||
|
||||
---
|
||||
|
||||
### 🎵 Music Player (direkt im Overview)
|
||||
|
||||
**Du siehst den aktuellen Track:**
|
||||
```
|
||||
♫ Track Title
|
||||
Artist Name
|
||||
Album Name
|
||||
━━━━━━━━━━━━━━━ 2:30 / 4:15
|
||||
|
||||
[⏮] [▶] [⏭]
|
||||
```
|
||||
|
||||
**Controls:**
|
||||
- `⏮` = Vorheriger Track
|
||||
- `▶` = Play/Pause
|
||||
- `⏭` = Nächster Track
|
||||
- **Fortschrittsbalken** zeigt Position
|
||||
|
||||
**Musik starten:**
|
||||
1. Gehe zu **Audio Tab** → "Radio" oder "Library"
|
||||
2. Wähle einen Song/Sender
|
||||
3. Player spielt ab → Zurück zu Overview
|
||||
4. Steuerung funktioniert von Overview aus!
|
||||
|
||||
---
|
||||
|
||||
### 🔊 Audio Zonen (direkt im Overview)
|
||||
|
||||
**Du siehst alle 4 Boot-Zonen:**
|
||||
```
|
||||
╔════════════╗ ╔════════════╗
|
||||
║ Salon ║ ║ Cockpit ║
|
||||
║ 🔊 72% ║ ║ 🔊 58% ║
|
||||
║ Mopidy ║ ║ Mopidy ║
|
||||
╚════════════╝ ╚════════════╝
|
||||
|
||||
╔════════════╗ ╔════════════╗
|
||||
║ Bug ║ ║ Heck ║
|
||||
║ 🔇 Muted ║ ║ ⚪ Offline║
|
||||
║ Spotify ║ ║ AirPlay ║
|
||||
╚════════════╝ ╚════════════╝
|
||||
```
|
||||
|
||||
**Jede Zone zeigt:**
|
||||
- Name (Salon, Cockpit, Bug, Heck)
|
||||
- Lautstärke (Slider)
|
||||
- Audio-Quelle (Spotify/AirPlay/Mopidy)
|
||||
- Mute-Button (🔊/🔇)
|
||||
|
||||
**Zone anpassen:**
|
||||
1. **Lautstärke:** Schieberegler bewegen
|
||||
2. **Mute:** Klick auf 🔊 Symbol
|
||||
3. **Quelle wechseln:** Klick auf Source-Name (Dropdown)
|
||||
|
||||
**Alle Zonen gleichzeitig:**
|
||||
- Für erweiterte Gruppen-Funktionen → **Audio Tab**
|
||||
|
||||
---
|
||||
|
||||
## 🎵 AUDIO TAB (Erweiterte Kontrolle)
|
||||
|
||||
**4 Sub-Tabs:**
|
||||
|
||||
### 1. **Zones** (Standard)
|
||||
- Gleiche Zonen wie Overview
|
||||
- Zusätzliche Funktionen:
|
||||
- Zonen gruppieren
|
||||
- Master-Lautstärke
|
||||
- Source Picker (Spotify/AirPlay/Mopidy)
|
||||
|
||||
### 2. **Accounts**
|
||||
- Spotify Account Manager
|
||||
- Multi-Account Support
|
||||
- Geräte-Zuweisung
|
||||
|
||||
### 3. **Radio**
|
||||
- Radio Browser (TuneIn Integration)
|
||||
- Tausende Sender weltweit
|
||||
- Suche nach Genre, Land, Sprache
|
||||
|
||||
### 4. **Library**
|
||||
- Lokale Musik-Bibliothek
|
||||
- Playlists
|
||||
- Track-Suche
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ NAVIGATION TAB (Vollbild-Karte)
|
||||
|
||||
**Wann nutzen?**
|
||||
- Wenn du die Karte groß brauchst
|
||||
- Für Route-Planung
|
||||
- Waypoint-Management
|
||||
|
||||
**Features:**
|
||||
- Live-Position mit Kurs
|
||||
- Track-Aufzeichnung (letzten 500 Punkte)
|
||||
- Waypoints mit Entfernungsanzeige
|
||||
- Info-Panel (Position, Heading, Speed, Distance)
|
||||
|
||||
**Keine Angst vor Crashes:**
|
||||
- Falls Karte nicht lädt → Fehleranzeige + Reload-Button
|
||||
- Dashboard bleibt stabil!
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ SYSTEMS TAB
|
||||
|
||||
**Zeigt:**
|
||||
- 🔋 Batterien (12V Starter + 24V House)
|
||||
- ⚙️ Motor (RPM, Betriebsstunden, Kraftstoff)
|
||||
- 🐳 Docker Services (Status aller Container)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 WORKFLOW-BEISPIELE
|
||||
|
||||
### Beispiel 1: "Ich will Musik hören"
|
||||
|
||||
**Schnellstart:**
|
||||
1. Öffne **Audio Tab** → **Radio**
|
||||
2. Wähle einen Sender (z.B. "NDR 2")
|
||||
3. Klick auf Play
|
||||
4. Zurück zu **Overview** → Player läuft!
|
||||
5. Lautstärke in Zonen anpassen (direkt im Overview)
|
||||
|
||||
### Beispiel 2: "Ich will navigieren"
|
||||
|
||||
**Mit Karte:**
|
||||
1. **Overview Tab** → Klick auf "🗺️ Karte"
|
||||
2. Karte zeigt deine Position live
|
||||
3. Zoom mit +/- oder Scroll
|
||||
4. "⊙" Button → Zentriert auf Boot
|
||||
|
||||
**Mit Instrumenten:**
|
||||
1. **Overview Tab** → Klick auf "📊 Instrumente"
|
||||
2. Siehst: Kompass, Geschwindigkeit, Tiefe, Wind
|
||||
3. Alles auf einen Blick
|
||||
|
||||
### Beispiel 3: "Karte lädt nicht"
|
||||
|
||||
**Problem:**
|
||||
- Karte zeigt nur Grau
|
||||
- Oder: "⚠️ Karte konnte nicht geladen werden"
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe Internet-Verbindung (Karte braucht OpenStreetMap)
|
||||
2. Klick auf "Neu laden" Button
|
||||
3. **ODER:** Nutze "📊 Instrumente" statt Karte
|
||||
4. **Dashboard crasht NICHT mehr!**
|
||||
|
||||
---
|
||||
|
||||
## 💡 TIPPS & TRICKS
|
||||
|
||||
### Tipp 1: Overview als Haupt-Screen
|
||||
- **Overview Tab** zeigt 90% von allem
|
||||
- Du brauchst die anderen Tabs selten
|
||||
- Perfekt für Kiosk-Modus auf dem Boot
|
||||
|
||||
### Tipp 2: Musik im Hintergrund
|
||||
- Starte Musik im **Audio Tab** → Radio/Library
|
||||
- Zurück zu **Overview**
|
||||
- Player läuft weiter, Controls funktionieren
|
||||
|
||||
### Tipp 3: Zonen-Management
|
||||
- **Schnell:** Lautstärke im Overview anpassen
|
||||
- **Erweitert:** Audio Tab für Gruppierung
|
||||
|
||||
### Tipp 4: Karte zu klein?
|
||||
- Overview Karte: ~400px hoch (für Übersicht)
|
||||
- **Navigation Tab:** Vollbild-Karte (für Details)
|
||||
|
||||
### Tipp 5: Mock-Daten vs. Echte Daten
|
||||
- **Dev Mode (jetzt):** Mock NMEA-Daten, simuliertes Boot
|
||||
- **Boot Mode (später):** Echte GPS, echte Sensoren
|
||||
- Dashboard funktioniert identisch!
|
||||
|
||||
---
|
||||
|
||||
## 🔧 TASTENKOMBINATIONEN
|
||||
|
||||
**Browser:**
|
||||
- `F11` → Vollbild (Kiosk-Simulation)
|
||||
- `Ctrl + R` → Seite neu laden (bei Problemen)
|
||||
- `F12` → Developer Console (für Debugging)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ TROUBLESHOOTING
|
||||
|
||||
### Problem: "Karte zeigt nur grauen Hintergrund"
|
||||
|
||||
**Ursache:** OpenStreetMap-Tiles laden nicht
|
||||
|
||||
**Lösung:**
|
||||
1. Prüfe Internet (http://tile.openstreetmap.org erreichbar?)
|
||||
2. Klick auf "Neu laden" im Dashboard
|
||||
3. Falls dauerhaft offline: Nutze "📊 Instrumente" Modus
|
||||
|
||||
### Problem: "Audio spielt nicht"
|
||||
|
||||
**Check:**
|
||||
1. **Overview** → Ist im Player ein Track sichtbar?
|
||||
2. **Audio Tab** → Zones → Sind Zonen verbunden (grün)?
|
||||
3. **Mopidy:** Öffne http://localhost:6680/iris/ → Spielt dort?
|
||||
4. Lautstärke > 0? Nicht gemuted?
|
||||
|
||||
### Problem: "Dashboard lädt nicht"
|
||||
|
||||
**Quick Fix:**
|
||||
```powershell
|
||||
# Services neu starten:
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart
|
||||
|
||||
# Browser-Cache leeren:
|
||||
Ctrl + Shift + Delete → "Cached Images" löschen
|
||||
```
|
||||
|
||||
### Problem: "Position bewegt sich nicht"
|
||||
|
||||
**Das ist normal im Dev-Mode:**
|
||||
- Mock-Daten simulieren langsame Fahrt (3-8 Knoten)
|
||||
- Position ändert sich alle paar Sekunden
|
||||
- Auf echtem Boot: Live GPS-Updates
|
||||
|
||||
---
|
||||
|
||||
## 🚢 DEPLOYMENT AUF BOOT (später)
|
||||
|
||||
**Wenn du das System aufs Boot bringst:**
|
||||
1. Ubuntu Server Setup → Siehe `KIOSK_SETUP.md`
|
||||
2. Chromium Kiosk-Modus:
|
||||
```bash
|
||||
chromium-browser --kiosk http://localhost:8080
|
||||
```
|
||||
3. Dashboard öffnet automatisch in **Overview Tab**
|
||||
4. Touchscreen-Steuerung funktioniert
|
||||
5. Alle Features identisch zu Dev!
|
||||
|
||||
---
|
||||
|
||||
## ✅ ZUSAMMENFASSUNG
|
||||
|
||||
### ✅ Was funktioniert jetzt:
|
||||
- ✅ **Kein Crash** bei Karten-Fehler
|
||||
- ✅ **Alles im Overview Tab** (Karte + Instrumente + Audio)
|
||||
- ✅ **Music Player** direkt bedienbar
|
||||
- ✅ **Audio-Zonen** direkt steuerbar
|
||||
- ✅ **Kein App-Wechsel** mehr nötig
|
||||
|
||||
### 🎯 Dein Workflow:
|
||||
1. **Dashboard öffnen:** http://localhost:8090
|
||||
2. **Overview Tab** → Alles auf einen Blick
|
||||
3. **Karte/Instrumente** → Toggle je nach Bedarf
|
||||
4. **Musik starten** → Audio Tab → Radio/Library
|
||||
5. **Zonen anpassen** → Direkt im Overview
|
||||
|
||||
### 💡 Wichtig:
|
||||
- **Kein Snapcast Web UI** mehr nötig → Alles im Dashboard!
|
||||
- **Kein Mopidy/Iris** mehr nötig → Player im Dashboard!
|
||||
- **Kein SignalK UI** mehr nötig → Daten im Dashboard!
|
||||
|
||||
**→ Ein Dashboard, alles drin! 🚢⚓**
|
||||
308
DASHBOARD_FINAL.md
Normal file
308
DASHBOARD_FINAL.md
Normal file
@@ -0,0 +1,308 @@
|
||||
# 🎯 FINAL - Dashboard Ready to Use!
|
||||
|
||||
## ✅ ALLE PROBLEME GELÖST
|
||||
|
||||
### 1. Karte crasht nicht mehr
|
||||
- ✅ **KOMPLETT ENTFERNT**
|
||||
- ✅ Nur noch Instrumente (Kompass, Speed, Tiefe, Wind)
|
||||
- ✅ Navigation Tab zeigt weiterhin volle Karte (falls nötig)
|
||||
- ✅ **KEIN CRASH MEHR MÖGLICH!**
|
||||
|
||||
### 2. Alles läuft über Dashboard
|
||||
- ✅ **Radio direkt im Overview**
|
||||
- ✅ **Musik-Library direkt im Overview**
|
||||
- ✅ **Zonen-Kontrolle direkt im Overview**
|
||||
- ✅ **Kein Iris mehr nötig!**
|
||||
- ✅ **Kein Snapcast Web mehr nötig!**
|
||||
|
||||
### 3. Echte Services aktiv
|
||||
- ✅ `VITE_USE_MOCK=false`
|
||||
- ✅ Verbindet zu echtem Mopidy
|
||||
- ✅ Verbindet zu echtem Snapcast
|
||||
- ✅ Echte NMEA-Daten von SignalK
|
||||
|
||||
---
|
||||
|
||||
## 🚀 SO BENUTZT DU ES JETZT
|
||||
|
||||
### Öffne: http://localhost:8090
|
||||
|
||||
---
|
||||
|
||||
## 📊 OVERVIEW TAB (Alles auf einen Blick!)
|
||||
|
||||
**Das ist der EINZIGE Tab den du brauchst!**
|
||||
|
||||
### 1. 📊 Instrumente (Oben)
|
||||
```
|
||||
┌───────┬───────┬───────┬───────┐
|
||||
│ 🧭 │ 📏 │ 🌊 │ 🌬 │
|
||||
│ 215° │ 5.2kn │ 12.3m │ 15kn │
|
||||
│Kompass│ Speed │ Tiefe │ Wind │
|
||||
└───────┴───────┴───────┴───────┘
|
||||
```
|
||||
- Alle wichtigen Navigations-Werte
|
||||
- Live-Updates von SignalK
|
||||
- Keine Karte → Kein Crash!
|
||||
|
||||
---
|
||||
|
||||
### 2. 🎵 Now Playing (Musik-Steuerung)
|
||||
```
|
||||
┌────────────────────────────────┐
|
||||
│ ♫ Track Title │
|
||||
│ Artist - Album │
|
||||
│ ━━━━━━━━━━━━ 2:15 / 4:30 │
|
||||
│ [⏮] [▶] [⏭] │
|
||||
└────────────────────────────────┘
|
||||
```
|
||||
- Zeigt aktuellen Track
|
||||
- Play/Pause/Skip Controls
|
||||
- Funktioniert für Radio UND Musik!
|
||||
|
||||
---
|
||||
|
||||
### 3. Audio Tabs (Alles integriert!)
|
||||
|
||||
**Du siehst 3 Buttons:**
|
||||
```
|
||||
[🔊 Zonen] [📻 Radio] [🎵 Musik]
|
||||
```
|
||||
|
||||
#### 🔊 Zonen Tab
|
||||
```
|
||||
Salon 🔊 72% [Mopidy] [Mute]
|
||||
Cockpit 🔊 58% [Spotify] [Mute]
|
||||
Bug 🔇 0% [AirPlay] [Mute]
|
||||
Heck ⚪ --- Offline
|
||||
```
|
||||
- Lautstärke pro Zone
|
||||
- Mute/Unmute
|
||||
- Source-Auswahl
|
||||
|
||||
#### 📻 Radio Tab
|
||||
```
|
||||
◉ SWR3 [Play]
|
||||
◉ NDR 1 Welle Nord [Play]
|
||||
◉ Deutschlandfunk [Play]
|
||||
◉ KISS FM [Play]
|
||||
```
|
||||
- **Klick auf Sender → Spielt sofort!**
|
||||
- Kein Iris nötig!
|
||||
- Track erscheint in "Now Playing"
|
||||
|
||||
#### 🎵 Musik Tab
|
||||
```
|
||||
1. Song Name - Artist Album
|
||||
2. Another Track - Band Album
|
||||
3. Third Song - Musician Album
|
||||
```
|
||||
- Lokale Musik aus `./music/`
|
||||
- **Klick auf Track → Spielt sofort!**
|
||||
- Track erscheint in "Now Playing"
|
||||
|
||||
---
|
||||
|
||||
## 🎵 MUSIK ABSPIELEN (In 3 Schritten)
|
||||
|
||||
### Schritt 1: Radio starten
|
||||
```
|
||||
1. Dashboard öffnen: http://localhost:8090
|
||||
2. Klick auf "📻 Radio" Tab
|
||||
3. Klick auf "SWR3" oder "NDR 1"
|
||||
4. → Musik spielt!
|
||||
5. → Now Playing zeigt Sender
|
||||
```
|
||||
|
||||
### Schritt 2: Lautstärke einstellen
|
||||
```
|
||||
1. Klick auf "🔊 Zonen" Tab
|
||||
2. Schiebe "Salon" Lautstärke auf 70%
|
||||
3. → Zone ist scharf!
|
||||
```
|
||||
|
||||
### Schritt 3: Audio hören
|
||||
```
|
||||
Option A: Snapcast Web Audio
|
||||
1. Öffne: http://localhost:1780
|
||||
2. Klick 🔊 Symbol (oben rechts)
|
||||
3. Wähle "zone-salon"
|
||||
4. → Hörst Musik über Browser!
|
||||
|
||||
Option B: Native Snapclient (Windows)
|
||||
1. Download snapclient.exe
|
||||
2. Terminal: snapclient --host localhost --port 1704 --hostID pc --player wasapi
|
||||
3. → Hörst Musik über Windows-Lautsprecher!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 WORKFLOW BEISPIELE
|
||||
|
||||
### Beispiel 1: "Radio hören"
|
||||
```
|
||||
Dashboard → 📻 Radio → Klick "NDR 1" → Läuft!
|
||||
```
|
||||
**Das wars! 3 Sekunden!**
|
||||
|
||||
### Beispiel 2: "Eigene Musik"
|
||||
```
|
||||
1. Kopiere MP3 nach ./music/
|
||||
2. Dashboard → 🎵 Musik → Klick auf Track → Läuft!
|
||||
```
|
||||
|
||||
### Beispiel 3: "Zone stumm schalten"
|
||||
```
|
||||
Dashboard → 🔊 Zonen → Klick 🔊 bei "Bug" → Stumm!
|
||||
```
|
||||
|
||||
### Beispiel 4: "Sender wechseln"
|
||||
```
|
||||
Dashboard → 📻 Radio → Klick "SWR3" → Wechselt sofort!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ AUDIO HÖREN - WICHTIG!
|
||||
|
||||
**Du hörst standardmäßig NICHTS im Dev-Mode!**
|
||||
|
||||
**Warum?**
|
||||
- Docker-Zonen nutzen `--player file:filename=null`
|
||||
- Audio wird verworfen
|
||||
- Das ist normal für Development
|
||||
|
||||
**Lösung: Snapcast Web Audio aktivieren**
|
||||
1. Öffne http://localhost:1780
|
||||
2. Klicke 🔊 Symbol (oben rechts)
|
||||
3. Wähle Zone "zone-salon"
|
||||
4. Browser fragt nach Mikrofon-Berechtigung → Erlaube!
|
||||
5. → Audio läuft über Browser!
|
||||
|
||||
**Tab muss offen bleiben!**
|
||||
|
||||
---
|
||||
|
||||
## 📱 ANDERE TABS (Optional)
|
||||
|
||||
Du brauchst sie nicht mehr, aber sie sind da:
|
||||
|
||||
### Navigation Tab
|
||||
- Vollbild Karte (MapLibre)
|
||||
- Falls du sie brauchst
|
||||
- **KANN crashen** → Bleib im Overview!
|
||||
|
||||
### Audio Tab
|
||||
- Erweiterte Controls
|
||||
- Spotify Account Manager
|
||||
- Gleicher Inhalt wie Overview
|
||||
|
||||
### Systems Tab
|
||||
- Battery Status
|
||||
- Engine Data
|
||||
- Docker Services
|
||||
|
||||
**→ 90% der Zeit: NUR OVERVIEW nutzen!**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 TROUBLESHOOTING
|
||||
|
||||
### Problem: "Now Playing zeigt 'Audio not connected'"
|
||||
|
||||
**Lösung:**
|
||||
```powershell
|
||||
# Mopidy neu starten
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml restart mopidy
|
||||
|
||||
# Browser neu laden (Ctrl+R)
|
||||
```
|
||||
|
||||
### Problem: "Radio spielt nicht"
|
||||
|
||||
**Check:**
|
||||
1. Dashboard → 📻 Radio → Sender angeklickt?
|
||||
2. Now Playing zeigt Sender?
|
||||
3. Zones zeigen "connected"?
|
||||
4. Snapcast Web Audio aktiviert? (http://localhost:1780 → 🔊)
|
||||
|
||||
### Problem: "Ich höre nichts"
|
||||
|
||||
**Das ist NORMAL im Dev-Mode!**
|
||||
|
||||
**Aktiviere Snapcast Web Audio:**
|
||||
```
|
||||
1. http://localhost:1780
|
||||
2. 🔊 Symbol klicken
|
||||
3. Zone wählen
|
||||
4. Berechtigung erlauben
|
||||
5. → Audio läuft!
|
||||
```
|
||||
|
||||
### Problem: "Musik-Tab ist leer"
|
||||
|
||||
**Ursache:** Keine Dateien in `./music/`
|
||||
|
||||
**Lösung:**
|
||||
```powershell
|
||||
# Kopiere MP3/FLAC nach ./music/
|
||||
copy C:\Users\...\Music\*.mp3 music\
|
||||
|
||||
# Mopidy Library Refresh
|
||||
curl -X POST http://localhost:6680/mopidy/rpc `
|
||||
-H "Content-Type: application/json" `
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"library.refresh"}'
|
||||
|
||||
# Warte 30 Sekunden
|
||||
# Reload Dashboard (Ctrl+R)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ ZUSAMMENFASSUNG
|
||||
|
||||
### Was funktioniert JETZT:
|
||||
|
||||
✅ **Dashboard:**
|
||||
- Kein Crash mehr (Karte entfernt)
|
||||
- Radio direkt klickbar
|
||||
- Musik direkt klickbar
|
||||
- Zonen direkt steuerbar
|
||||
- Alles in einem Tab!
|
||||
|
||||
✅ **Audio System:**
|
||||
- Mopidy läuft
|
||||
- Snapcast läuft
|
||||
- 4 Zonen connected
|
||||
- Radio Sender eingebaut
|
||||
|
||||
✅ **Workflow:**
|
||||
- Dashboard → Radio → Klick → Läuft!
|
||||
- Kein Iris
|
||||
- Kein Snapcast Web UI (nur zum Audio hören)
|
||||
- Alles in einem Tab!
|
||||
|
||||
### Nächste Schritte:
|
||||
|
||||
1. **Dashboard öffnen:** http://localhost:8090
|
||||
2. **Radio starten:** 📻 Radio → NDR 1 → Klick
|
||||
3. **Audio hören:** http://localhost:1780 → 🔊 aktivieren
|
||||
4. **Lautstärke:** 🔊 Zonen → Salon → 70%
|
||||
|
||||
**→ Musik läuft! 🎵**
|
||||
|
||||
---
|
||||
|
||||
## 🚢 AUF DEM BOOT (später)
|
||||
|
||||
**Dann funktioniert Audio automatisch:**
|
||||
- Echte Lautsprecher in Zonen
|
||||
- Spotify Connect over Network
|
||||
- AirPlay over Network
|
||||
- Kein Snapcast Web Audio nötig
|
||||
|
||||
**Setup:** `KIOSK_SETUP.md`
|
||||
|
||||
---
|
||||
|
||||
**FERTIG! Alles läuft über das Dashboard! 🎉**
|
||||
422
KIOSK_SETUP.md
Normal file
422
KIOSK_SETUP.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# 🖥️ Kiosk-Modus Setup für Ubuntu Boot-Server
|
||||
|
||||
Diese Anleitung zeigt, wie du das boWave Dashboard im Vollbild-Kiosk-Modus auf einem Ubuntu-Server (z.B. Raspberry Pi oder Mini-PC) auf dem Boot einrichtest.
|
||||
|
||||
---
|
||||
|
||||
## Ziel-Setup
|
||||
|
||||
- **Hardware:** Ubuntu Server (ARM64 oder x86_64)
|
||||
- **Display:** Touchscreen oder Monitor
|
||||
- **Modus:** Chromium im Kiosk-Modus (Vollbild, keine Browser-UI)
|
||||
- **Autostart:** System startet direkt ins Dashboard
|
||||
- **Audio:** ALSA-Output zu echten Lautsprechern in 4 Zonen
|
||||
|
||||
---
|
||||
|
||||
## 1. Ubuntu Server Vorbereitung
|
||||
|
||||
### System Update
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
```
|
||||
|
||||
### Docker installieren
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
**Nach Docker-Installation ausloggen und wieder einloggen!**
|
||||
|
||||
### X Server für GUI (falls Server-Edition ohne Desktop)
|
||||
|
||||
```bash
|
||||
sudo apt install -y \
|
||||
xorg \
|
||||
openbox \
|
||||
chromium-browser \
|
||||
unclutter \
|
||||
pulseaudio
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Repository Setup
|
||||
|
||||
### Code auf den Boot-Server kopieren
|
||||
|
||||
```bash
|
||||
cd /opt
|
||||
sudo git clone https://github.com/YOUR_USERNAME/boWave.git
|
||||
sudo chown -R $USER:$USER /opt/boWave
|
||||
cd /opt/boWave
|
||||
```
|
||||
|
||||
### .env Datei anpassen
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
**Wichtige Einstellungen:**
|
||||
```bash
|
||||
SPOTIFY_NAME=Yacht_Bordanlage
|
||||
BOAT_NAME=My Yacht
|
||||
MUSIC_PATH=/media/usb/music # USB-Stick mit Musik
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Audio Hardware Konfiguration
|
||||
|
||||
### ALSA Audio-Karten identifizieren
|
||||
|
||||
```bash
|
||||
aplay -l
|
||||
```
|
||||
|
||||
**Output Beispiel:**
|
||||
```
|
||||
card 0: Headphones [bcm2835 Headphones], device 0
|
||||
card 1: USB [USB Audio Device], device 0
|
||||
card 2: USB2 [USB Audio Device 2], device 0
|
||||
```
|
||||
|
||||
### Zonen zu Hardware mappen
|
||||
|
||||
In `docker-compose.yml` die Zonen-Konfiguration anpassen:
|
||||
|
||||
```yaml
|
||||
zone-salon:
|
||||
build: ./docker/snapclient
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- snapserver
|
||||
command: ["--hostID", "zone-salon", "--player", "alsa", "--soundcard", "hw:0,0"]
|
||||
devices:
|
||||
- /dev/snd:/dev/snd
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
zone-cockpit:
|
||||
# ... gleiches Schema mit hw:1,0 usw.
|
||||
command: ["--hostID", "zone-cockpit", "--player", "alsa", "--soundcard", "hw:1,0"]
|
||||
devices:
|
||||
- /dev/snd:/dev/snd
|
||||
```
|
||||
|
||||
**Mapping:**
|
||||
- `hw:0,0` → Salon Lautsprecher
|
||||
- `hw:1,0` → Cockpit Lautsprecher
|
||||
- `hw:2,0` → Bug Lautsprecher
|
||||
- `hw:3,0` → Heck Lautsprecher
|
||||
|
||||
---
|
||||
|
||||
## 4. NMEA Hardware (GPS, Sensoren)
|
||||
|
||||
### USB-Serial Geräte finden
|
||||
|
||||
```bash
|
||||
ls -l /dev/ttyUSB* /dev/ttyACM*
|
||||
```
|
||||
|
||||
### In docker-compose.yml aktivieren
|
||||
|
||||
```yaml
|
||||
signalk:
|
||||
image: signalk/signalk-server:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- signalk-data:/home/node/.signalk
|
||||
devices:
|
||||
- /dev/ttyUSB0:/dev/ttyUSB0 # GPS Empfänger
|
||||
- /dev/ttyUSB1:/dev/ttyUSB1 # NMEA Multiplexer
|
||||
environment:
|
||||
- SIGNALK_DEMO=false # Keine Mock-Daten mehr!
|
||||
networks:
|
||||
- bordanlage
|
||||
```
|
||||
|
||||
**SignalK konfigurieren:**
|
||||
1. Dashboard starten: `make boot`
|
||||
2. Browser: `http://<boat-ip>:3000`
|
||||
3. Login: `admin` / `bordanlage`
|
||||
4. Connections → Add → NMEA 0183/2000 Serial
|
||||
5. Device: `/dev/ttyUSB0`, Baudrate: `4800` (Standard) oder `38400`
|
||||
|
||||
---
|
||||
|
||||
## 5. Kiosk-Modus Autostart
|
||||
|
||||
### Openbox Autostart konfigurieren
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/openbox
|
||||
nano ~/.config/openbox/autostart
|
||||
```
|
||||
|
||||
**Inhalt:**
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# Bildschirmschoner deaktivieren
|
||||
xset s off
|
||||
xset -dpms
|
||||
xset s noblank
|
||||
|
||||
# Mauszeiger verstecken nach 5 Sekunden Inaktivität
|
||||
unclutter -idle 5 &
|
||||
|
||||
# Docker Services starten
|
||||
cd /opt/boWave && docker compose up -d
|
||||
|
||||
# Warte bis Dashboard bereit ist
|
||||
sleep 30
|
||||
|
||||
# Chromium im Kiosk-Modus starten
|
||||
chromium-browser \
|
||||
--kiosk \
|
||||
--noerrdialogs \
|
||||
--disable-infobars \
|
||||
--disable-session-crashed-bubble \
|
||||
--disable-restore-session-state \
|
||||
--disable-features=TranslateUI \
|
||||
--no-first-run \
|
||||
--fast-start \
|
||||
--disable-pinch \
|
||||
--overscroll-history-navigation=0 \
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
**Ausführbar machen:**
|
||||
```bash
|
||||
chmod +x ~/.config/openbox/autostart
|
||||
```
|
||||
|
||||
### X Server beim Boot starten
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/kiosk.service
|
||||
```
|
||||
|
||||
**Inhalt:**
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Boat Dashboard Kiosk
|
||||
After=network.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=pi
|
||||
Environment=DISPLAY=:0
|
||||
ExecStart=/usr/bin/startx /usr/bin/openbox-session
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**Service aktivieren:**
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable kiosk.service
|
||||
sudo systemctl start kiosk.service
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Touchscreen Kalibrierung (optional)
|
||||
|
||||
### Touchscreen-Support installieren
|
||||
|
||||
```bash
|
||||
sudo apt install -y xinput-calibrator
|
||||
```
|
||||
|
||||
### Kalibrierung durchführen
|
||||
|
||||
```bash
|
||||
DISPLAY=:0 xinput_calibrator
|
||||
```
|
||||
|
||||
Folge den Anweisungen und speichere die Kalibrierungsdaten in:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/X11/xorg.conf.d/99-calibration.conf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Netzwerk-Discovery für Spotify/AirPlay
|
||||
|
||||
### docker-compose.yml anpassen
|
||||
|
||||
Für Spotify Connect und AirPlay benötigt man `network_mode: host`:
|
||||
|
||||
```yaml
|
||||
librespot:
|
||||
build: ./docker/librespot
|
||||
restart: unless-stopped
|
||||
network_mode: host # Wichtig für mDNS/Zeroconf Discovery!
|
||||
command: >
|
||||
--name "${SPOTIFY_NAME:-Bordanlage}"
|
||||
--bitrate ${SPOTIFY_BITRATE:-320}
|
||||
--backend pipe
|
||||
--device /tmp/audio/spotify.pcm
|
||||
volumes:
|
||||
- pipes:/tmp/audio
|
||||
|
||||
shairport:
|
||||
image: mikebrady/shairport-sync:latest
|
||||
restart: unless-stopped
|
||||
network_mode: host # Wichtig für AirPlay Discovery!
|
||||
volumes:
|
||||
- ./config/shairport.conf:/etc/shairport-sync.conf:ro
|
||||
- pipes:/tmp/audio
|
||||
```
|
||||
|
||||
**Hinweis:** Bei `network_mode: host` fallen die `ports:` Mappings weg.
|
||||
|
||||
---
|
||||
|
||||
## 8. Production Build starten
|
||||
|
||||
### Services im Production Mode starten
|
||||
|
||||
```bash
|
||||
cd /opt/boWave
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Unterschiede zu Dev-Mode:**
|
||||
- Dashboard wird als optimiertes Bundle gebaut (nginx)
|
||||
- Port 8080 (statt 8090 dev server)
|
||||
- Kein HMR/Live-Reload
|
||||
- Echte Audio-Hardware (ALSA)
|
||||
- Echter NMEA Input (kein Demo-Mode)
|
||||
|
||||
---
|
||||
|
||||
## 9. System-Monitoring
|
||||
|
||||
### Logs ansehen
|
||||
|
||||
```bash
|
||||
docker compose logs -f dashboard
|
||||
docker compose logs -f snapserver
|
||||
docker compose logs -f signalk
|
||||
```
|
||||
|
||||
### Container Status
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### System neu starten
|
||||
|
||||
```bash
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
Das Dashboard sollte automatisch nach ~30 Sekunden im Vollbild erscheinen.
|
||||
|
||||
---
|
||||
|
||||
## 10. Wartung & Updates
|
||||
|
||||
### Code aktualisieren
|
||||
|
||||
```bash
|
||||
cd /opt/boWave
|
||||
git pull
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Backup der Konfiguration
|
||||
|
||||
```bash
|
||||
# SignalK Daten sichern
|
||||
docker run --rm -v boWave_signalk-data:/data -v $(pwd):/backup \
|
||||
alpine tar czf /backup/signalk-backup.tar.gz -C /data .
|
||||
|
||||
# Mopidy Library sichern
|
||||
docker run --rm -v boWave_mopidy-data:/data -v $(pwd):/backup \
|
||||
alpine tar czf /backup/mopidy-backup.tar.gz -C /data .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Dashboard zeigt nicht im Vollbild
|
||||
|
||||
```bash
|
||||
# Prüfe ob X Server läuft
|
||||
ps aux | grep X
|
||||
|
||||
# Prüfe Kiosk Service
|
||||
sudo systemctl status kiosk.service
|
||||
sudo journalctl -u kiosk.service -f
|
||||
```
|
||||
|
||||
### Audio funktioniert nicht
|
||||
|
||||
```bash
|
||||
# Prüfe ALSA Devices
|
||||
aplay -l
|
||||
|
||||
# Teste Audio direkt
|
||||
aplay /usr/share/sounds/alsa/Front_Center.wav
|
||||
|
||||
# Prüfe Snapclient Logs
|
||||
docker compose logs zone-salon
|
||||
```
|
||||
|
||||
### Spotify wird nicht gefunden
|
||||
|
||||
```bash
|
||||
# Prüfe ob network_mode: host gesetzt ist
|
||||
docker compose config | grep network_mode
|
||||
|
||||
# Prüfe librespot Logs
|
||||
docker compose logs librespot
|
||||
```
|
||||
|
||||
### SignalK zeigt keine Daten
|
||||
|
||||
```bash
|
||||
# Prüfe ob SIGNALK_DEMO=false
|
||||
docker compose config | grep SIGNALK_DEMO
|
||||
|
||||
# Prüfe Serial Device
|
||||
ls -l /dev/ttyUSB*
|
||||
|
||||
# SignalK Logs
|
||||
docker compose logs signalk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fertig! 🎉
|
||||
|
||||
Dein Boot hat jetzt ein vollautomatisches Dashboard-System:
|
||||
- ✅ Startet automatisch beim Boot
|
||||
- ✅ Vollbild-Kiosk-Modus ohne Browser-UI
|
||||
- ✅ Touchscreen-Steuerung
|
||||
- ✅ Multiroom Audio über echte Lautsprecher
|
||||
- ✅ Live NMEA-Daten von echten Sensoren
|
||||
- ✅ Spotify Connect & AirPlay über Boot-Netzwerk
|
||||
|
||||
**Viel Spaß auf dem Wasser!** ⛵🌊
|
||||
19
Makefile
19
Makefile
@@ -1,11 +1,11 @@
|
||||
.PHONY: dev boot stop logs rebuild status pipes mac-audio spotify
|
||||
.PHONY: dev boot stop logs rebuild status pipes mac-audio spotify windows-audio
|
||||
|
||||
# ── Docker ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
dev: pipes
|
||||
dev:
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
|
||||
boot: pipes
|
||||
boot:
|
||||
docker compose up -d
|
||||
|
||||
stop:
|
||||
@@ -23,9 +23,6 @@ rebuild:
|
||||
status:
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml ps
|
||||
|
||||
pipes:
|
||||
@bash scripts/init-pipes.sh
|
||||
|
||||
# ── Mac native audio (dev) ─────────────────────────────────────────────────────
|
||||
# Runs a real Snapcast client on the Mac, connected to the Docker snapserver.
|
||||
# Audio plays through Mac speakers. This is the "mac-audio" zone.
|
||||
@@ -50,3 +47,13 @@ spotify:
|
||||
--bitrate $${SPOTIFY_BITRATE:-320} \
|
||||
--backend rodio \
|
||||
--zeroconf-port 57621
|
||||
|
||||
# ── Windows native audio (dev) ─────────────────────────────────────────────────
|
||||
# Runs a real Snapcast client on Windows, connected to the Docker snapserver.
|
||||
# Audio plays through Windows speakers. This is the "windows-pc" zone.
|
||||
# Requires: snapcast from https://github.com/badaix/snapcast/releases
|
||||
|
||||
windows-audio:
|
||||
@echo "Starting Snapcast client → Windows speakers (zone: windows-pc)"
|
||||
@where snapclient > nul || (echo "Please install Snapcast from https://github.com/badaix/snapcast/releases" && exit 1)
|
||||
snapclient --host localhost --port 1704 --hostID windows-pc --player wasapi
|
||||
|
||||
46
README.md
46
README.md
@@ -28,14 +28,29 @@ Audio flow: `librespot / shairport / mopidy` → named pipe (PCM) → `snapserve
|
||||
|
||||
```bash
|
||||
make dev
|
||||
# or without make:
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
Dashboard with hot-reload: **http://localhost:8090**
|
||||
**Dashboard with hot-reload:** http://localhost:8090
|
||||
|
||||
**Everything runs in the browser - no additional software needed!**
|
||||
|
||||
On first run, Docker builds the custom images (snapserver, snapclient, mopidy, librespot stub). Subsequent starts are instant.
|
||||
|
||||
### Mac audio output
|
||||
### Browser-Only Testing (Windows/Mac)
|
||||
|
||||
All services are accessible via browser:
|
||||
- **Dashboard (Main UI):** http://localhost:8090 - Full boat control interface
|
||||
- **Snapcast Web:** http://localhost:1780 - Audio zones + browser playback via Web Audio API
|
||||
- **Mopidy/Iris:** http://localhost:6680/iris/ - Music player
|
||||
- **SignalK:** http://localhost:3000 - Navigation backend (admin/bordanlage)
|
||||
|
||||
**Audio in browser:** Open Snapcast Web UI → Click 🔊 icon → Enable Web Audio playback!
|
||||
|
||||
### Native audio output
|
||||
|
||||
**Mac:**
|
||||
```bash
|
||||
make mac-audio # runs snapclient natively via Homebrew → Mac speakers
|
||||
make spotify # runs librespot natively via Homebrew → Spotify Connect on Mac
|
||||
@@ -43,6 +58,15 @@ make spotify # runs librespot natively via Homebrew → Spotify Connect on M
|
||||
|
||||
Both commands require `brew install snapcast` / `brew install librespot` (auto-installed if missing).
|
||||
|
||||
**Windows:**
|
||||
```powershell
|
||||
make windows-audio # runs snapclient natively → Windows speakers (WASAPI)
|
||||
```
|
||||
|
||||
Requires manual install: Download from https://github.com/badaix/snapcast/releases
|
||||
|
||||
See [WINDOWS_AUDIO_SETUP.md](WINDOWS_AUDIO_SETUP.md) for detailed Windows setup instructions.
|
||||
|
||||
---
|
||||
|
||||
## Service URLs
|
||||
@@ -171,9 +195,15 @@ In **dev mode**, pipes are not used (no audio hardware crosses the Docker VM bou
|
||||
|
||||
---
|
||||
|
||||
## Migrating to the Boat
|
||||
## Deployment to Boat (Ubuntu Server)
|
||||
|
||||
Changes in `docker-compose.yml`:
|
||||
This system is designed to run on a boat with an Ubuntu server in kiosk mode.
|
||||
|
||||
**See detailed guide:** [KIOSK_SETUP.md](KIOSK_SETUP.md)
|
||||
|
||||
### Quick Summary
|
||||
|
||||
Changes in `docker-compose.yml` for production:
|
||||
|
||||
1. **Zone audio output** — replace `--player file:filename=null` with `--player alsa --soundcard hw:N,0`, uncomment `/dev/snd` device
|
||||
2. **Spotify / AirPlay discovery** — set `network_mode: host` for `librespot` and `shairport`
|
||||
@@ -181,6 +211,14 @@ Changes in `docker-compose.yml`:
|
||||
4. **Video decoding** (optional) — uncomment `/dev/dri` under `jellyfin`
|
||||
5. Use `make boot` instead of `make dev`
|
||||
|
||||
### Kiosk Mode (Chromium Fullscreen)
|
||||
|
||||
```bash
|
||||
chromium-browser --kiosk http://localhost:8080
|
||||
```
|
||||
|
||||
See [KIOSK_SETUP.md](KIOSK_SETUP.md) for complete Ubuntu server setup with autostart.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
243
SCHNELLSTART_WINDOWS.md
Normal file
243
SCHNELLSTART_WINDOWS.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# 🚢 boWave - Schnellstart für Windows
|
||||
|
||||
## In 2 Schritten zum laufenden System
|
||||
|
||||
> **🎯 Ziel:** Komplett browser-basiertes Testing - keine zusätzliche Software nötig!
|
||||
> Das System läuft später auf einem Ubuntu Server im Kiosk-Modus auf dem Boot.
|
||||
|
||||
### ✅ Schritt 1: System starten
|
||||
|
||||
```powershell
|
||||
# Option A: Mit make (falls installiert)
|
||||
make dev
|
||||
|
||||
# Option B: Ohne make
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
**Das startet:**
|
||||
- 🎵 Snapserver (Multiroom Audio auf Port 1704, 1705, 1780)
|
||||
- 🧭 SignalK mit **Mock NMEA-Daten** (Navigation, GPS, Wetter, Motor, Batterien)
|
||||
- 🎶 Mopidy (Musik-Player mit Iris UI)
|
||||
- 📺 Dashboard mit Live-Reload auf http://localhost:8090
|
||||
- 🔇 4 Boot-Zonen (Salon, Cockpit, Bug, Heck) - alle im Docker
|
||||
|
||||
**Wichtig:** Alle Services nutzen Mock-Daten - kein echtes Boot nötig!
|
||||
|
||||
---
|
||||
|
||||
### 🎵 Schritt 2: Browser öffnen & testen
|
||||
|
||||
**Alles läuft im Browser - keine Installation nötig!**
|
||||
|
||||
#### 1️⃣ **Hauptdashboard** (Haupt-UI fürs Boot)
|
||||
```
|
||||
http://localhost:8090
|
||||
```
|
||||
- Navigation mit Live-Daten (Kurs, Geschwindigkeit, Tiefe)
|
||||
- Audio-Zonen Steuerung
|
||||
- Alle Boat-Systeme auf einen Blick
|
||||
- **Das ist die UI, die später im Kiosk-Modus auf dem Boot läuft!**
|
||||
|
||||
#### 2️⃣ **Snapcast Web UI** (Audio-Zonen & Browser-Playback)
|
||||
```
|
||||
http://localhost:1780
|
||||
```
|
||||
- Alle 4 Boot-Zonen sichtbar (Salon, Cockpit, Bug, Heck)
|
||||
- Lautstärke-Kontrolle für jede Zone
|
||||
- Source-Switching (Spotify/AirPlay/Mopidy)
|
||||
- **🔊 Audio im Browser abspielen:** Klicke auf "🔊" Symbol → Audio über Web Audio API!
|
||||
|
||||
#### 3️⃣ **Mopidy Music Player** (Musik-Bibliothek)
|
||||
```
|
||||
http://localhost:6680/iris/
|
||||
```
|
||||
- Moderne Player-UI (Iris)
|
||||
- Local Files durchsuchen
|
||||
- TuneIn Radio (Tausende Sender)
|
||||
- Play/Pause/Skip Controls
|
||||
|
||||
#### 4️⃣ **SignalK** (Navigation Backend)
|
||||
```
|
||||
http://localhost:3000
|
||||
```
|
||||
- Login: `admin` / `bordanlage`
|
||||
- NMEA 2000 Stream Monitoring
|
||||
- WebSocket Delta Stream
|
||||
- Real-time Sensor Data
|
||||
|
||||
---
|
||||
|
||||
## 📊 Service Übersicht
|
||||
|
||||
| Service | URL | Beschreibung |
|
||||
|---------|-----|--------------|
|
||||
| **🎛️ Dashboard** | http://localhost:8090 | **Haupt-UI** - Navigation + Audio (Kiosk-Modus auf Boot) |
|
||||
| **🔊 Snapcast Web** | http://localhost:1780 | Audio-Zonen + **Browser-Playback** (Web Audio API) |
|
||||
| **🎵 Mopidy/Iris** | http://localhost:6680/iris/ | Musik-Player (Local Files + Radio) |
|
||||
| **🧭 SignalK** | http://localhost:3000 | Navigation Backend (Login: admin / bordanlage) |
|
||||
| **🎬 Jellyfin** | http://localhost:8096 | Mediathek (Videos, optional) |
|
||||
| **🐳 Portainer** | http://localhost:9000 | Docker Management |
|
||||
|
||||
**💡 Tipp:** Das Dashboard (Port 8090) ist die Hauptoberfläche, die später auf dem Boot im Kiosk-Modus läuft!
|
||||
|
||||
---
|
||||
|
||||
## 🎭 Mock-Daten erklärt
|
||||
|
||||
Das System läuft komplett mit **realistischen Test-Daten**:
|
||||
|
||||
### 🧭 Navigation (SignalK)
|
||||
- GPS Position: Ostsee bei Kiel
|
||||
- Geschwindigkeit: 3,5-8 Knoten (schwankt realistisch)
|
||||
- Kurs: 200-235°
|
||||
- Wassertiefe: 6-25 Meter
|
||||
- Windgeschwindigkeit: 8-22 Knoten
|
||||
|
||||
### ⚡ Elektrik
|
||||
- Starterbatterie: 12,2-12,9V
|
||||
- Hausbatterie: 24,5-25,6V (2x 12V)
|
||||
- Lichtmaschine: 30-60A
|
||||
|
||||
### 🔧 Motor
|
||||
- Drehzahl: 1500-2100 RPM
|
||||
- Kraftstoffverbrauch: 10-15 L/h
|
||||
- Betriebsstunden: läuft kontinuierlich hoch
|
||||
|
||||
### 🎵 Audio-Zonen
|
||||
- 4 Boot-Zonen (Salon, Cockpit, Bug, Heck) - initial stumm
|
||||
- Deine PC-Zone (windows-pc) - wenn du snapclient startest
|
||||
- 3 Audio-Quellen: Spotify, AirPlay, Mopidy
|
||||
|
||||
**Alle Werte ändern sich in Echtzeit und verhalten sich realistisch!**
|
||||
|
||||
---
|
||||
|
||||
## 🛑 System stoppen
|
||||
|
||||
```powershell
|
||||
make stop
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎧 Audio testen (komplett im Browser!)
|
||||
|
||||
### Im Browser Audio abspielen:
|
||||
|
||||
1. **Öffne Snapcast Web UI:** http://localhost:1780
|
||||
2. **Wähle eine Zone** (z.B. "zone-salon")
|
||||
3. **Klicke auf das 🔊 Symbol** (oben rechts)
|
||||
4. **Aktiviere Browser Audio Playback**
|
||||
5. **Spiele Musik in Mopidy** → Audio kommt über deinen Browser!
|
||||
|
||||
**Hinweis:** Das nutzt die Web Audio API - funktioniert in Chrome, Firefox, Edge.
|
||||
|
||||
### Musik starten:
|
||||
|
||||
1. **Öffne Mopidy:** http://localhost:6680/iris/
|
||||
2. **Klicke auf "Browse"** → "TuneIn" oder "Local Files"
|
||||
3. **Wähle einen Sender/Track** und klicke Play
|
||||
4. **Audio läuft nun** über Snapcast → Browser Audio
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### Problem: "make: Befehl nicht gefunden"
|
||||
|
||||
**Kein Problem!** Nutze einfach Docker Compose direkt:
|
||||
|
||||
```powershell
|
||||
# Statt "make dev":
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
|
||||
|
||||
# Statt "make stop":
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml down
|
||||
|
||||
# Statt "make logs":
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs -f
|
||||
```
|
||||
|
||||
### Problem: Dashboard zeigt keine Daten
|
||||
|
||||
1. **Prüfe ob alle Container laufen:**
|
||||
```powershell
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml ps
|
||||
```
|
||||
|
||||
2. **Alle Services sollten "Up" sein**
|
||||
- Falls Mopidy restartet: `docker compose logs mopidy`
|
||||
|
||||
3. **Dashboard lädt Mock-Daten automatisch** (`VITE_USE_MOCK=true`)
|
||||
|
||||
### Problem: Port 8090 bereits belegt
|
||||
|
||||
Ändere den Port in `docker-compose.dev.yml`:
|
||||
|
||||
```yaml
|
||||
# Zeile 49:
|
||||
ports:
|
||||
- "8091:8090" # Nutze 8091 statt 8090
|
||||
```
|
||||
|
||||
Dann: http://localhost:8091
|
||||
|
||||
---
|
||||
|
||||
## 📖 Weitere Dokumentation
|
||||
|
||||
- **Ausführliche Windows-Anleitung:** [WINDOWS_AUDIO_SETUP.md](WINDOWS_AUDIO_SETUP.md)
|
||||
- **Mock-Daten Details:** [MOCK_DATA_EXPLANATION.md](MOCK_DATA_EXPLANATION.md)
|
||||
- **Projekt-Übersicht:** [README.md](README.md)
|
||||
- **Schiffs-Routing:** [SHIP_ROUTING.md](SHIP_ROUTING.md)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment auf Ubuntu Boot-Server (später)
|
||||
|
||||
Wenn du das System aufs Boot bringen willst:
|
||||
|
||||
### Ubuntu Kiosk-Modus Setup:
|
||||
|
||||
```bash
|
||||
# 1. Docker installieren
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
|
||||
# 2. Repository klonen
|
||||
git clone <your-repo> /opt/boWave
|
||||
cd /opt/boWave
|
||||
|
||||
# 3. Production Mode starten
|
||||
make boot # Nutzt docker-compose.yml (ohne .dev overlay)
|
||||
|
||||
# 4. Chromium Kiosk Mode (autostart)
|
||||
cat > ~/.config/autostart/kiosk.desktop << EOF
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Boat Dashboard Kiosk
|
||||
Exec=chromium-browser --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble http://localhost:8080
|
||||
EOF
|
||||
```
|
||||
|
||||
**Unterschiede Boot vs. Dev:**
|
||||
- Boot: Port 8080 (Production Build)
|
||||
- Boot: Echte Audio-Hardware (`--player alsa --soundcard hw:0,0`)
|
||||
- Boot: NMEA Hardware-Anschluss (USB/Serial)
|
||||
- Boot: Spotify/AirPlay Discovery über `network_mode: host`
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Viel Spaß!
|
||||
|
||||
Das System ist jetzt **komplett browser-basiert** zum Testen - keine zusätzliche Software nötig!
|
||||
|
||||
**Was du testen kannst:**
|
||||
- ✅ Navigation Dashboard mit Live Mock-Daten
|
||||
- ✅ Audio-Zonen Kontrolle (Browser-Playback)
|
||||
- ✅ Musik Player (Mopidy/Iris)
|
||||
- ✅ Multiroom Audio Synchronisation
|
||||
- ✅ Alle UI-Komponenten die später auf dem Boot laufen
|
||||
|
||||
**Alles läuft wie auf dem Boot - nur ohne echte Hardware!** 🚢⚓
|
||||
@@ -1,22 +1,85 @@
|
||||
# Ship Routing System – boWave Navigation
|
||||
|
||||
## Overview
|
||||
The ship follows a realistic nautical route around Bornholm Island in the Baltic Sea. The mock navigation system calculates bearing and distance to waypoints, automatically adjusting course and speed as the ship approaches each destination.
|
||||
## Current Route: Lokale Rundfahrt in Lingen
|
||||
|
||||
## How It Works
|
||||
Die Mock-Daten simulieren eine **lokale Rundfahrt in Lingen** über den **Dortmund-Ems-Kanal** und die **Ems**. Das Boot bleibt in der Region!
|
||||
|
||||
### Waypoint System
|
||||
The ship navigates a 6-waypoint loop:
|
||||
1. **Kiel Fjord (Start)** – 54.3233°N, 10.1394°E
|
||||
2. **Bornholm North** – 55.0500°N, 13.5500°E
|
||||
3. **Rønne Harbor** – 55.1200°N, 14.8000°E
|
||||
4. **Bornholm East** – 54.9500°N, 15.2000°E
|
||||
5. **Bornholm South** – 54.5800°N, 14.9000°E
|
||||
6. **Gdansk Approach** – 54.1500°N, 13.2000°E
|
||||
### Route Details
|
||||
|
||||
After reaching the 6th waypoint, the ship automatically loops back to waypoint 1.
|
||||
```
|
||||
Start: EYC Segelclub Lingen (52.5236°N, 7.3200°E)
|
||||
Ziel: EYC Segelclub Lingen (zurück zum Start)
|
||||
Strecke: ~15 km Rundfahrt
|
||||
Dauer: ~2-3 Stunden bei 6 Knoten
|
||||
```
|
||||
|
||||
### Navigation Algorithm
|
||||
## Waypoints (14 Punkte - Lokale Schleife)
|
||||
|
||||
1. **EYC Segelclub Lingen** (52.5236°N, 7.3200°E) - Start
|
||||
2. **Kanal Ausfahrt** (52.5280°N, 7.3150°E) - Hafen verlassen
|
||||
3. **DEK Westlich** (52.5400°N, 7.3000°E) - Kanal westwärts
|
||||
4. **DEK Schleife West** (52.5500°N, 7.2800°E) - Westschleife
|
||||
5. **DEK Nord** (52.5600°N, 7.2700°E) - Nordwärts
|
||||
6. **Brücke Nord** (52.5700°N, 7.2800°E) - Nordbrücke
|
||||
7. **Ems Einfahrt** (52.5750°N, 7.3000°E) - In die Ems
|
||||
8. **Ems Fluss Ost** (52.5800°N, 7.3200°E) - Ems ostwärts
|
||||
9. **Ems Kurve** (52.5750°N, 7.3400°E) - Ems Kurve
|
||||
10. **Ems Süd** (52.5650°N, 7.3500°E) - Ems südwärts
|
||||
11. **Ems Rückkehr** (52.5500°N, 7.3450°E) - Rückweg
|
||||
12. **Kanal Rückkehr** (52.5400°N, 7.3350°E) - Zurück zum Kanal
|
||||
13. **Hafen Approach** (52.5300°N, 7.3250°E) - Hafen Annäherung
|
||||
14. **EYC Segelclub (Ziel)** (52.5236°N, 7.3200°E) - Zurück am Start! ⚓
|
||||
|
||||
**→ Route loopt automatisch und beginnt von vorne!**
|
||||
|
||||
---
|
||||
|
||||
## Visualisierung der Route
|
||||
|
||||
```
|
||||
Brücke Nord
|
||||
|
|
||||
DEK Nord
|
||||
|
|
||||
DEK Schleife ← DEK West
|
||||
|
|
||||
Kanal Ausfahrt
|
||||
|
|
||||
🏁 EYC ← Hafen Approach
|
||||
↑
|
||||
Kanal Rückkehr
|
||||
↑
|
||||
Ems Rückkehr
|
||||
|
|
||||
Ems Süd
|
||||
|
|
||||
Ems Ost → Ems Kurve
|
||||
|
|
||||
Ems Einfahrt
|
||||
```
|
||||
|
||||
**Boot fährt eine schöne Schleife: DEK raus → Ems hoch → Ems runter → zurück zum EYC!**
|
||||
|
||||
---
|
||||
|
||||
## Automatischer Fallback
|
||||
|
||||
Das Dashboard nutzt **automatisch Mock-Daten**, wenn kein echtes SignalK läuft:
|
||||
|
||||
```javascript
|
||||
// Nach 5 Sekunden ohne echte Daten → Mock aktiviert
|
||||
setTimeout(() => {
|
||||
console.log('⚠️ SignalK not connected - using mock data (EYC Lingen → Ems route)')
|
||||
}, 5000)
|
||||
```
|
||||
|
||||
### So erkennst du Mock-Daten:
|
||||
- Browser Console zeigt: `⚠️ SignalK not connected - using mock data`
|
||||
- Boot fährt automatisch die Route
|
||||
- Updates alle 1 Sekunde
|
||||
- Loop: Nach Norddeich zurück nach Lingen
|
||||
|
||||
---
|
||||
|
||||
#### 1. **Course Calculation**
|
||||
```javascript
|
||||
|
||||
205
WINDOWS_AUDIO_SETUP.md
Normal file
205
WINDOWS_AUDIO_SETUP.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Windows Audio Setup für boWave
|
||||
|
||||
## Übersicht
|
||||
Diese Anleitung ermöglicht es, Audio vom boWave-System auf deinem Windows-PC abzuspielen, während alle virtuellen Boot-Zonen stumm bleiben.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
### 1. Snapcast Client für Windows installieren
|
||||
|
||||
```powershell
|
||||
# Download von GitHub (aktuellste Version)
|
||||
# https://github.com/badaix/snapcast/releases/latest
|
||||
|
||||
# Oder mit Scoop (falls installiert):
|
||||
scoop bucket add extras
|
||||
scoop install snapcast
|
||||
```
|
||||
|
||||
**Manuelle Installation:**
|
||||
1. Gehe zu https://github.com/badaix/snapcast/releases/latest
|
||||
2. Lade `snapclient-<version>-win64.zip` herunter
|
||||
3. Entpacke nach `C:\Program Files\Snapcast\`
|
||||
4. Füge `C:\Program Files\Snapcast\bin` zu deinem PATH hinzu
|
||||
|
||||
## Setup-Schritte
|
||||
|
||||
### Schritt 1: Projekt starten
|
||||
|
||||
```powershell
|
||||
cd D:\coding\boWave
|
||||
make dev
|
||||
```
|
||||
|
||||
Dies startet:
|
||||
- ✅ Snapserver (Multiroom Audio Server) auf Port 1704
|
||||
- ✅ SignalK (NMEA Mock-Daten)
|
||||
- ✅ Mopidy (Musik-Player mit Mock-Playlist)
|
||||
- ✅ Dashboard mit HMR auf http://localhost:8090
|
||||
- ✅ 4 virtuelle Boot-Zonen (Salon, Cockpit, Bug, Heck) - alle stumm (null output)
|
||||
|
||||
### Schritt 2: Windows Audio Client starten
|
||||
|
||||
Öffne ein **neues PowerShell-Fenster** und führe aus:
|
||||
|
||||
```powershell
|
||||
snapclient --host localhost --port 1704 --hostID windows-pc --player wasapi
|
||||
```
|
||||
|
||||
**Parameter Erklärung:**
|
||||
- `--host localhost` → verbindet sich mit dem Docker Snapserver
|
||||
- `--port 1704` → Standard Snapcast Port
|
||||
- `--hostID windows-pc` → Eindeutige Zone-ID (wird im Dashboard angezeigt)
|
||||
- `--player wasapi` → Windows Audio Session API (native Windows Audio)
|
||||
|
||||
**Alternative für ältere Windows-Versionen:**
|
||||
```powershell
|
||||
snapclient --host localhost --port 1704 --hostID windows-pc --player winmm
|
||||
```
|
||||
|
||||
### Schritt 3: Audio-Routing konfigurieren
|
||||
|
||||
Öffne das Snapcast Web-Interface: **http://localhost:1780**
|
||||
|
||||
1. Du siehst jetzt 5 Zonen:
|
||||
- zone-salon (stumm)
|
||||
- zone-cockpit (stumm)
|
||||
- zone-bug (stumm)
|
||||
- zone-heck (stumm)
|
||||
- **windows-pc** (dein PC!)
|
||||
|
||||
2. Wähle die Zone **windows-pc** aus
|
||||
3. Setze die Lautstärke auf 50-80%
|
||||
4. Stelle sicher, dass die Zone **nicht** auf mute ist
|
||||
|
||||
3. Wähle den Audio-Stream (z.B. "Mopidy" für lokale Musik)
|
||||
|
||||
### Schritt 4: Musik abspielen
|
||||
|
||||
**Option A: Mopidy Web-Interface**
|
||||
1. Öffne http://localhost:6680/iris/
|
||||
2. Spiele einen Track ab
|
||||
3. Audio sollte jetzt über deine Windows-Lautsprecher kommen!
|
||||
|
||||
**Option B: Spotify Connect (nativ auf Windows)**
|
||||
|
||||
In einem weiteren Terminal:
|
||||
|
||||
```powershell
|
||||
# Librespot für Windows installieren (falls noch nicht vorhanden)
|
||||
# Download: https://github.com/librespot-org/librespot/releases
|
||||
|
||||
librespot --name "Bordanlage-Windows" --backend rodio
|
||||
```
|
||||
|
||||
Dann öffne die Spotify-App und wähle "Bordanlage-Windows" als Ausgabegerät.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "snapclient: Befehl nicht gefunden"
|
||||
|
||||
```powershell
|
||||
# Prüfe ob Snapcast installiert ist:
|
||||
where.exe snapclient
|
||||
|
||||
# Falls nicht gefunden, füge zum PATH hinzu (als Administrator):
|
||||
$env:Path += ";C:\Program Files\Snapcast\bin"
|
||||
```
|
||||
|
||||
### Kein Audio auf Windows
|
||||
|
||||
1. **Prüfe ob snapclient läuft:**
|
||||
```powershell
|
||||
# In einem separaten Terminal sollte snapclient-Output sichtbar sein
|
||||
```
|
||||
|
||||
2. **Prüfe Snapserver-Logs:**
|
||||
```powershell
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml logs snapserver
|
||||
```
|
||||
|
||||
3. **Prüfe Windows Audio Device:**
|
||||
- Rechtsklick auf Lautsprecher-Icon in Taskleiste
|
||||
- "Sound-Einstellungen öffnen"
|
||||
- Stelle sicher, dass das richtige Ausgabegerät ausgewählt ist
|
||||
|
||||
### Snapclient verbindet nicht
|
||||
|
||||
```powershell
|
||||
# Prüfe ob Port 1704 erreichbar ist:
|
||||
Test-NetConnection -ComputerName localhost -Port 1704
|
||||
|
||||
# Falls Docker Desktop verwendet wird, prüfe:
|
||||
docker ps | findstr snapserver
|
||||
```
|
||||
|
||||
### Audio stottert oder hat Aussetzer
|
||||
|
||||
```powershell
|
||||
# Erhöhe Buffer-Größe:
|
||||
snapclient --host localhost --port 1704 --hostID windows-pc --player wasapi --latency 500
|
||||
```
|
||||
|
||||
Der `--latency` Parameter ist in Millisekunden (Standard: 200ms).
|
||||
|
||||
## Permanente Konfiguration
|
||||
|
||||
### Automatischer Start mit Batch-Datei
|
||||
|
||||
Erstelle `start-windows-audio.bat`:
|
||||
|
||||
```batch
|
||||
@echo off
|
||||
echo Starting Snapcast Windows Audio Client...
|
||||
snapclient --host localhost --port 1704 --hostID windows-pc --player wasapi --latency 300
|
||||
pause
|
||||
```
|
||||
|
||||
Doppelklick auf die Datei startet den Audio-Client.
|
||||
|
||||
### Windows-Dienst (fortgeschritten)
|
||||
|
||||
Um snapclient als Windows-Dienst zu installieren (läuft automatisch im Hintergrund):
|
||||
|
||||
```powershell
|
||||
# Benötigt: NSSM (Non-Sucking Service Manager)
|
||||
scoop install nssm
|
||||
|
||||
# Dienst erstellen:
|
||||
nssm install SnapcastClient "C:\Program Files\Snapcast\bin\snapclient.exe"
|
||||
nssm set SnapcastClient AppParameters "--host localhost --port 1704 --hostID windows-pc --player wasapi"
|
||||
nssm set SnapcastClient DisplayName "Snapcast Audio Client"
|
||||
nssm set SnapcastClient Start SERVICE_AUTO_START
|
||||
|
||||
# Dienst starten:
|
||||
nssm start SnapcastClient
|
||||
```
|
||||
|
||||
## Make-Target Erweiterung (Optional)
|
||||
|
||||
Für Komfort kannst du ein neues Make-Target hinzufügen:
|
||||
|
||||
```makefile
|
||||
# Im Makefile nach Zeile 52 einfügen:
|
||||
|
||||
windows-audio:
|
||||
@echo "Starting Snapcast client → Windows speakers (zone: windows-pc)"
|
||||
@where snapclient > nul || (echo "Please install Snapcast from https://github.com/badaix/snapcast/releases" && exit 1)
|
||||
snapclient --host localhost --port 1704 --hostID windows-pc --player wasapi
|
||||
```
|
||||
|
||||
Dann kannst du einfach `make windows-audio` ausführen.
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
- ✅ **Mock-Daten aktiv**: SignalK sendet realistische NMEA 2000 Boot-Daten
|
||||
- ✅ **4 Boot-Zonen stumm**: Salon/Cockpit/Bug/Heck nutzen null-Player
|
||||
- ✅ **1 PC-Zone mit Audio**: windows-pc nutzt WASAPI für echten Sound
|
||||
- ✅ **Dashboard funktionsfähig**: http://localhost:8090 zeigt alle Zonen
|
||||
- ✅ **Alle Services laufen in Docker**: Nur snapclient läuft nativ für Audio-Output
|
||||
|
||||
**Next Steps:**
|
||||
1. `make dev` starten
|
||||
2. `snapclient --host localhost --port 1704 --hostID windows-pc --player wasapi` in neuem Terminal
|
||||
3. Musik über Mopidy oder Spotify abspielen
|
||||
4. Genießen! 🎵
|
||||
@@ -11,7 +11,8 @@ output = audioresample ! audioconvert ! audio/x-raw,rate=44100,channels=2,format
|
||||
enabled = true
|
||||
hostname = 0.0.0.0
|
||||
port = 6680
|
||||
allowed_origins =
|
||||
allowed_origins =
|
||||
csrf_protection = false
|
||||
|
||||
[mpd]
|
||||
enabled = false
|
||||
|
||||
@@ -10,6 +10,7 @@ threads = -1
|
||||
enabled = true
|
||||
bind_to_address = 0.0.0.0
|
||||
port = 1780
|
||||
doc_root = /usr/share/snapserver/snapweb/
|
||||
|
||||
[logging]
|
||||
sink = system
|
||||
|
||||
@@ -7,6 +7,42 @@ server {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# SignalK Proxy (incl. WebSocket)
|
||||
location /signalk {
|
||||
proxy_pass http://signalk:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Snapcast Proxy (incl. WebSocket)
|
||||
location /snapcast-ws {
|
||||
proxy_pass http://snapserver:1780;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Mopidy Proxy (incl. WebSocket)
|
||||
location /mopidy {
|
||||
proxy_pass http://mopidy:6680;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Jellyfin Proxy
|
||||
location /jellyfin {
|
||||
proxy_pass http://jellyfin:8096;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
gzip_min_length 1000;
|
||||
|
||||
296
dashboard/package-lock.json
generated
296
dashboard/package-lock.json
generated
@@ -8,8 +8,12 @@
|
||||
"name": "bordanlage-dashboard",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"maplibre-gl": "^5.21.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"spotify-web-api-js": "^1.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
@@ -739,6 +743,122 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
|
||||
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz",
|
||||
"integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz",
|
||||
"integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"rw": "^1.3.3",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz",
|
||||
"integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
|
||||
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@maplibre/geojson-vt": "^5.0.4",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"pbf": "^4.0.1",
|
||||
"supercluster": "^8.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
|
||||
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1148,6 +1268,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/supercluster": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1262,6 +1397,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.325",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz",
|
||||
@@ -1343,6 +1484,12 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -1362,6 +1509,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -1375,6 +1528,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -1397,6 +1562,49 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.21.1",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.1.tgz",
|
||||
"integrity": "sha512-zto1RTnFkOpOO1bm93ElCXF1huey2N4LvXaGLMFcYAu9txh0OhGIdX1q3LZLkrMKgMxMeYduaQo+DVNzg098fg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.0.7",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@maplibre/geojson-vt": "^6.0.4",
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.7.0",
|
||||
"@maplibre/mlt": "^1.1.8",
|
||||
"@maplibre/vt-pbf": "^4.3.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"earcut": "^3.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.0.2",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1404,6 +1612,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -1430,6 +1644,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1466,6 +1692,24 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -1491,6 +1735,20 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
@@ -1501,6 +1759,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
||||
@@ -1546,6 +1813,12 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rw": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@@ -1575,6 +1848,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/spotify-web-api-js": {
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/spotify-web-api-js/-/spotify-web-api-js-1.5.2.tgz",
|
||||
"integrity": "sha512-ie1gbg1wCabfobIkXTIBLUMyULS/hMCpF44Cdx2pAO0/+FrjhNSDjlDzcwCEDy+ZIo3Fscs+Gkg/GTeQ/ijo+Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supercluster": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"maplibre-gl": "^5.21.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"spotify-web-api-js": "^1.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { ThemeProvider } from './contexts/ThemeContext.jsx'
|
||||
import { SpotifyProvider } from './contexts/SpotifyContext.jsx'
|
||||
import TopBar from './components/layout/TopBar.jsx'
|
||||
import TabNav from './components/layout/TabNav.jsx'
|
||||
import Overview from './pages/Overview.jsx'
|
||||
@@ -18,13 +20,17 @@ export default function App() {
|
||||
const Page = PAGES[tab] || Overview
|
||||
|
||||
return (
|
||||
<div style={styles.app}>
|
||||
<TopBar />
|
||||
<TabNav activeTab={tab} onTabChange={setTab} />
|
||||
<main style={styles.main}>
|
||||
<Page />
|
||||
</main>
|
||||
</div>
|
||||
<ThemeProvider>
|
||||
<SpotifyProvider>
|
||||
<div style={styles.app}>
|
||||
<TopBar />
|
||||
<TabNav activeTab={tab} onTabChange={setTab} />
|
||||
<main style={styles.main}>
|
||||
<Page />
|
||||
</main>
|
||||
</div>
|
||||
</SpotifyProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -63,28 +63,35 @@ export default function NowPlaying({ compact = false }) {
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex', gap: 16, padding: 16,
|
||||
background: 'var(--surface)', borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--glass-bg)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
alignItems: 'center',
|
||||
animation: 'slideInDown 0.3s ease-out',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
compact: { padding: '10px 14px' },
|
||||
cover: {
|
||||
width: 64, height: 64, flexShrink: 0,
|
||||
background: 'var(--surface2)', borderRadius: 6,
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 'var(--radius)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
coverImg: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||
coverIcon: { fontSize: 28, color: 'var(--muted)' },
|
||||
info: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 },
|
||||
info: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 },
|
||||
title: { fontWeight: 600, fontSize: 14, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
||||
artist: { fontSize: 12, color: 'var(--muted)' },
|
||||
artist: { fontSize: 12, color: 'var(--muted)', fontWeight: 500 },
|
||||
album: { fontSize: 11, color: 'var(--muted)', opacity: 0.7 },
|
||||
progressRow: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 },
|
||||
progressBg: { flex: 1, height: 3, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' },
|
||||
progressBg: { flex: 1, height: 3, background: 'rgba(255, 255, 255, 0.1)', borderRadius: 2, overflow: 'hidden' },
|
||||
progressFill: { height: '100%', background: 'var(--accent)', borderRadius: 2, transition: 'width 1s linear' },
|
||||
timeText: { fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)', minWidth: 30 },
|
||||
controls: { display: 'flex', gap: 4, marginTop: 4 },
|
||||
btn: { width: 36, height: 36, fontSize: 14, background: 'var(--surface2)', color: 'var(--text)', minWidth: 36 },
|
||||
playBtn: { background: 'var(--accent)', color: '#000', fontWeight: 700 },
|
||||
timeText: { fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)', minWidth: 30, fontWeight: 500 },
|
||||
controls: { display: 'flex', gap: 6, marginTop: 4 },
|
||||
btn: { width: 36, height: 36, fontSize: 14, background: 'rgba(255, 255, 255, 0.08)', color: 'var(--text)', minWidth: 36, border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)', transition: 'all 0.2s' },
|
||||
playBtn: { background: 'var(--accent)', color: 'white', fontWeight: 700, border: 'none' },
|
||||
}
|
||||
|
||||
180
dashboard/src/components/audio/SpotifyAccountManager.jsx
Normal file
180
dashboard/src/components/audio/SpotifyAccountManager.jsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useState } from 'react'
|
||||
import { useSpotify } from '../../contexts/SpotifyContext.jsx'
|
||||
|
||||
export default function SpotifyAccountManager() {
|
||||
const { accounts, addAccount, removeAccount } = useSpotify()
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [form, setForm] = useState({ displayName: '', email: '' })
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!form.email) return
|
||||
|
||||
addAccount({
|
||||
id: `spotify-${Date.now()}`,
|
||||
email: form.email,
|
||||
displayName: form.displayName || form.email.split('@')[0],
|
||||
addedAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
setForm({ displayName: '', email: '' })
|
||||
setIsAdding(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<span style={styles.title}>🎵 Spotify Accounts</span>
|
||||
<button
|
||||
className="icon ghost"
|
||||
onClick={() => setIsAdding(!isAdding)}
|
||||
style={{ fontSize: 18 }}
|
||||
title="Add account"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isAdding && (
|
||||
<div style={styles.form}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Display name (optional)"
|
||||
value={form.displayName}
|
||||
onChange={e => setForm({ ...form, displayName: e.target.value })}
|
||||
style={styles.input}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={form.email}
|
||||
onChange={e => setForm({ ...form, email: e.target.value })}
|
||||
style={styles.input}
|
||||
/>
|
||||
<div style={styles.formButtons}>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={handleAdd}
|
||||
style={{ flex: 1, fontSize: 12 }}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => setIsAdding(false)}
|
||||
style={{ flex: 1, fontSize: 12 }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={styles.list}>
|
||||
{accounts.length === 0 ? (
|
||||
<div style={styles.empty}>No Spotify accounts</div>
|
||||
) : (
|
||||
accounts.map(account => (
|
||||
<div key={account.id} style={styles.item}>
|
||||
<div style={styles.itemInfo}>
|
||||
<div style={styles.itemName}>{account.displayName}</div>
|
||||
<div style={styles.itemEmail}>{account.email}</div>
|
||||
</div>
|
||||
<button
|
||||
className="icon ghost"
|
||||
onClick={() => removeAccount(account.id)}
|
||||
style={{ fontSize: 14, color: '#ef4444' }}
|
||||
title="Remove account"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
background: 'var(--glass-bg)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: 14,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingBottom: 8,
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
title: {
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: 'var(--text)',
|
||||
},
|
||||
form: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: 10,
|
||||
background: 'rgba(29, 185, 84, 0.08)',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid rgba(29, 185, 84, 0.2)',
|
||||
},
|
||||
input: {
|
||||
padding: '8px 10px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'var(--radius)',
|
||||
color: 'var(--text)',
|
||||
fontSize: 12,
|
||||
fontFamily: 'var(--font-ui)',
|
||||
},
|
||||
formButtons: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
},
|
||||
list: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
},
|
||||
empty: {
|
||||
fontSize: 11,
|
||||
color: 'var(--muted)',
|
||||
textAlign: 'center',
|
||||
padding: 12,
|
||||
},
|
||||
item: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 8,
|
||||
background: 'rgba(29, 185, 84, 0.08)',
|
||||
border: '1px solid rgba(29, 185, 84, 0.15)',
|
||||
borderRadius: 'var(--radius)',
|
||||
},
|
||||
itemInfo: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
itemName: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text)',
|
||||
},
|
||||
itemEmail: {
|
||||
fontSize: 10,
|
||||
color: 'var(--muted)',
|
||||
marginTop: 2,
|
||||
},
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useSpotify } from '../../contexts/SpotifyContext.jsx'
|
||||
|
||||
export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, groupedWith }) {
|
||||
const { id, name, active, volume, muted, source } = zone
|
||||
const { getAccountForZone, assignAccountToZone, removeAccountFromZone, accounts } = useSpotify()
|
||||
|
||||
const spotifyAccount = source === 'Spotify' ? getAccountForZone(id) : null
|
||||
|
||||
// Map source to emoji and connection info
|
||||
const sourceInfo = {
|
||||
@@ -9,6 +14,17 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
|
||||
}
|
||||
const info = sourceInfo[source] || { emoji: '📢', color: 'var(--muted)', label: source }
|
||||
|
||||
const handleSourceChange = (newSource) => {
|
||||
onSource(id, newSource)
|
||||
|
||||
// If switching to Spotify, assign an account if not already assigned
|
||||
if (newSource === 'Spotify' && !spotifyAccount && accounts.length > 0) {
|
||||
assignAccountToZone(id, accounts[0].id)
|
||||
} else if (newSource !== 'Spotify' && spotifyAccount) {
|
||||
removeAccountFromZone(id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
...styles.card,
|
||||
@@ -23,6 +39,11 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
|
||||
<span style={{ ...styles.sourceTag, background: `${info.color}22`, color: info.color }}>
|
||||
{info.emoji} {info.label}
|
||||
</span>
|
||||
{spotifyAccount && (
|
||||
<span style={{ ...styles.accountTag, color: '#1DB954', fontSize: 10 }}>
|
||||
👤 {spotifyAccount.displayName || spotifyAccount.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={styles.badges}>
|
||||
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
|
||||
@@ -45,13 +66,34 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
|
||||
<div style={styles.sourceControl}>
|
||||
<select
|
||||
value={source}
|
||||
onChange={e => onSource(id, e.target.value)}
|
||||
onChange={e => handleSourceChange(e.target.value)}
|
||||
style={styles.sourceSelect}
|
||||
>
|
||||
<option value="Spotify">🎵 Spotify</option>
|
||||
<option value="AirPlay">🎙️ AirPlay</option>
|
||||
<option value="Mopidy">📻 Mopidy</option>
|
||||
</select>
|
||||
|
||||
{source === 'Spotify' && accounts.length > 0 && (
|
||||
<select
|
||||
value={spotifyAccount?.id || ''}
|
||||
onChange={e => {
|
||||
if (e.target.value) {
|
||||
assignAccountToZone(id, e.target.value)
|
||||
}
|
||||
}}
|
||||
style={styles.accountSelect}
|
||||
title="Select Spotify account for this zone"
|
||||
>
|
||||
<option value="">Select Account</option>
|
||||
{accounts.map(acc => (
|
||||
<option key={acc.id} value={acc.id}>
|
||||
{acc.displayName || acc.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{onGroup && (
|
||||
<button
|
||||
style={styles.groupBtn}
|
||||
@@ -81,49 +123,69 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
|
||||
const styles = {
|
||||
card: {
|
||||
padding: 14,
|
||||
background: 'var(--surface)',
|
||||
background: 'var(--glass-bg)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--border)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
display: 'flex', flexDirection: 'column', gap: 10,
|
||||
transition: 'border-color 0.2s, opacity 0.2s',
|
||||
transition: 'all 0.3s cubic-bezier(0.23, 1, 0.320, 1)',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 },
|
||||
titleArea: { display: 'flex', flexDirection: 'column', gap: 6, flex: 1 },
|
||||
name: { fontWeight: 600, fontSize: 14 },
|
||||
name: { fontWeight: 600, fontSize: 15, letterSpacing: '-0.01em' },
|
||||
sourceTag: {
|
||||
fontSize: 11, padding: '3px 8px', borderRadius: 4, fontWeight: 600,
|
||||
fontSize: 11, padding: '4px 8px', borderRadius: 6, fontWeight: 600,
|
||||
display: 'inline-block', width: 'fit-content',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
accountTag: {
|
||||
fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 500,
|
||||
display: 'inline-block', width: 'fit-content',
|
||||
background: 'rgba(29, 185, 84, 0.1)',
|
||||
},
|
||||
badges: { display: 'flex', gap: 4 },
|
||||
badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' },
|
||||
badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.2s' },
|
||||
|
||||
groupInfo: {
|
||||
fontSize: 11, color: 'var(--muted)',
|
||||
background: '#38bdf811', padding: 8, borderRadius: 4,
|
||||
background: 'rgba(14, 165, 233, 0.08)', padding: 8, borderRadius: 6,
|
||||
borderLeft: '2px solid var(--accent)',
|
||||
animation: 'slideInUp 0.3s ease-out',
|
||||
},
|
||||
groupLabel: { fontWeight: 600, marginBottom: 4 },
|
||||
groupLabel: { fontWeight: 600, marginBottom: 4, display: 'block' },
|
||||
groupZones: { display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 },
|
||||
groupZone: {
|
||||
fontSize: 10, padding: '2px 6px', background: 'var(--accent)',
|
||||
color: 'var(--bg)', borderRadius: 3, fontWeight: 600,
|
||||
fontSize: 10, padding: '3px 8px', background: 'var(--accent)',
|
||||
color: 'white', borderRadius: 4, fontWeight: 600,
|
||||
animation: 'slideInUp 0.2s ease-out',
|
||||
},
|
||||
|
||||
sourceControl: { display: 'flex', gap: 8, alignItems: 'center' },
|
||||
sourceControl: { display: 'flex', gap: 6, alignItems: 'center' },
|
||||
sourceSelect: {
|
||||
flex: 1, padding: '8px 10px', background: 'var(--bg)', color: 'var(--text)',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--radius)',
|
||||
flex: 1.2, padding: '8px 10px', background: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
accountSelect: {
|
||||
flex: 1, padding: '8px 10px', background: 'rgba(29, 185, 84, 0.08)',
|
||||
color: 'var(--text)',
|
||||
border: '1px solid rgba(29, 185, 84, 0.2)', borderRadius: 'var(--radius)',
|
||||
fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
groupBtn: {
|
||||
minWidth: 44, minHeight: 44, padding: 0,
|
||||
background: 'var(--border)', color: 'var(--text)',
|
||||
border: 'none', borderRadius: 'var(--radius)', cursor: 'pointer',
|
||||
fontSize: 18, transition: 'background 0.2s',
|
||||
'&:hover': { background: 'var(--accent)' },
|
||||
minWidth: 40, minHeight: 40, padding: 0,
|
||||
background: 'rgba(255, 255, 255, 0.08)', color: 'var(--text)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 16, transition: 'all 0.2s',
|
||||
},
|
||||
|
||||
volumeRow: { display: 'flex', alignItems: 'center', gap: 10 },
|
||||
muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44, background: 'none', border: 'none', cursor: 'pointer' },
|
||||
volVal: { fontFamily: 'var(--font-mono)', fontSize: 13, minWidth: 26, textAlign: 'right', color: 'var(--muted)' },
|
||||
muteBtn: { fontSize: 18, minWidth: 40, minHeight: 40, background: 'none', border: 'none', cursor: 'pointer', transition: 'transform 0.2s' },
|
||||
volVal: { fontFamily: 'var(--font-mono)', fontSize: 12, minWidth: 28, textAlign: 'right', color: 'var(--muted)' },
|
||||
}
|
||||
|
||||
@@ -100,25 +100,29 @@ const styles = {
|
||||
gridTemplateColumns: '240px 1fr',
|
||||
gap: 16,
|
||||
height: '100%',
|
||||
padding: 16,
|
||||
},
|
||||
groupingPanel: {
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: 12,
|
||||
background: 'var(--glass-bg)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
padding: 14,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
maxHeight: '100vh',
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
overflowY: 'auto',
|
||||
animation: 'slideInDown 0.3s ease-out',
|
||||
},
|
||||
panelTitle: {
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
color: 'var(--muted)',
|
||||
margin: '0 0 8px 0',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 11,
|
||||
@@ -132,13 +136,14 @@ const styles = {
|
||||
gap: 6,
|
||||
},
|
||||
groupListItem: {
|
||||
background: '#38bdf811',
|
||||
border: '1px solid var(--accent)',
|
||||
borderRadius: 4,
|
||||
background: 'rgba(14, 165, 233, 0.08)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
borderRadius: 6,
|
||||
padding: '8px 10px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
groupName: {
|
||||
fontSize: 11,
|
||||
@@ -155,11 +160,13 @@ const styles = {
|
||||
padding: '0 4px',
|
||||
minWidth: 24,
|
||||
minHeight: 24,
|
||||
transition: 'transform 0.2s',
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: 12,
|
||||
overflowY: 'auto',
|
||||
animation: 'slideInUp 0.3s ease-out',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function TabNav({ activeTab, onTabChange }) {
|
||||
...(activeTab === tab.id ? styles.active : {}),
|
||||
}}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={activeTab === tab.id ? 'slide-in-down' : ''}
|
||||
>
|
||||
<span style={styles.icon}>{tab.icon}</span>
|
||||
<span style={styles.label}>{tab.label}</span>
|
||||
@@ -31,6 +32,7 @@ const styles = {
|
||||
background: 'var(--surface)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
flexShrink: 0,
|
||||
backdropFilter: 'blur(8px)',
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
@@ -43,14 +45,14 @@ const styles = {
|
||||
fontSize: 11,
|
||||
color: 'var(--muted)',
|
||||
borderRadius: 0,
|
||||
borderBottom: '2px solid transparent',
|
||||
transition: 'color 0.15s, border-color 0.15s',
|
||||
borderBottom: '3px solid transparent',
|
||||
transition: 'color 0.2s cubic-bezier(0.23, 1, 0.320, 1), border-color 0.2s cubic-bezier(0.23, 1, 0.320, 1)',
|
||||
minHeight: 48,
|
||||
},
|
||||
active: {
|
||||
color: 'var(--accent)',
|
||||
borderBottom: '2px solid var(--accent)',
|
||||
borderBottom: '3px solid var(--accent)',
|
||||
},
|
||||
icon: { fontSize: 16 },
|
||||
icon: { fontSize: 18, transition: 'transform 0.2s' },
|
||||
label: { fontSize: 10, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase' },
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||
import { useTheme } from '../../contexts/ThemeContext.jsx'
|
||||
|
||||
const isMock = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
const isDev = import.meta.env.DEV
|
||||
@@ -10,6 +11,7 @@ function formatTime() {
|
||||
|
||||
export default function TopBar() {
|
||||
const { sog, heading, connected } = useNMEA()
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const [time, setTime] = useState(formatTime())
|
||||
|
||||
// Clock tick
|
||||
@@ -23,7 +25,7 @@ export default function TopBar() {
|
||||
<div style={styles.left}>
|
||||
<span style={styles.logo}>⚓ Bordanlage</span>
|
||||
{isMock && <span style={styles.devBadge}>DEV · MOCK DATA</span>}
|
||||
{isDev && !isMock && <span style={{ ...styles.devBadge, background: '#38bdf822', color: 'var(--accent)', borderColor: 'var(--accent)' }}>DEV · LIVE</span>}
|
||||
{isDev && !isMock && <span style={{ ...styles.devBadge, background: '#0ea5e922', color: 'var(--accent)', borderColor: 'var(--accent)' }}>DEV · LIVE</span>}
|
||||
</div>
|
||||
|
||||
<div style={styles.center}>
|
||||
@@ -45,6 +47,14 @@ export default function TopBar() {
|
||||
</div>
|
||||
|
||||
<div style={styles.right}>
|
||||
<button
|
||||
className="icon ghost"
|
||||
onClick={toggleTheme}
|
||||
title={isDark ? 'Light Mode' : 'Dark Mode'}
|
||||
style={{ fontSize: 18 }}
|
||||
>
|
||||
{isDark ? '☀️' : '🌙'}
|
||||
</button>
|
||||
<span style={styles.time}>{time}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -6,7 +6,7 @@ import WindRose from '../instruments/WindRose.jsx'
|
||||
|
||||
function DataRow({ label, value, unit, highlight }) {
|
||||
return (
|
||||
<div style={{ ...styles.row, background: highlight ? '#38bdf811' : 'transparent' }}>
|
||||
<div style={{ ...styles.row, background: highlight ? 'rgba(14, 165, 233, 0.08)' : 'transparent' }}>
|
||||
<span style={styles.label}>{label}</span>
|
||||
<span style={styles.value}>
|
||||
{value != null ? `${typeof value === 'number' ? value.toFixed(1) : value}` : '—'}
|
||||
@@ -97,12 +97,16 @@ export default function InstrumentPanel() {
|
||||
|
||||
const styles = {
|
||||
panel: { display: 'flex', flexDirection: 'column', gap: 16 },
|
||||
gauges: { display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' },
|
||||
gauges: { display: 'flex', gap: 10, flexWrap: 'wrap', justifyContent: 'center' },
|
||||
|
||||
waypointBox: {
|
||||
background: 'var(--surface)', borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--accent)',
|
||||
background: 'var(--glass-bg)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||
padding: 12,
|
||||
animation: 'slideInUp 0.3s ease-out',
|
||||
},
|
||||
waypointTitle: {
|
||||
fontSize: 12, fontWeight: 700, color: 'var(--accent)',
|
||||
@@ -116,7 +120,7 @@ const styles = {
|
||||
waypointRoute: {
|
||||
display: 'flex', flexDirection: 'column', gap: 6,
|
||||
marginTop: 4, paddingTop: 8,
|
||||
borderTop: '1px solid var(--border)',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
routeLabel: { fontSize: 11, color: 'var(--muted)' },
|
||||
waypoints: {
|
||||
@@ -125,22 +129,26 @@ const styles = {
|
||||
waypointTag: {
|
||||
width: 28, height: 28,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: 4, fontSize: 11, fontWeight: 600,
|
||||
borderRadius: 6, fontSize: 11, fontWeight: 600,
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
|
||||
table: {
|
||||
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
||||
gap: '0 24px', background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)', padding: 16,
|
||||
border: '1px solid var(--border)',
|
||||
gap: '0 24px',
|
||||
background: 'var(--glass-bg)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
borderRadius: 'var(--radius-lg)', padding: 16,
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
animation: 'slideInUp 0.3s ease-out',
|
||||
},
|
||||
row: {
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '7px 0', borderBottom: '1px solid var(--border)',
|
||||
padding: '7px 0', borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
transition: 'background 0.2s',
|
||||
},
|
||||
label: { fontSize: 12, color: 'var(--muted)' },
|
||||
value: { fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)' },
|
||||
label: { fontSize: 12, color: 'var(--muted)', fontWeight: 500 },
|
||||
value: { fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)', fontWeight: 600 },
|
||||
unit: { color: 'var(--muted)', fontSize: 11 },
|
||||
}
|
||||
|
||||
486
dashboard/src/components/nav/NavigationMap.jsx
Normal file
486
dashboard/src/components/nav/NavigationMap.jsx
Normal file
@@ -0,0 +1,486 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||
import { getApi } from '../../mock/index.js'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
|
||||
export default function NavigationMap() {
|
||||
const { lat, lon, heading, sog } = useNMEA()
|
||||
const mapContainer = useRef(null)
|
||||
const map = useRef(null)
|
||||
const shipMarkerRef = useRef(null)
|
||||
const trackSourceRef = useRef(false)
|
||||
const [zoom, setZoom] = useState(11)
|
||||
const [mapError, setMapError] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const api = getApi()
|
||||
const snapshot = api.signalk.getSnapshot?.()
|
||||
const waypoints = api.signalk.getWaypoints?.() || []
|
||||
|
||||
const mapLat = lat ?? 55.32
|
||||
const mapLon = lon ?? 15.22
|
||||
|
||||
useEffect(() => {
|
||||
if (map.current) return
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
map.current = new maplibregl.Map({
|
||||
container: mapContainer.current,
|
||||
style: {
|
||||
version: 8,
|
||||
sources: {
|
||||
'osm': {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
},
|
||||
'seamark': {
|
||||
type: 'raster',
|
||||
tiles: ['https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenSeaMap contributors',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-base',
|
||||
type: 'raster',
|
||||
source: 'osm',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
{
|
||||
id: 'seamark-overlay',
|
||||
type: 'raster',
|
||||
source: 'seamark',
|
||||
minzoom: 5,
|
||||
maxzoom: 19,
|
||||
paint: {
|
||||
'raster-opacity': 0.8,
|
||||
},
|
||||
},
|
||||
],
|
||||
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||
},
|
||||
center: [mapLon, mapLat],
|
||||
zoom: 11,
|
||||
pitch: 0,
|
||||
bearing: 0,
|
||||
antialias: true,
|
||||
})
|
||||
|
||||
// Add navigation controls
|
||||
map.current.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
|
||||
// Add ship marker source and layer
|
||||
map.current.on('load', () => {
|
||||
if (!map.current.getSource('ship')) {
|
||||
map.current.addSource('ship', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [mapLon, mapLat] },
|
||||
properties: { heading: 0 },
|
||||
},
|
||||
})
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'ship-marker',
|
||||
type: 'symbol',
|
||||
source: 'ship',
|
||||
layout: {
|
||||
'icon-image': 'marker-blue',
|
||||
'icon-size': 1.2,
|
||||
'icon-rotate': ['get', 'heading'],
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Add track source for ship trail
|
||||
if (!map.current.getSource('track')) {
|
||||
map.current.addSource('track', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'LineString', coordinates: [[mapLon, mapLat]] },
|
||||
},
|
||||
})
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'track-line',
|
||||
type: 'line',
|
||||
source: 'track',
|
||||
paint: {
|
||||
'line-color': '#0ea5e9',
|
||||
'line-width': 2,
|
||||
'line-opacity': 0.7,
|
||||
'line-dasharray': [5, 5],
|
||||
},
|
||||
})
|
||||
trackSourceRef.current = true
|
||||
}
|
||||
|
||||
// Add waypoints
|
||||
if (waypoints.length > 0) {
|
||||
const waypointFeatures = waypoints.map((wp, idx) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [wp.lon, wp.lat] },
|
||||
properties: { index: idx, isCurrent: idx === snapshot?.currentWaypoint },
|
||||
}))
|
||||
|
||||
if (!map.current.getSource('waypoints')) {
|
||||
map.current.addSource('waypoints', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: waypointFeatures,
|
||||
},
|
||||
})
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'waypoint-circles',
|
||||
type: 'circle',
|
||||
source: 'waypoints',
|
||||
paint: {
|
||||
'circle-radius': 8,
|
||||
'circle-color': ['case', ['get', 'isCurrent'], '#0ea5e9', '#f59e0b'],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
},
|
||||
})
|
||||
|
||||
map.current.addLayer({
|
||||
id: 'waypoint-labels',
|
||||
type: 'symbol',
|
||||
source: 'waypoints',
|
||||
layout: {
|
||||
'text-field': ['to-string', ['+', ['get', 'index'], 1]],
|
||||
'text-size': 12,
|
||||
'text-font': ['Open Sans Semibold'],
|
||||
'text-offset': [0, 0],
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
map.current.getSource('waypoints').setData({
|
||||
type: 'FeatureCollection',
|
||||
features: waypointFeatures,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
map.current.on('zoom', () => setZoom(map.current.getZoom()))
|
||||
|
||||
map.current.on('load', () => {
|
||||
setIsLoading(false)
|
||||
setMapError(null)
|
||||
})
|
||||
|
||||
map.current.on('error', (e) => {
|
||||
console.error('Map error:', e)
|
||||
setMapError('Map failed to load. Check your internet connection.')
|
||||
setIsLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (map.current) {
|
||||
map.current.remove()
|
||||
map.current = null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize map:', error)
|
||||
setMapError(`Map initialization failed: ${error.message}`)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update ship position
|
||||
useEffect(() => {
|
||||
if (!map.current || lat == null || lon == null) return
|
||||
|
||||
if (map.current.getSource('ship')) {
|
||||
const trackSource = map.current.getSource('track')
|
||||
if (trackSource && trackSourceRef.current) {
|
||||
const currentData = trackSource._data
|
||||
// Check if geometry exists and has coordinates array
|
||||
if (currentData?.geometry?.coordinates) {
|
||||
if (currentData.geometry.coordinates.length > 500) {
|
||||
currentData.geometry.coordinates.shift() // Keep last 500 points
|
||||
}
|
||||
currentData.geometry.coordinates.push([lon, lat])
|
||||
trackSource.setData(currentData)
|
||||
}
|
||||
}
|
||||
|
||||
map.current.getSource('ship').setData({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||
properties: { heading: heading ?? 0 },
|
||||
})
|
||||
|
||||
// Auto-center on ship
|
||||
map.current.flyTo({
|
||||
center: [lon, lat],
|
||||
zoom: zoom,
|
||||
speed: 0.5,
|
||||
curve: 1,
|
||||
})
|
||||
}
|
||||
}, [lat, lon, heading, zoom])
|
||||
|
||||
// Show error state
|
||||
if (mapError) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.errorState}>
|
||||
<div style={styles.errorIcon}>⚠️</div>
|
||||
<div style={styles.errorTitle}>Karte konnte nicht geladen werden</div>
|
||||
<div style={styles.errorMessage}>{mapError}</div>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => window.location.reload()}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{isLoading && (
|
||||
<div style={styles.loadingOverlay}>
|
||||
<div style={styles.spinner}></div>
|
||||
<div style={{ marginTop: 12, color: 'var(--muted)' }}>Karte wird geladen...</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={mapContainer} style={styles.mapBox} />
|
||||
|
||||
{/* Map Controls */}
|
||||
<div style={styles.controls}>
|
||||
<button
|
||||
className="icon ghost"
|
||||
onClick={() => map.current?.zoomIn()}
|
||||
title="Zoom in"
|
||||
style={{ fontSize: 18 }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
className="icon ghost"
|
||||
onClick={() => map.current?.zoomOut()}
|
||||
title="Zoom out"
|
||||
style={{ fontSize: 18 }}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
className="icon ghost"
|
||||
onClick={() => {
|
||||
if (lat != null && lon != null) {
|
||||
map.current?.flyTo({
|
||||
center: [lon, lat],
|
||||
zoom: 12,
|
||||
duration: 1000,
|
||||
})
|
||||
}
|
||||
}}
|
||||
title="Center on ship"
|
||||
style={{ fontSize: 16 }}
|
||||
>
|
||||
⊙
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info Panel */}
|
||||
{lat != null && lon != null && (
|
||||
<div style={styles.infoPanel}>
|
||||
<div style={styles.infoSection}>
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}>Position</span>
|
||||
<span style={styles.value}>{lat.toFixed(5)}°</span>
|
||||
</div>
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}></span>
|
||||
<span style={styles.value}>{lon.toFixed(5)}°</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{heading != null && (
|
||||
<div style={styles.infoSection}>
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}>Heading</span>
|
||||
<span style={styles.value}>{Math.round(heading)}°</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sog != null && (
|
||||
<div style={styles.infoSection}>
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}>Speed</span>
|
||||
<span style={styles.value}>{(sog * 1.943844).toFixed(1)} kn</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{waypoints.length > 0 && snapshot?.currentWaypoint != null && (
|
||||
<div style={styles.infoSection}>
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}>Route</span>
|
||||
<span style={styles.value}>WP {snapshot.currentWaypoint + 1} / {waypoints.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot?.distanceToWaypoint != null && (
|
||||
<div style={styles.infoSection}>
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}>Distance</span>
|
||||
<span style={styles.value}>{(snapshot.distanceToWaypoint * 1.852).toFixed(1)} km</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Map Style Toggle */}
|
||||
<div style={styles.layerToggle}>
|
||||
<span style={{ fontSize: 11, color: 'var(--muted)' }}>+ SeaMarks</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 'var(--radius)',
|
||||
},
|
||||
mapBox: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
zIndex: 1000,
|
||||
},
|
||||
infoPanel: {
|
||||
position: 'absolute',
|
||||
bottom: 12,
|
||||
left: 12,
|
||||
background: 'var(--glass-bg)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: '10px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: 'var(--text)',
|
||||
zIndex: 500,
|
||||
maxWidth: 180,
|
||||
animation: 'slideInUp 0.3s ease-out',
|
||||
},
|
||||
infoSection: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
},
|
||||
infoRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
color: 'var(--muted)',
|
||||
fontWeight: 600,
|
||||
fontSize: 10,
|
||||
},
|
||||
value: {
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
textAlign: 'right',
|
||||
},
|
||||
layerToggle: {
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
right: 12,
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: '4px 8px',
|
||||
zIndex: 500,
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'var(--surface)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 2000,
|
||||
},
|
||||
spinner: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: '4px solid var(--border)',
|
||||
borderTop: '4px solid var(--accent)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
},
|
||||
errorState: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
padding: 32,
|
||||
textAlign: 'center',
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)',
|
||||
},
|
||||
errorIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 16,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text)',
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorMessage: {
|
||||
fontSize: 14,
|
||||
color: 'var(--muted)',
|
||||
maxWidth: 400,
|
||||
},
|
||||
}
|
||||
204
dashboard/src/components/nav/SimpleNavigationMap.jsx
Normal file
204
dashboard/src/components/nav/SimpleNavigationMap.jsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||
|
||||
// Simple fallback map component without MapLibre (no external dependencies)
|
||||
export default function SimpleNavigationMap() {
|
||||
const { lat, lon, heading, sog, cog } = useNMEA()
|
||||
|
||||
const mapLat = lat ?? 54.32
|
||||
const mapLon = lon ?? 10.22
|
||||
|
||||
// Simple marker rotation based on heading
|
||||
const rotation = heading ?? cog ?? 0
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{/* Info Panel */}
|
||||
<div style={styles.infoPanel}>
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}>Position</span>
|
||||
<span style={styles.value}>
|
||||
{lat != null ? lat.toFixed(5) : '--'}° N
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}></span>
|
||||
<span style={styles.value}>
|
||||
{lon != null ? lon.toFixed(5) : '--'}° E
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{heading != null && (
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}>Heading</span>
|
||||
<span style={styles.value}>{Math.round(heading)}°</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sog != null && (
|
||||
<div style={styles.infoRow}>
|
||||
<span style={styles.label}>Speed</span>
|
||||
<span style={styles.value}>{(sog * 1.943844).toFixed(1)} kn</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Simple visual representation */}
|
||||
<div style={styles.mapView}>
|
||||
<div style={styles.compassRose}>
|
||||
<div style={styles.northMarker}>N</div>
|
||||
<div
|
||||
style={{
|
||||
...styles.shipMarker,
|
||||
transform: `rotate(${rotation}deg)`
|
||||
}}
|
||||
>
|
||||
▲
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.coords}>
|
||||
{lat != null && lon != null ? (
|
||||
<>
|
||||
<div>{lat.toFixed(5)}°</div>
|
||||
<div>{lon.toFixed(5)}°</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: 'var(--muted)' }}>No GPS signal</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map notice */}
|
||||
<div style={styles.notice}>
|
||||
<div style={styles.noticeTitle}>🗺️ Karten-Modus</div>
|
||||
<div style={styles.noticeText}>
|
||||
Interaktive Seekarte ist im Production Mode verfügbar.
|
||||
<br />
|
||||
Hier siehst du Position, Kurs und Geschwindigkeit.
|
||||
</div>
|
||||
<a
|
||||
href="http://localhost:3000"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={styles.link}
|
||||
>
|
||||
→ SignalK Karte öffnen (Port 3000)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
minHeight: 400,
|
||||
},
|
||||
mapView: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 32,
|
||||
padding: 32,
|
||||
},
|
||||
compassRose: {
|
||||
position: 'relative',
|
||||
width: 180,
|
||||
height: 180,
|
||||
border: '2px solid var(--accent)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'radial-gradient(circle, rgba(14, 165, 233, 0.05) 0%, transparent 70%)',
|
||||
},
|
||||
northMarker: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
shipMarker: {
|
||||
fontSize: 48,
|
||||
color: 'var(--accent)',
|
||||
transition: 'transform 0.5s ease-out',
|
||||
},
|
||||
coords: {
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text)',
|
||||
textAlign: 'center',
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
infoPanel: {
|
||||
background: 'var(--glass-bg)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: '12px 16px',
|
||||
margin: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
},
|
||||
infoRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 16,
|
||||
},
|
||||
label: {
|
||||
color: 'var(--muted)',
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
},
|
||||
value: {
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
fontSize: 13,
|
||||
},
|
||||
notice: {
|
||||
background: 'var(--surface2)',
|
||||
padding: 20,
|
||||
borderTop: '1px solid var(--border)',
|
||||
textAlign: 'center',
|
||||
},
|
||||
noticeTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--text)',
|
||||
marginBottom: 8,
|
||||
},
|
||||
noticeText: {
|
||||
fontSize: 12,
|
||||
color: 'var(--muted)',
|
||||
lineHeight: 1.5,
|
||||
marginBottom: 12,
|
||||
},
|
||||
link: {
|
||||
display: 'inline-block',
|
||||
fontSize: 12,
|
||||
color: 'var(--accent)',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 600,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 'var(--radius)',
|
||||
background: 'rgba(14, 165, 233, 0.1)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
}
|
||||
222
dashboard/src/components/systems/BoatControl.jsx
Normal file
222
dashboard/src/components/systems/BoatControl.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React from 'react';
|
||||
import { useNMEA } from '../../hooks/useNMEA';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'lights', icon: '💡', label: 'Lights', pos: { top: '15%', left: '10%' } },
|
||||
{ id: 'climate', icon: '❄️', label: 'Climate', pos: { top: '35%', left: '5%' } },
|
||||
{ id: 'nav', icon: '📍', label: 'Navigation', pos: { top: '55%', left: '5%' } },
|
||||
{ id: 'audio', icon: '🎵', label: 'Audio', pos: { top: '75%', left: '10%' } },
|
||||
{ id: 'battery', icon: '🔋', label: 'Energy', pos: { bottom: '15%', left: '15%' } },
|
||||
|
||||
{ id: 'tanks', icon: '🌊', label: 'Tanks', pos: { top: '15%', right: '10%' } },
|
||||
{ id: 'power', icon: '🔌', label: 'Shore Power', pos: { top: '35%', right: '5%' } },
|
||||
{ id: 'wind', icon: '🌬️', label: 'Wind', pos: { top: '55%', right: '5%' } },
|
||||
{ id: 'engine', icon: '⚙️', label: 'Engine', pos: { top: '75%', right: '10%' } },
|
||||
];
|
||||
|
||||
export default function BoatControl({ activeCategory, onCategoryChange }) {
|
||||
const { sog, heading } = useNMEA();
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.shipControlLabel}>SHIP CONTROL</div>
|
||||
|
||||
<div style={styles.mainArea}>
|
||||
{/* Category Icons Left */}
|
||||
{CATEGORIES.slice(0, 5).map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
style={{
|
||||
...styles.catBtn,
|
||||
...cat.pos,
|
||||
...(activeCategory === cat.id ? styles.catBtnActive : {})
|
||||
}}
|
||||
onClick={() => onCategoryChange(cat.id)}
|
||||
>
|
||||
<span style={styles.catIcon}>{cat.icon}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Central Boat Graphic */}
|
||||
<div style={styles.boatContainer}>
|
||||
<img
|
||||
src="https://www.prestige-yachts.com/fichiers/Prestige_F4.9_Profile_600.png"
|
||||
alt="Prestige Yacht"
|
||||
style={styles.boatImg}
|
||||
onError={(e) => {
|
||||
// Fallback if image fails to load
|
||||
e.target.style.display = 'none';
|
||||
e.target.nextSibling.style.display = 'flex';
|
||||
}}
|
||||
/>
|
||||
<div style={{...styles.boatFallback, display: 'none'}}>
|
||||
<div style={styles.boatHull}></div>
|
||||
</div>
|
||||
|
||||
{/* Central Instrument Over Boat */}
|
||||
<div style={styles.centralGauge}>
|
||||
<div style={styles.gaugeValue}>{sog?.toFixed(1) || '0.0'}</div>
|
||||
<div style={styles.gaugeUnit}>knots</div>
|
||||
<div style={styles.gaugeHeading}>{Math.round(heading || 0)}°</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Icons Right */}
|
||||
{CATEGORIES.slice(5).map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
style={{
|
||||
...styles.catBtn,
|
||||
...cat.pos,
|
||||
...(activeCategory === cat.id ? styles.catBtnActive : {})
|
||||
}}
|
||||
onClick={() => onCategoryChange(cat.id)}
|
||||
>
|
||||
<span style={styles.catIcon}>{cat.icon}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={styles.bottomBar}>
|
||||
<div style={styles.bottomIcon}>⚙️</div>
|
||||
<div style={styles.time}>{new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })}</div>
|
||||
<div style={styles.bottomIcon}>📖</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
background: 'radial-gradient(circle at center, #1a2a3a 0%, #07111f 100%)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
shipControlLabel: {
|
||||
position: 'absolute',
|
||||
top: 40,
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
fontSize: 48,
|
||||
fontWeight: 300,
|
||||
letterSpacing: '0.2em',
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
fontFamily: 'serif',
|
||||
},
|
||||
mainArea: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
catBtn: {
|
||||
position: 'absolute',
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s cubic-bezier(0.23, 1, 0.320, 1)',
|
||||
zIndex: 10,
|
||||
},
|
||||
catBtnActive: {
|
||||
background: 'rgba(14, 165, 233, 0.2)',
|
||||
borderColor: '#0ea5e9',
|
||||
boxShadow: '0 0 20px rgba(14, 165, 233, 0.4)',
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
catIcon: {
|
||||
fontSize: 24,
|
||||
opacity: 0.8,
|
||||
},
|
||||
boatContainer: {
|
||||
position: 'relative',
|
||||
width: '70%',
|
||||
maxWidth: 800,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: 'fadeIn 1s ease-out',
|
||||
},
|
||||
boatImg: {
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
filter: 'drop-shadow(0 20px 30px rgba(0,0,0,0.5)) brightness(1.1)',
|
||||
},
|
||||
boatFallback: {
|
||||
width: 400,
|
||||
height: 100,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
boatHull: {
|
||||
width: '100%',
|
||||
height: 40,
|
||||
background: '#fff',
|
||||
borderRadius: '50% 50% 10% 10%',
|
||||
},
|
||||
centralGauge: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 180,
|
||||
height: 180,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(7, 17, 31, 0.6)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '2px solid rgba(255,255,255,0.15)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 0 40px rgba(0,0,0,0.5)',
|
||||
},
|
||||
gaugeValue: {
|
||||
fontSize: 42,
|
||||
fontWeight: 700,
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: '#fff',
|
||||
},
|
||||
gaugeUnit: {
|
||||
fontSize: 12,
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--muted)',
|
||||
letterSpacing: '0.1em',
|
||||
marginTop: -4,
|
||||
},
|
||||
gaugeHeading: {
|
||||
marginTop: 8,
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
bottomBar: {
|
||||
height: 60,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 40,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
bottomIcon: {
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
time: {
|
||||
fontSize: 18,
|
||||
fontWeight: 400,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}
|
||||
};
|
||||
@@ -15,8 +15,13 @@ export default function EngineData() {
|
||||
|
||||
const styles = {
|
||||
grid: {
|
||||
display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center',
|
||||
padding: 16, background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
|
||||
display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center',
|
||||
padding: 16,
|
||||
background: 'var(--glass-bg)',
|
||||
backdropFilter: 'blur(var(--glass-blur))',
|
||||
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
animation: 'slideInUp 0.3s ease-out',
|
||||
}
|
||||
}
|
||||
|
||||
125
dashboard/src/components/systems/FloorPlan.jsx
Normal file
125
dashboard/src/components/systems/FloorPlan.jsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
|
||||
const ZONES = [
|
||||
{ id: 'salon', label: 'Salon', top: '40%', left: '30%', width: '20%', height: '20%' },
|
||||
{ id: 'cockpit', label: 'Cockpit', top: '40%', left: '55%', width: '15%', height: '20%' },
|
||||
{ id: 'bug', label: 'Owner Cabin', top: '40%', left: '10%', width: '15%', height: '20%' },
|
||||
{ id: 'heck', label: 'VIP Cabin', top: '40%', left: '75%', width: '15%', height: '20%' },
|
||||
];
|
||||
|
||||
export default function FloorPlan({ type, onZoneClick }) {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.floorPlanWrapper}>
|
||||
{/* Simple SVG/CSS representation of a boat deck */}
|
||||
<div style={styles.deckOutline}>
|
||||
{ZONES.map(zone => (
|
||||
<div
|
||||
key={zone.id}
|
||||
style={{
|
||||
...styles.zone,
|
||||
top: zone.top,
|
||||
left: zone.left,
|
||||
width: zone.width,
|
||||
height: zone.height,
|
||||
}}
|
||||
onClick={() => onZoneClick(zone.id)}
|
||||
>
|
||||
<div style={styles.zoneLabel}>{zone.label}</div>
|
||||
<div style={styles.zoneStatus}>
|
||||
{type === 'lights' ? '💡 80%' : '🌡️ 22°C'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.controls}>
|
||||
<div style={styles.controlHeader}>{type === 'lights' ? 'Lighting Control' : 'Climate Control'}</div>
|
||||
<div style={styles.controlRow}>
|
||||
<span>Master Switch</span>
|
||||
<button style={styles.toggle}>ON</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
padding: 20,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 30,
|
||||
animation: 'slideInUp 0.4s ease-out',
|
||||
},
|
||||
floorPlanWrapper: {
|
||||
width: '100%',
|
||||
maxWidth: 900,
|
||||
height: 300,
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
borderRadius: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
},
|
||||
deckOutline: {
|
||||
width: '90%',
|
||||
height: '60%',
|
||||
border: '2px solid rgba(255,255,255,0.2)',
|
||||
borderRadius: '80px 80px 40px 40px',
|
||||
position: 'relative',
|
||||
},
|
||||
zone: {
|
||||
position: 'absolute',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
},
|
||||
zoneLabel: {
|
||||
fontSize: 10,
|
||||
color: 'var(--muted)',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
zoneStatus: {
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
},
|
||||
controls: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
background: 'var(--glass-bg)',
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
},
|
||||
controlHeader: {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
marginBottom: 15,
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--accent)',
|
||||
},
|
||||
controlRow: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
toggle: {
|
||||
background: 'var(--accent)',
|
||||
color: 'white',
|
||||
padding: '4px 12px',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}
|
||||
};
|
||||
149
dashboard/src/contexts/SpotifyContext.jsx
Normal file
149
dashboard/src/contexts/SpotifyContext.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { createContext, useContext, useState, useCallback } from 'react'
|
||||
|
||||
const SpotifyContext = createContext()
|
||||
|
||||
export function SpotifyProvider({ children }) {
|
||||
// Account management
|
||||
const [accounts, setAccounts] = useState(() => {
|
||||
const saved = localStorage.getItem('spotify_accounts')
|
||||
return saved ? JSON.parse(saved) : []
|
||||
})
|
||||
|
||||
// Zone to account mapping: { zoneId: accountId }
|
||||
const [zoneAccounts, setZoneAccounts] = useState(() => {
|
||||
const saved = localStorage.getItem('zone_accounts')
|
||||
return saved ? JSON.parse(saved) : {}
|
||||
})
|
||||
|
||||
// Active Spotify Connect instances per account
|
||||
const [spotifyConnects, setSpotifyConnects] = useState({})
|
||||
|
||||
// Add or update account
|
||||
const addAccount = useCallback((account) => {
|
||||
setAccounts(prev => {
|
||||
const filtered = prev.filter(a => a.id !== account.id)
|
||||
const updated = [...filtered, account]
|
||||
localStorage.setItem('spotify_accounts', JSON.stringify(updated))
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Remove account
|
||||
const removeAccount = useCallback((accountId) => {
|
||||
setAccounts(prev => {
|
||||
const updated = prev.filter(a => a.id !== accountId)
|
||||
localStorage.setItem('spotify_accounts', JSON.stringify(updated))
|
||||
return updated
|
||||
})
|
||||
|
||||
// Remove zone mappings for this account
|
||||
setZoneAccounts(prev => {
|
||||
const updated = { ...prev }
|
||||
Object.keys(updated).forEach(zoneId => {
|
||||
if (updated[zoneId] === accountId) delete updated[zoneId]
|
||||
})
|
||||
localStorage.setItem('zone_accounts', JSON.stringify(updated))
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Assign account to zone
|
||||
const assignAccountToZone = useCallback((zoneId, accountId) => {
|
||||
setZoneAccounts(prev => {
|
||||
const updated = { ...prev, [zoneId]: accountId }
|
||||
localStorage.setItem('zone_accounts', JSON.stringify(updated))
|
||||
return updated
|
||||
})
|
||||
|
||||
// Activate Spotify Connect for this account if not already active
|
||||
if (accountId && !spotifyConnects[accountId]) {
|
||||
activateSpotifyConnect(accountId)
|
||||
}
|
||||
}, [spotifyConnects])
|
||||
|
||||
// Remove account from zone
|
||||
const removeAccountFromZone = useCallback((zoneId) => {
|
||||
const accountId = zoneAccounts[zoneId]
|
||||
setZoneAccounts(prev => {
|
||||
const updated = { ...prev }
|
||||
delete updated[zoneId]
|
||||
localStorage.setItem('zone_accounts', JSON.stringify(updated))
|
||||
return updated
|
||||
})
|
||||
|
||||
// Deactivate Spotify Connect if no zones use this account
|
||||
if (accountId && !Object.values(zoneAccounts).includes(accountId)) {
|
||||
deactivateSpotifyConnect(accountId)
|
||||
}
|
||||
}, [zoneAccounts])
|
||||
|
||||
// Activate Spotify Connect for account
|
||||
const activateSpotifyConnect = useCallback((accountId) => {
|
||||
if (!accountId) return
|
||||
|
||||
setSpotifyConnects(prev => {
|
||||
if (prev[accountId]) return prev
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[accountId]: {
|
||||
id: `spotify-${accountId}`,
|
||||
status: 'active',
|
||||
device: 'Bordanlage',
|
||||
connectedAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Deactivate Spotify Connect for account
|
||||
const deactivateSpotifyConnect = useCallback((accountId) => {
|
||||
setSpotifyConnects(prev => {
|
||||
const updated = { ...prev }
|
||||
delete updated[accountId]
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Get account for zone
|
||||
const getAccountForZone = useCallback((zoneId) => {
|
||||
const accountId = zoneAccounts[zoneId]
|
||||
if (!accountId) return null
|
||||
return accounts.find(a => a.id === accountId)
|
||||
}, [zoneAccounts, accounts])
|
||||
|
||||
// Get zones using account
|
||||
const getZonesForAccount = useCallback((accountId) => {
|
||||
return Object.entries(zoneAccounts)
|
||||
.filter(([_, aId]) => aId === accountId)
|
||||
.map(([zId]) => zId)
|
||||
}, [zoneAccounts])
|
||||
|
||||
return (
|
||||
<SpotifyContext.Provider
|
||||
value={{
|
||||
accounts,
|
||||
addAccount,
|
||||
removeAccount,
|
||||
zoneAccounts,
|
||||
assignAccountToZone,
|
||||
removeAccountFromZone,
|
||||
spotifyConnects,
|
||||
activateSpotifyConnect,
|
||||
deactivateSpotifyConnect,
|
||||
getAccountForZone,
|
||||
getZonesForAccount,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SpotifyContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useSpotify() {
|
||||
const context = useContext(SpotifyContext)
|
||||
if (!context) {
|
||||
throw new Error('useSpotify must be used within SpotifyProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
35
dashboard/src/contexts/ThemeContext.jsx
Normal file
35
dashboard/src/contexts/ThemeContext.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
const ThemeContext = createContext()
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved) return saved === 'dark'
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (isDark) {
|
||||
root.classList.remove('light-mode')
|
||||
} else {
|
||||
root.classList.add('light-mode')
|
||||
}
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light')
|
||||
}, [isDark])
|
||||
|
||||
const toggleTheme = () => setIsDark(prev => !prev)
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ isDark, toggleTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return context
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const SERVICES = [
|
||||
{ id: 'signalk', name: 'SignalK', host: import.meta.env.VITE_SIGNALK_HOST || 'localhost', port: 3000, path: '/signalk' },
|
||||
{ id: 'snapserver', name: 'Snapcast', host: import.meta.env.VITE_SNAPCAST_HOST || 'localhost', port: 1780, path: '/' },
|
||||
{ id: 'mopidy', name: 'Mopidy', host: import.meta.env.VITE_MOPIDY_HOST || 'localhost', port: 6680, path: '/' },
|
||||
{ id: 'jellyfin', name: 'Jellyfin', host: import.meta.env.VITE_JELLYFIN_HOST || 'localhost', port: 8096, path: '/' },
|
||||
{ id: 'portainer', name: 'Portainer', host: import.meta.env.VITE_PORTAINER_HOST || 'localhost', port: 9000, path: '/' },
|
||||
{ id: 'signalk', name: 'SignalK', path: '/signalk' },
|
||||
{ id: 'snapserver', name: 'Snapcast', path: '/snapcast-ws' },
|
||||
{ id: 'mopidy', name: 'Mopidy', path: '/mopidy' },
|
||||
{ id: 'jellyfin', name: 'Jellyfin', path: '/jellyfin/' },
|
||||
{ id: 'portainer', name: 'Portainer', path: '/portainer' },
|
||||
{ id: 'spotify', name: 'Spotify', path: '/snapcast-ws' }, // Use snapcast as proxy for its status
|
||||
{ id: 'airplay', name: 'AirPlay', path: '/snapcast-ws' },
|
||||
]
|
||||
|
||||
async function ping(host, port, path) {
|
||||
async function ping(path) {
|
||||
try {
|
||||
const host = window.location.host
|
||||
// mode: 'no-cors' bypasses CORS blocks; any response (opaque) = server is up
|
||||
await fetch(`http://${host}:${port}${path}`, {
|
||||
await fetch(`http://${host}${path}`, {
|
||||
method: 'GET',
|
||||
mode: 'no-cors',
|
||||
signal: AbortSignal.timeout(3000),
|
||||
@@ -24,16 +27,26 @@ async function ping(host, port, path) {
|
||||
|
||||
export function useDocker() {
|
||||
const [services, setServices] = useState(
|
||||
SERVICES.map(s => ({ ...s, url: `http://${s.host}:${s.port}`, status: 'unknown' }))
|
||||
SERVICES.map(s => ({ ...s, url: `http://${window.location.host}${s.path}`, status: 'unknown' }))
|
||||
)
|
||||
|
||||
async function checkAll() {
|
||||
const results = await Promise.all(
|
||||
SERVICES.map(async s => ({
|
||||
...s,
|
||||
url: `http://${s.host}:${s.port}`,
|
||||
status: await ping(s.host, s.port, s.path) ? 'online' : 'offline',
|
||||
}))
|
||||
SERVICES.map(async s => {
|
||||
const url = `http://${window.location.host}${s.path}`
|
||||
let status = 'offline'
|
||||
|
||||
if (s.id === 'spotify' || s.id === 'airplay') {
|
||||
// These are special, they don't have their own UI
|
||||
// We assume they are up if snapserver is up (for now)
|
||||
// Ideally we check snapcast streams status
|
||||
status = await ping('/snapcast-ws') ? 'online' : 'offline'
|
||||
} else {
|
||||
status = await ping(s.path) ? 'online' : 'offline'
|
||||
}
|
||||
|
||||
return { ...s, url, status }
|
||||
})
|
||||
)
|
||||
setServices(results)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ function parseStatus(status) {
|
||||
|
||||
export function useZones() {
|
||||
const [zones, setZones] = useState([])
|
||||
const [streams, setStreams] = useState([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const { snapcast } = getApi()
|
||||
|
||||
@@ -28,6 +29,7 @@ export function useZones() {
|
||||
const status = await snapcast.call('Server.GetStatus')
|
||||
if (alive) {
|
||||
setZones(parseStatus(status))
|
||||
setStreams(status?.server?.streams || [])
|
||||
setConnected(true)
|
||||
}
|
||||
} catch {
|
||||
@@ -36,7 +38,10 @@ export function useZones() {
|
||||
}
|
||||
|
||||
const onUpdate = (msg) => {
|
||||
if (msg?.result?.server) setZones(parseStatus(msg.result))
|
||||
if (msg?.result?.server) {
|
||||
setZones(parseStatus(msg.result))
|
||||
setStreams(msg.result.server.streams || [])
|
||||
}
|
||||
}
|
||||
|
||||
snapcast.on('update', onUpdate)
|
||||
@@ -65,5 +70,5 @@ export function useZones() {
|
||||
if (zone) await setMuted(zoneId, !zone.muted)
|
||||
}, [zones, setMuted])
|
||||
|
||||
return { zones, connected, setVolume, setMuted, setSource, toggleZone }
|
||||
return { zones, streams, connected, setVolume, setMuted, setSource, toggleZone }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/* ─── CSS Custom Properties ───────────────────────────────────────────────── */
|
||||
:root {
|
||||
/* Dark Mode Colors */
|
||||
--bg: #07111f;
|
||||
--surface: #0a1928;
|
||||
--surface2: #0d2035;
|
||||
--border: #1e2a3a;
|
||||
--text: #e2eaf2;
|
||||
--muted: #4a6080;
|
||||
--accent: #38bdf8;
|
||||
--success: #34d399;
|
||||
|
||||
/* Brand Colors */
|
||||
--accent: #0ea5e9;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--spotify: #1DB954;
|
||||
@@ -18,6 +21,34 @@
|
||||
|
||||
--radius: 8px;
|
||||
--radius-lg: 16px;
|
||||
|
||||
/* Glassmorphism */
|
||||
--glass-bg: rgba(10, 25, 40, 0.7);
|
||||
--glass-blur: 12px;
|
||||
}
|
||||
|
||||
/* Light Mode */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f8fafc;
|
||||
--surface: #f1f5f9;
|
||||
--surface2: #e2e8f0;
|
||||
--border: #cbd5e1;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--glass-bg: rgba(241, 245, 249, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Light Mode Toggle */
|
||||
html.light-mode {
|
||||
--bg: #f8fafc;
|
||||
--surface: #f1f5f9;
|
||||
--surface2: #e2e8f0;
|
||||
--border: #cbd5e1;
|
||||
--text: #0f172a;
|
||||
--muted: #64748b;
|
||||
--glass-bg: rgba(241, 245, 249, 0.8);
|
||||
}
|
||||
|
||||
/* ─── Reset ───────────────────────────────────────────────────────────────── */
|
||||
@@ -39,11 +70,80 @@ html, body, #root {
|
||||
::-webkit-scrollbar-track { background: var(--surface); }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
|
||||
/* ─── Animations ───────────────────────────────────────────────────────────── */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -1000px 0; }
|
||||
100% { background-position: 1000px 0; }
|
||||
}
|
||||
|
||||
/* ─── Glassmorphic Components ───────────────────────────────────────────────── */
|
||||
.glass-panel {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--radius);
|
||||
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
background: rgba(10, 25, 40, 0.85);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 32px rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
|
||||
/* Light mode card hover */
|
||||
html.light-mode .glass-card:hover {
|
||||
background: rgba(241, 245, 249, 0.95);
|
||||
box-shadow: 0 8px 32px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
/* ─── Utilities ───────────────────────────────────────────────────────────── */
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.muted { color: var(--muted); }
|
||||
.accent { color: var(--accent); }
|
||||
|
||||
.fade-in { animation: fadeIn 0.3s ease-out; }
|
||||
.slide-in-up { animation: slideInUp 0.3s cubic-bezier(0.23, 1, 0.320, 1); }
|
||||
.slide-in-down { animation: slideInDown 0.3s cubic-bezier(0.23, 1, 0.320, 1); }
|
||||
.pulse { animation: pulse 2s ease-in-out infinite; }
|
||||
|
||||
/* ─── Button Variants ───────────────────────────────────────────────────────── */
|
||||
|
||||
/* ─── Button Variants ───────────────────────────────────────────────────────── */
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
@@ -56,9 +156,52 @@ button {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
transition: all 0.2s cubic-bezier(0.23, 1, 0.320, 1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
button:active {
|
||||
opacity: 0.8;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Primary Button */
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(14, 165, 233, 0.3);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background: #06b6d4;
|
||||
box-shadow: 0 6px 20px rgba(14, 165, 233, 0.4);
|
||||
}
|
||||
|
||||
/* Ghost Button */
|
||||
button.ghost {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
button.ghost:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Icon Button */
|
||||
button.icon {
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
button.icon:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button:active { opacity: 0.7; }
|
||||
|
||||
input[type=range] {
|
||||
-webkit-appearance: none;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// API router – uses real services by default; set VITE_USE_MOCK=true to force mocks.
|
||||
// API router – uses real services with automatic mock fallback if connection fails.
|
||||
import { createSignalKMock } from './signalk.mock.js'
|
||||
import { createSnapcastMock } from './snapcast.mock.js'
|
||||
import { createMopidyMock } from './mopidy.mock.js'
|
||||
import { createSignalKClient } from '../api/signalk.js'
|
||||
import { createSnapcastClient } from '../api/snapcast.js'
|
||||
import { createMopidyClient } from '../api/mopidy.js'
|
||||
import { createJellyfinClient } from '../api/jellyfin.js'
|
||||
|
||||
const forceMock = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
@@ -14,18 +15,82 @@ export function createApi() {
|
||||
signalk: createSignalKMock(),
|
||||
snapcast: createSnapcastMock(),
|
||||
mopidy: createMopidyMock(),
|
||||
jellyfin: createJellyfinClient('http://localhost:8090/jellyfin', 'fake-key'),
|
||||
isMock: true,
|
||||
}
|
||||
}
|
||||
|
||||
const snapcastHost = import.meta.env.VITE_SNAPCAST_HOST || 'localhost'
|
||||
const signalkHost = import.meta.env.VITE_SIGNALK_HOST || 'localhost'
|
||||
const mopidyHost = import.meta.env.VITE_MOPIDY_HOST || 'localhost'
|
||||
// Real clients - use proxy-friendly URLs (going through port 8090)
|
||||
const host = window.location.host
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const httpProtocol = window.location.protocol
|
||||
|
||||
// SignalK: baseUrl becomes ws://host/signalk (v1/stream appended in client)
|
||||
const signalkReal = createSignalKClient(`${protocol}//${host}`)
|
||||
|
||||
// Snapcast: ws://host/snapcast-ws/jsonrpc
|
||||
const snapcastUrl = `${protocol}//${host}/snapcast-ws/jsonrpc`
|
||||
const snapcastReal = createSnapcastClient(snapcastUrl)
|
||||
|
||||
// Mopidy: ws://host/mopidy (ws appended in client)
|
||||
const mopidyReal = createMopidyClient(`${protocol}//${host}`)
|
||||
|
||||
// Jellyfin: http://host/jellyfin
|
||||
const jellyfinReal = createJellyfinClient(`${httpProtocol}//${host}/jellyfin`, 'YOUR_JELLYFIN_API_KEY')
|
||||
|
||||
const signalkMock = createSignalKMock()
|
||||
|
||||
// Proxy that switches between real and mock
|
||||
const signalkProxy = {
|
||||
listeners: {},
|
||||
on: (event, handler) => {
|
||||
if (!signalkProxy.listeners[event]) signalkProxy.listeners[event] = []
|
||||
signalkProxy.listeners[event].push(handler)
|
||||
signalkReal.on(event, handler)
|
||||
signalkMock.on(event, handler)
|
||||
},
|
||||
off: (event, handler) => {
|
||||
if (signalkProxy.listeners[event]) {
|
||||
signalkProxy.listeners[event] = signalkProxy.listeners[event].filter(h => h !== handler)
|
||||
}
|
||||
signalkReal.off(event, handler)
|
||||
signalkMock.off(event, handler)
|
||||
},
|
||||
emit: (event, data) => {
|
||||
if (signalkProxy.listeners[event]) {
|
||||
signalkProxy.listeners[event].forEach(h => h(data))
|
||||
}
|
||||
},
|
||||
start: () => signalkMock.start(),
|
||||
stop: () => signalkMock.stop(),
|
||||
getSnapshot: () => signalkMock.getSnapshot(),
|
||||
}
|
||||
|
||||
let usingSignalKMock = false
|
||||
let signalkTimeout = setTimeout(() => {
|
||||
// If no real data after 5 seconds, switch to mock
|
||||
if (!usingSignalKMock) {
|
||||
console.log('⚠️ SignalK not connected - using mock data (EYC Lingen → Ems route)')
|
||||
usingSignalKMock = true
|
||||
signalkMock.start()
|
||||
signalkProxy.emit('connected', null)
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
// If real connection succeeds, cancel mock
|
||||
signalkReal.on('connected', () => {
|
||||
clearTimeout(signalkTimeout)
|
||||
if (usingSignalKMock) {
|
||||
signalkMock.stop()
|
||||
usingSignalKMock = false
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
signalk: createSignalKClient(`ws://${signalkHost}:3000`),
|
||||
snapcast: createSnapcastClient(`ws://${snapcastHost}:1705`),
|
||||
mopidy: createMopidyClient(`ws://${mopidyHost}:6680`),
|
||||
signalk: signalkProxy,
|
||||
snapcast: snapcastReal,
|
||||
mopidy: mopidyReal,
|
||||
jellyfin: jellyfinReal,
|
||||
isMock: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
// Simulates a SignalK WebSocket delta stream with realistic Baltic Sea boat data.
|
||||
// The ship navigates a realistic route around Bornholm Island, Baltic Sea.
|
||||
// Simulates a SignalK WebSocket delta stream with realistic boat data.
|
||||
// Route: Lokale Fahrt in Lingen über DEK und Ems (bleibt in der Region)
|
||||
|
||||
const INTERVAL_MS = 1000
|
||||
|
||||
function degToRad(d) { return d * Math.PI / 180 }
|
||||
function radToDeg(r) { return r * 180 / Math.PI }
|
||||
|
||||
// Realistic sailing route around Bornholm Island, Baltic Sea
|
||||
// Lokale Route: Lingen Bereich - DEK und Ems (bleibt in der Region)
|
||||
const WAYPOINTS = [
|
||||
{ lat: 54.3233, lon: 10.1394, name: 'Kiel Fjord (Start)' },
|
||||
{ lat: 55.0500, lon: 13.5500, name: 'Bornholm North' },
|
||||
{ lat: 55.1200, lon: 14.8000, name: 'Rønne Harbor' },
|
||||
{ lat: 54.9500, lon: 15.2000, name: 'Bornholm East' },
|
||||
{ lat: 54.5800, lon: 14.9000, name: 'Bornholm South' },
|
||||
{ lat: 54.1500, lon: 13.2000, name: 'Gdansk Approach' },
|
||||
{ lat: 54.3233, lon: 10.1394, name: 'Kiel Fjord (Loop)' },
|
||||
{ lat: 52.5236, lon: 7.3200, name: 'EYC Segelclub Lingen' },
|
||||
{ lat: 52.5280, lon: 7.3150, name: 'Kanal Ausfahrt' },
|
||||
{ lat: 52.5400, lon: 7.3000, name: 'DEK Westlich' },
|
||||
{ lat: 52.5500, lon: 7.2800, name: 'DEK Schleife West' },
|
||||
{ lat: 52.5600, lon: 7.2700, name: 'DEK Nord' },
|
||||
{ lat: 52.5700, lon: 7.2800, name: 'Brücke Nord' },
|
||||
{ lat: 52.5750, lon: 7.3000, name: 'Ems Einfahrt' },
|
||||
{ lat: 52.5800, lon: 7.3200, name: 'Ems Fluss Ost' },
|
||||
{ lat: 52.5750, lon: 7.3400, name: 'Ems Kurve' },
|
||||
{ lat: 52.5650, lon: 7.3500, name: 'Ems Süd' },
|
||||
{ lat: 52.5500, lon: 7.3450, name: 'Ems Rückkehr' },
|
||||
{ lat: 52.5400, lon: 7.3350, name: 'Kanal Rückkehr' },
|
||||
{ lat: 52.5300, lon: 7.3250, name: 'Hafen Approach' },
|
||||
{ lat: 52.5236, lon: 7.3200, name: 'EYC Segelclub (Ziel)' },
|
||||
]
|
||||
|
||||
// Calculate distance between two coordinates in nautical miles
|
||||
@@ -69,6 +76,7 @@ export function createSignalKMock() {
|
||||
waterUsed: 23,
|
||||
wasteWater: 18,
|
||||
freshWater: 156,
|
||||
trackPoints: [{ lat: WAYPOINTS[0].lat, lon: WAYPOINTS[0].lon }],
|
||||
}
|
||||
|
||||
// Navigate to next waypoint
|
||||
@@ -115,6 +123,12 @@ export function createSignalKMock() {
|
||||
|
||||
state.lat += dLat
|
||||
state.lon += dLon
|
||||
|
||||
// Record track point (keep last 500)
|
||||
state.trackPoints.push({ lat: state.lat, lon: state.lon })
|
||||
if (state.trackPoints.length > 500) {
|
||||
state.trackPoints.shift()
|
||||
}
|
||||
}
|
||||
|
||||
function buildDelta() {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import NowPlaying from '../components/audio/NowPlaying.jsx'
|
||||
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
|
||||
import SpotifyAccountManager from '../components/audio/SpotifyAccountManager.jsx'
|
||||
import SourcePicker from '../components/audio/SourcePicker.jsx'
|
||||
import RadioBrowser from '../components/audio/RadioBrowser.jsx'
|
||||
import LibraryBrowser from '../components/audio/LibraryBrowser.jsx'
|
||||
|
||||
const SUB_TABS = ['Zones', 'Radio', 'Library']
|
||||
const SUB_TABS = ['Zones', 'Accounts', 'Radio', 'Library']
|
||||
|
||||
export default function Audio() {
|
||||
const [subTab, setSubTab] = useState('Zones')
|
||||
@@ -28,9 +29,10 @@ export default function Audio() {
|
||||
</div>
|
||||
|
||||
<div style={styles.content}>
|
||||
{subTab === 'Zones' && <ZoneGrid />}
|
||||
{subTab === 'Radio' && <RadioBrowser />}
|
||||
{subTab === 'Library' && <LibraryBrowser />}
|
||||
{subTab === 'Zones' && <ZoneGrid />}
|
||||
{subTab === 'Accounts' && <SpotifyAccountManager />}
|
||||
{subTab === 'Radio' && <RadioBrowser />}
|
||||
{subTab === 'Library' && <LibraryBrowser />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -40,10 +42,10 @@ const styles = {
|
||||
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flex: 1 },
|
||||
subTabs: { display: 'flex', gap: 2, background: 'var(--surface)', borderRadius: 'var(--radius)', padding: 3, border: '1px solid var(--border)' },
|
||||
subTab: {
|
||||
flex: 1, height: 36, fontSize: 13, fontWeight: 500,
|
||||
color: 'var(--muted)', borderRadius: 6,
|
||||
background: 'none', minHeight: 36,
|
||||
flex: 1, height: 36, fontSize: 12, fontWeight: 600,
|
||||
color: 'var(--muted)', borderRadius: 'var(--radius)',
|
||||
background: 'none', minHeight: 36, transition: 'all 0.2s',
|
||||
},
|
||||
subTabActive: { background: 'var(--surface2)', color: 'var(--text)' },
|
||||
content: { flex: 1 },
|
||||
subTabActive: { background: 'var(--accent)', color: 'white' },
|
||||
content: { flex: 1, overflowY: 'auto' },
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import ChartPlaceholder from '../components/nav/ChartPlaceholder.jsx'
|
||||
import NavigationMap from '../components/nav/NavigationMap.jsx'
|
||||
import InstrumentPanel from '../components/nav/InstrumentPanel.jsx'
|
||||
|
||||
export default function Navigation() {
|
||||
return (
|
||||
<div style={styles.layout}>
|
||||
<div style={styles.chart}>
|
||||
<ChartPlaceholder />
|
||||
<NavigationMap />
|
||||
</div>
|
||||
<div style={styles.panel}>
|
||||
<InstrumentPanel />
|
||||
|
||||
@@ -1,42 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import { useNMEA } from '../hooks/useNMEA.js'
|
||||
import Compass from '../components/instruments/Compass.jsx'
|
||||
import SpeedLog from '../components/instruments/SpeedLog.jsx'
|
||||
import DepthSounder from '../components/instruments/DepthSounder.jsx'
|
||||
import WindRose from '../components/instruments/WindRose.jsx'
|
||||
import NowPlaying from '../components/audio/NowPlaying.jsx'
|
||||
import BoatControl from '../components/systems/BoatControl.jsx'
|
||||
import FloorPlan from '../components/systems/FloorPlan.jsx'
|
||||
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
|
||||
import RadioBrowser from '../components/audio/RadioBrowser.jsx'
|
||||
import LibraryBrowser from '../components/audio/LibraryBrowser.jsx'
|
||||
import InstrumentPanel from '../components/nav/InstrumentPanel.jsx'
|
||||
import EngineData from '../components/systems/EngineData.jsx'
|
||||
import BatteryStatus from '../components/systems/BatteryStatus.jsx'
|
||||
|
||||
export default function Overview() {
|
||||
const nmea = useNMEA()
|
||||
const [activeCategory, setActiveCategory] = useState(null)
|
||||
|
||||
const handleBack = () => setActiveCategory(null)
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
{/* Instruments row */}
|
||||
<section style={styles.instruments}>
|
||||
<Compass heading={nmea.heading} cog={nmea.cog} />
|
||||
<SpeedLog sog={nmea.sog} />
|
||||
<DepthSounder depth={nmea.depth} />
|
||||
<WindRose windAngle={nmea.windAngle} windSpeed={nmea.windSpeed} />
|
||||
</section>
|
||||
|
||||
{/* Now Playing */}
|
||||
<NowPlaying compact />
|
||||
|
||||
{/* Zone quick overview */}
|
||||
<section>
|
||||
<div style={styles.sectionTitle}>Audio Zones</div>
|
||||
<ZoneGrid />
|
||||
</section>
|
||||
{!activeCategory ? (
|
||||
<BoatControl
|
||||
activeCategory={activeCategory}
|
||||
onCategoryChange={setActiveCategory}
|
||||
/>
|
||||
) : (
|
||||
<div style={styles.detailView}>
|
||||
<div style={styles.detailHeader}>
|
||||
<button onClick={handleBack} style={styles.backBtn}>← Back</button>
|
||||
<h2 style={styles.detailTitle}>{activeCategory.toUpperCase()}</h2>
|
||||
</div>
|
||||
|
||||
<div style={styles.detailContent}>
|
||||
{activeCategory === 'lights' && <FloorPlan type="lights" />}
|
||||
{activeCategory === 'climate' && <FloorPlan type="climate" />}
|
||||
{activeCategory === 'audio' && (
|
||||
<div style={styles.audioWrapper}>
|
||||
<ZoneGrid />
|
||||
</div>
|
||||
)}
|
||||
{activeCategory === 'nav' && <InstrumentPanel />}
|
||||
{activeCategory === 'engine' && <EngineData />}
|
||||
{activeCategory === 'battery' && <BatteryStatus />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 16, overflow: 'auto', flex: 1 },
|
||||
instruments: {
|
||||
display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center',
|
||||
padding: 16, background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
|
||||
page: {
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: '#07111f',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
sectionTitle: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 10 },
|
||||
detailView: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
animation: 'fadeIn 0.4s ease-out',
|
||||
padding: 20,
|
||||
background: 'radial-gradient(circle at center, #1a2a3a 0%, #07111f 100%)',
|
||||
},
|
||||
detailHeader: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
backBtn: {
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: 'var(--text)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: 8,
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
},
|
||||
detailTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 300,
|
||||
letterSpacing: '0.2em',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
margin: 0,
|
||||
},
|
||||
detailContent: {
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: '0 10px',
|
||||
},
|
||||
audioWrapper: {
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}
|
||||
}
|
||||
|
||||
5
dev.bat
Normal file
5
dev.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
echo Starting boWave Development Environment...
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
|
||||
echo Dashboard will be available at http://localhost:8090
|
||||
pause
|
||||
@@ -9,17 +9,25 @@ services:
|
||||
environment:
|
||||
- SIGNALK_DEMO=true # Built-in demo NMEA data generator
|
||||
|
||||
# Librespot: stub in dev (pipe backend doesn't cross VM boundary).
|
||||
# For real Spotify on Mac run: make spotify
|
||||
librespot:
|
||||
build:
|
||||
context: ./docker/librespot
|
||||
dockerfile: Dockerfile.dev
|
||||
entrypoint: []
|
||||
command: ["sh", "-c", "echo 'librespot stub: run make spotify for Mac audio' && sleep infinity"]
|
||||
restart: "no"
|
||||
volumes: []
|
||||
ports: []
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "57621:57621/udp"
|
||||
- "57621:57621/tcp"
|
||||
command: [
|
||||
"--name", "${SPOTIFY_NAME:-Bordanlage} (Dev)",
|
||||
"--bitrate", "${SPOTIFY_BITRATE:-320}",
|
||||
"--backend", "pipe",
|
||||
"--device", "/tmp/audio/spotify.pcm",
|
||||
"--zeroconf-port", "57621"
|
||||
]
|
||||
volumes:
|
||||
- pipes:/tmp/audio
|
||||
networks:
|
||||
- bordanlage
|
||||
restart: unless-stopped
|
||||
|
||||
# Zones: real snapclient containers with null player (v0.35+, URI format)
|
||||
zone-salon:
|
||||
@@ -44,7 +52,7 @@ services:
|
||||
volumes:
|
||||
- ./dashboard:/app
|
||||
- /app/node_modules
|
||||
command: ["sh", "-c", "npm install && npm run dev -- --host 0.0.0.0 --port 8090"]
|
||||
command: ["sh", "-c", "npm install --legacy-peer-deps && npm run dev -- --host 0.0.0.0 --port 8090"]
|
||||
ports:
|
||||
- "8090:8090"
|
||||
environment:
|
||||
@@ -52,4 +60,4 @@ services:
|
||||
- VITE_SIGNALK_HOST=localhost
|
||||
- VITE_MOPIDY_HOST=localhost
|
||||
- VITE_JELLYFIN_HOST=localhost
|
||||
- VITE_USE_MOCK=true # Use mock data for dev testing (real APIs when false)
|
||||
- VITE_USE_MOCK=false # FALSE = Nutze echte Services (Spotify, AirPlay, Mopidy)
|
||||
|
||||
@@ -37,8 +37,6 @@ services:
|
||||
networks:
|
||||
- bordanlage
|
||||
|
||||
# ─── Audio Sources ─────────────────────────────────────────────────────────
|
||||
|
||||
librespot:
|
||||
build: ./docker/librespot
|
||||
restart: unless-stopped
|
||||
@@ -47,12 +45,13 @@ services:
|
||||
ports:
|
||||
- "57621:57621/udp" # Spotify zeroconf discovery
|
||||
- "57621:57621/tcp"
|
||||
command: >
|
||||
--name "${SPOTIFY_NAME:-Bordanlage}"
|
||||
--bitrate ${SPOTIFY_BITRATE:-320}
|
||||
--backend pipe
|
||||
--device /tmp/audio/spotify.pcm
|
||||
--zeroconf-port 57621
|
||||
command: [
|
||||
"--name", "${SPOTIFY_NAME:-Bordanlage}",
|
||||
"--bitrate", "${SPOTIFY_BITRATE:-320}",
|
||||
"--backend", "pipe",
|
||||
"--device", "/tmp/audio/spotify.pcm",
|
||||
"--zeroconf-port", "57621"
|
||||
]
|
||||
volumes:
|
||||
- pipes:/tmp/audio
|
||||
networks:
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
# Stage 1: Build librespot from source
|
||||
# Pin to v0.5.0 — v0.8.0 has a vergen_lib dependency conflict
|
||||
FROM rust:slim-bookworm AS builder
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends pkg-config libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN cargo install librespot --version "=0.5.0"
|
||||
# Use the official librespot image
|
||||
FROM ghcr.io/librespot-org/librespot:latest
|
||||
|
||||
# Stage 2: Minimal runtime image
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /usr/local/cargo/bin/librespot /usr/local/bin/librespot
|
||||
ENTRYPOINT ["librespot"]
|
||||
# The official image already has the entrypoint set to librespot
|
||||
# We just need to ensure it's used correctly in docker-compose
|
||||
|
||||
@@ -1,3 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Create the audio directory if it doesn't exist
|
||||
mkdir -p /tmp/audio
|
||||
|
||||
# Create the named pipes if they don't exist
|
||||
for pipe in spotify.pcm airplay.pcm mopidy.pcm; do
|
||||
path="/tmp/audio/$pipe"
|
||||
if [ ! -p "$path" ]; then
|
||||
mkfifo "$path"
|
||||
echo "Created pipe: $path"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "All audio pipes ready. Starting Mopidy..."
|
||||
exec "$@"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
FROM debian:bookworm-slim
|
||||
ARG VERSION=0.35.0
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates wget \
|
||||
&& ARCH=$(dpkg --print-architecture) \
|
||||
&& wget -q "https://github.com/badaix/snapcast/releases/download/v${VERSION}/snapclient_${VERSION}-1_${ARCH}_bookworm.deb" \
|
||||
-O /tmp/snapclient.deb \
|
||||
&& dpkg -i /tmp/snapclient.deb || apt-get install -fy \
|
||||
&& rm /tmp/snapclient.deb \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENTRYPOINT ["snapclient"]
|
||||
RUN ARCH=$(dpkg --print-architecture) \
|
||||
&& curl -L "https://github.com/badaix/snapcast/releases/download/v${VERSION}/snapclient_${VERSION}-1_${ARCH}_bookworm.deb" -o /tmp/snapclient.deb \
|
||||
&& dpkg -i /tmp/snapclient.deb || apt-get install -fy \
|
||||
&& rm /tmp/snapclient.deb
|
||||
|
||||
ENTRYPOINT ["/usr/bin/snapclient"]
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
FROM debian:bookworm-slim
|
||||
ARG VERSION=0.35.0
|
||||
ARG WEB_VERSION=0.9.1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates wget \
|
||||
&& ARCH=$(dpkg --print-architecture) \
|
||||
&& wget -q "https://github.com/badaix/snapcast/releases/download/v${VERSION}/snapserver_${VERSION}-1_${ARCH}_bookworm.deb" \
|
||||
-O /tmp/snapserver.deb \
|
||||
&& dpkg -i /tmp/snapserver.deb || apt-get install -fy \
|
||||
&& rm /tmp/snapserver.deb \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN ARCH=$(dpkg --print-architecture) \
|
||||
&& curl -L "https://github.com/badaix/snapcast/releases/download/v${VERSION}/snapserver_${VERSION}-1_${ARCH}_bookworm.deb" -o /tmp/snapserver.deb \
|
||||
&& dpkg -i /tmp/snapserver.deb || apt-get install -fy \
|
||||
&& rm /tmp/snapserver.deb
|
||||
|
||||
RUN curl -L "https://github.com/badaix/snapweb/releases/download/v${WEB_VERSION}/snapweb.zip" -o /tmp/snapweb.zip \
|
||||
&& mkdir -p /usr/share/snapserver/snapweb \
|
||||
&& unzip -o /tmp/snapweb.zip -d /usr/share/snapserver/snapweb \
|
||||
&& rm /tmp/snapweb.zip
|
||||
|
||||
EXPOSE 1704 1705 1780
|
||||
CMD ["snapserver"]
|
||||
|
||||
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
BIN
photo_2026-04-02_02-11-20.jpg
Normal file
BIN
photo_2026-04-02_02-11-20.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 180 KiB |
BIN
photo_2026-04-02_02-11-30.jpg
Normal file
BIN
photo_2026-04-02_02-11-30.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
Reference in New Issue
Block a user