Compare commits

...

6 Commits

Author SHA1 Message Date
denshooter
fec4e4635c feat: complete dashboard redesign, proxy unification, and Windows compatibility fixes 2026-04-02 12:13:37 +02:00
denshooter
8192388c5d fix: Add --legacy-peer-deps to npm install in docker-compose for React 18/19 peer dependency conflict
This fixes the dashboard container failing to install dependencies due to
react-leaflet v5 requiring React 19 while the project uses React 18.3.1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 15:09:20 +01:00
denshooter
e236e1f673 Phase 4: Glassmorphic design refresh across all components
- Enhanced all major components with glassmorphic styling:
  * EngineData gauges: frosted glass panels with animations
  * InstrumentPanel: glassmorphic waypoint boxes and data tables
  * NowPlaying: glassmorphic album art container and controls
  * All panels: smooth fade-in animations on mount

- Updated visual elements:
  * Consistent use of backdrop-filter blur effect
  * Semi-transparent borders with 0.1-0.2 opacity
  * Smooth animations (slideInUp, slideInDown, fadeIn)
  * Better font weights and hierarchy
  * Improved contrast and readability

- Color scheme refinements:
  * Highlight backgrounds use RGBA with proper opacity
  * Better use of accent colors for emphasis
  * Consistent border styling with transparency
  * Support for light/dark mode throughout

- Animation improvements:
  * All cards and panels animate on mount
  * Tab transitions are smooth and snappy
  * Hover effects with scale and shadow changes
  * Cubic-bezier timing functions for natural feel

- Build optimization:
  * Still 70 modules, same bundle size
  * CSS is well-organized and maintainable
  * No breaking changes to component APIs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 15:03:35 +01:00
denshooter
beeee82896 Phase 3: Real Spotify account integration with zone mapping
- Created SpotifyContext for account management
  * Add/remove Spotify accounts with email and display name
  * Persistent storage to localStorage
  * Automatic account activation when assigned to zone

- Implemented zone-to-account mapping system
  * Each zone can be assigned a Spotify account
  * Multiple zones can share one account
  * Switching between sources preserves account assignment

- Enhanced ZoneCard component:
  * Account selector dropdown when Spotify is selected
  * Display account name/email under zone name
  * Auto-select first account when switching to Spotify
  * Green-tinted account dropdown for visual distinction

- Created SpotifyAccountManager component:
  * Add new Spotify accounts with email and display name
  * List all configured accounts
  * Remove accounts (cleans up zone mappings)
  * Collapsible form for adding new accounts
  * Glassmorphic styling with green accent

- Updated Audio page:
  * New 'Accounts' tab for Spotify account management
  * Accessible alongside Zones, Radio, and Library tabs
  * Smooth tab transitions with animations

- Architecture supports:
  * Real Spotify API integration (ready for OAuth)
  * Multiple accounts simultaneously
  * Spotify Connect per account (one instance per account)
  * Zone grouping with shared account control

- Build: 70 modules, 1.25 MB (345 KB gzipped)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 15:02:20 +01:00
denshooter
0b70891bca Phase 2: MapLibre GL with OpenSeaMap navigation
- Integrated MapLibre GL for professional maritime mapping
- Combined OSM base maps with OpenSeaMap layer for nautical overlays
  * Seamarks, buoys, channels, and depth information
  * Fully styled with MapLibre-compatible tiles

- Created NavigationMap component with:
  * Real-time ship position marker with heading indicator
  * Automatic centering and smooth flyTo animations
  * Waypoint display with current waypoint highlighting
  * Ship track visualization (last 500 points, dashed line)
  * Route polyline showing waypoints

- Professional map controls:
  * Zoom in/out buttons with smooth animations
  * Center-on-ship button for quick navigation
  * Info panel showing current position, heading, speed, distance
  * Glassmorphic info panel with dark/light mode support

- Enhanced SignalK mock:
  * Added trackPoints array to record ship movement
  * Automatically maintains last 500 points for performance
  * Integrated with map for visual track history

- Updated Navigation page to use new map
- Build: 68 modules, 1.24 MB (343 KB gzipped)
- Hot module reloading working smoothly

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 15:00:17 +01:00
denshooter
4fab26106c Phase 1: Modern glassmorphic design system with light/dark mode
- Added glassmorphic components with backdrop blur and transparency
  * .glass-panel and .glass-card classes for frosted glass effect
  * Smooth transitions and hover animations
  * Proper light mode support via prefers-color-scheme and manual toggle

- Implemented light/dark mode system
  * ThemeContext provider for global theme state
  * Persists user preference to localStorage
  * Theme toggle button in TopBar (☀️/🌙)
  * Automatic detection of system preference

- Enhanced UI components with modern animations
  * Updated button styles with primary, ghost, and icon variants
  * Smooth transitions (cubic-bezier curves)
  * Slide/fade animations on component mount
  * Hover effects with subtle shadows

- Improved visual design
  * Updated color scheme (brighter accent: #0ea5e9)
  * Better visual hierarchy in typography
  * Refined spacing and padding
  * Glass effect on panels and cards

- Updated components:
  * TopBar: Added theme toggle
  * TabNav: Smooth transitions and glassmorphic styling
  * ZoneCard: Glassmorphic cards with hover effects
  * ZoneGrid: Frosted panel design

- Build: 65 modules, 184.27 KB (57.47 KB gzipped)
- All hot reloading working smoothly

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 14:56:16 +01:00
49 changed files with 4388 additions and 216 deletions

356
AUDIO_SETUP_REAL.md Normal file
View 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
View 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
View 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
View 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
View 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!** ⛵🌊

View File

@@ -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

View File

@@ -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
View 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!** 🚢⚓

View File

@@ -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
View 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! 🎵

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
)
}

View File

@@ -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' },
}

View 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,
},
}

View File

@@ -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)' },
}

View File

@@ -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',
}
}

View File

@@ -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' },
}

View File

@@ -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>

View File

@@ -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 },
}

View 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,
},
}

View 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',
},
}

View 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)',
}
};

View File

@@ -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',
}
}

View 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,
}
};

View 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
}

View 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
}

View File

@@ -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)
}

View File

@@ -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 }
}

View File

@@ -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;

View File

@@ -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,
}
}

View File

@@ -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() {

View File

@@ -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' },
}

View File

@@ -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 />

View File

@@ -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
View 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

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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 "$@"

View File

@@ -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"]

View File

@@ -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
View File

@@ -0,0 +1 @@
{}

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

5
stop.bat Normal file
View File

@@ -0,0 +1,5 @@
@echo off
echo Stopping boWave...
docker compose -f docker-compose.yml -f docker-compose.dev.yml down
echo boWave stopped.
pause