Initial implementation: Bordanlage boat onboard system

Complete multiroom audio + navigation dashboard:
- Docker stack: SignalK, Snapcast (4 zones), librespot, shairport-sync, Mopidy, Jellyfin, Portainer
- React 18 + Vite dashboard with nautical dark theme
- Full mock system (SignalK NMEA simulation, Snapcast zones, Mopidy player)
- Real API clients for all services with reconnect logic
- SVG instruments: Compass, WindRose, Gauge, DepthSounder, SpeedLog
- Pages: Overview, Navigation, Audio (zones/radio/library), Systems
- Dev mode runs fully without hardware (make dev)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 11:47:33 +01:00
commit 946c0a5377
57 changed files with 3450 additions and 0 deletions

25
bordanlage/.env Normal file
View File

@@ -0,0 +1,25 @@
# General
COMPOSE_PROJECT_NAME=bordanlage
DEV=true
# Spotify Connect
SPOTIFY_NAME=Bordanlage
SPOTIFY_BITRATE=320
SPOTIFY_CACHE_SIZE=1024
# Boat Info
BOAT_NAME=My Yacht
BOAT_MMSI=123456789
# Paths
MUSIC_PATH=./music
# Jellyfin API Key (set after first run)
JELLYFIN_API_KEY=
# Service URLs (used by dashboard)
VITE_SNAPCAST_HOST=localhost
VITE_SIGNALK_HOST=localhost
VITE_MOPIDY_HOST=localhost
VITE_JELLYFIN_HOST=localhost
VITE_PORTAINER_HOST=localhost

25
bordanlage/.env.example Normal file
View File

@@ -0,0 +1,25 @@
# General
COMPOSE_PROJECT_NAME=bordanlage
DEV=true
# Spotify Connect
SPOTIFY_NAME=Bordanlage
SPOTIFY_BITRATE=320
SPOTIFY_CACHE_SIZE=1024
# Boat Info
BOAT_NAME=My Yacht
BOAT_MMSI=123456789
# Paths
MUSIC_PATH=./music
# Jellyfin API Key (set after first run)
JELLYFIN_API_KEY=
# Service URLs (used by dashboard)
VITE_SNAPCAST_HOST=localhost
VITE_SIGNALK_HOST=localhost
VITE_MOPIDY_HOST=localhost
VITE_JELLYFIN_HOST=localhost
VITE_PORTAINER_HOST=localhost

22
bordanlage/Makefile Normal file
View File

@@ -0,0 +1,22 @@
.PHONY: dev boot stop logs rebuild status pipes
dev: pipes
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
boot: pipes
docker compose up -d
stop:
docker compose down
logs:
docker compose logs -f
rebuild:
docker compose build --no-cache
status:
docker compose ps
pipes:
@bash scripts/init-pipes.sh

125
bordanlage/README.md Normal file
View File

@@ -0,0 +1,125 @@
# Bordanlage Boat Onboard System
A complete multiroom audio + navigation dashboard system for boats.
Runs on any Docker-capable computer fully simulated in dev mode (no hardware needed).
---
## Prerequisites
- [Docker Desktop](https://www.docker.com/products/docker-desktop/) (or Docker Engine + Compose)
- `make`
- For Spotify Connect: a Spotify Premium account
---
## Quick Start
```bash
cd bordanlage
make dev
```
Dashboard: **http://localhost:8080**
---
## Service URLs
| Service | URL | Description |
|---------------|------------------------------|------------------------------|
| Dashboard | http://localhost:8080 | Main touch UI |
| SignalK | http://localhost:3000 | Navigation data + chart viewer |
| Mopidy/Iris | http://localhost:6680/iris/ | Music player UI |
| Snapcast Web | http://localhost:1780 | Multiroom audio control |
| Jellyfin | http://localhost:8096 | Media library |
| Portainer | http://localhost:9000 | Docker management |
---
## Spotify Connect
1. Run `make dev` (or `make boot` on the boat)
2. Open Spotify on your phone/computer
3. Tap the device icon (bottom right) → look for **"Bordanlage"**
4. If it doesn't appear automatically: Go to **Connect to a Device** → enter the IP of the host machine manually
> On Linux (boat): set `network_mode: host` for the `librespot` service in `docker-compose.yml` for reliable mDNS discovery.
---
## AirPlay
1. Ensure the `shairport` container is running
2. On your iPhone/Mac: open Control Center → tap AirPlay → select **"Bordanlage AirPlay"**
> On Mac: AirPlay works natively via Bonjour.
> On Windows WSL2: the `avahi` container in `docker-compose.dev.yml` handles mDNS.
---
## Adding Music
Drop audio files into `./music/`. Mopidy and Jellyfin both mount this directory.
Trigger a Mopidy library scan:
```bash
curl -s http://localhost:6680/mopidy/rpc -d '{"jsonrpc":"2.0","id":1,"method":"local.scan"}' \
-H "Content-Type: application/json"
```
For Jellyfin: open http://localhost:8096 → Settings → Libraries → Scan.
---
## Connecting Real NMEA Hardware
Edit `docker-compose.yml` and uncomment the `signalk` device section:
```yaml
signalk:
devices:
- /dev/ttyUSB0:/dev/ttyUSB0 # NMEA 0183 via USB-Serial
```
Then configure the NMEA connection in SignalK at http://localhost:3000 → Server → Connections.
For NMEA 2000: use a Yacht Devices YDNU-02 or similar USB gateway.
---
## Migration to Real Boat
Changes needed in `docker-compose.yml`:
1. **Audio output per zone**: add `--soundcard hw:N,0` to each `zone-*` command and uncomment `/dev/snd`
2. **Spotify/AirPlay discovery**: set `network_mode: host` for `librespot` and `shairport`
3. **Hardware video decoding** (optional): uncomment `/dev/dri` in `jellyfin`
4. **NMEA hardware**: uncomment `/dev/ttyUSB0` in `signalk`
5. Set `DEV=false` in `.env`
Run `make boot` instead of `make dev`.
---
## Troubleshooting
**Spotify device not showing up on Mac:**
- Ensure port 57621 (UDP+TCP) is accessible. Docker Desktop on Mac sometimes blocks UDP.
- Try connecting manually: Spotify → "Connect to a Device" → "Connect to [IP]"
**AirPlay not visible on Windows:**
- The `avahi` container requires D-Bus. Run Docker Desktop with host networking or use WSL2.
**Snapcast zones show as offline:**
- Audio pipes must exist before snapserver starts. Run `make pipes` or `bash scripts/init-pipes.sh`
**Mopidy won't start:**
- Check `docker compose logs mopidy`. The custom Dockerfile installs plugins on first build; rebuild with `make rebuild`
**Dashboard shows "No signal":**
- In dev mode this is normal until mock data initializes (12 seconds)
- In production: check that SignalK is running and the WebSocket URL is correct in `.env`
**Port conflicts:**
- Edit the port mappings in `docker-compose.yml` or `docker-compose.dev.yml`

View File

@@ -0,0 +1,32 @@
[core]
data_dir = /var/lib/mopidy
[logging]
verbosity = 1
[audio]
output = audioresample ! audioconvert ! audio/x-raw,rate=44100,channels=2,format=S16LE ! filesink location=/tmp/audio/mopidy.pcm
[http]
enabled = true
hostname = 0.0.0.0
port = 6680
allowed_origins =
[mpd]
enabled = false
[local]
enabled = true
media_dir = /music
scan_timeout = 1000
[stream]
enabled = true
protocols = http, https, mms, rtmp, rtsp
[tunein]
enabled = true
[iris]
enabled = true

View File

@@ -0,0 +1,48 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Serve React app HTML5 history mode
location / {
try_files $uri $uri/ /index.html;
}
# Proxy SignalK
location /signalk/ {
proxy_pass http://signalk:3000/signalk/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
# Proxy Snapcast WebSocket API
location /snapcast/ {
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;
}
# Proxy Mopidy
location /mopidy/ {
proxy_pass http://mopidy:6680/mopidy/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
# Proxy Jellyfin
location /jellyfin/ {
proxy_pass http://jellyfin:8096/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript;
}

View File

@@ -0,0 +1,15 @@
general = {
name = "Bordanlage AirPlay";
port = 5000;
interpolation = "auto";
output_backend = "pipe";
};
pipe = {
name = "/tmp/audio/airplay.pcm";
};
metadata = {
enabled = yes;
include_cover_art = yes;
};

View File

@@ -0,0 +1,16 @@
[stream]
source = pipe:///tmp/audio/spotify.pcm?name=Spotify&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
source = pipe:///tmp/audio/airplay.pcm?name=AirPlay&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
source = pipe:///tmp/audio/mopidy.pcm?name=Mopidy&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
[server]
threads = -1
[http]
enabled = true
bind_to_address = 0.0.0.0
port = 1780
[logging]
sink = system
filter = *:info

View File

@@ -0,0 +1,14 @@
# Stage 1: Build React app
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve with nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Bordanlage</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,13 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
}

View File

@@ -0,0 +1,19 @@
{
"name": "bordanlage-dashboard",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
}
}

View File

@@ -0,0 +1,46 @@
import { useState } from 'react'
import TopBar from './components/layout/TopBar.jsx'
import TabNav from './components/layout/TabNav.jsx'
import Overview from './pages/Overview.jsx'
import Navigation from './pages/Navigation.jsx'
import Audio from './pages/Audio.jsx'
import Systems from './pages/Systems.jsx'
const PAGES = {
overview: Overview,
navigation: Navigation,
audio: Audio,
systems: Systems,
}
export default function App() {
const [tab, setTab] = useState('overview')
const Page = PAGES[tab] || Overview
return (
<div style={styles.app}>
<TopBar />
<TabNav activeTab={tab} onTabChange={setTab} />
<main style={styles.main}>
<Page />
</main>
</div>
)
}
const styles = {
app: {
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
background: 'var(--bg)',
},
main: {
flex: 1,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
minHeight: 0,
},
}

View File

@@ -0,0 +1,36 @@
// Jellyfin REST API client.
export function createJellyfinClient(baseUrl, apiKey) {
const headers = {
'Authorization': `MediaBrowser Token="${apiKey}", Client="Bordanlage", Device="Dashboard", DeviceId="bordanlage-1", Version="1.0"`,
'Content-Type': 'application/json',
}
async function get(path) {
const res = await fetch(`${baseUrl}${path}`, { headers })
if (!res.ok) throw new Error(`Jellyfin ${res.status}: ${path}`)
return res.json()
}
return {
async getArtists() {
return get('/Artists?SortBy=SortName&SortOrder=Ascending&IncludeItemTypes=Audio&Recursive=true')
},
async getAlbums(artistId) {
const q = artistId ? `&ArtistIds=${artistId}` : ''
return get(`/Items?SortBy=SortName&IncludeItemTypes=MusicAlbum&Recursive=true${q}`)
},
async getTracks(albumId) {
return get(`/Items?ParentId=${albumId}&IncludeItemTypes=Audio&SortBy=IndexNumber`)
},
async search(query) {
return get(`/Items?SearchTerm=${encodeURIComponent(query)}&IncludeItemTypes=Audio,MusicAlbum,MusicArtist&Recursive=true&Limit=20`)
},
getStreamUrl(itemId) {
return `${baseUrl}/Audio/${itemId}/stream?static=true&api_key=${apiKey}`
},
getImageUrl(itemId, type = 'Primary') {
return `${baseUrl}/Items/${itemId}/Images/${type}?api_key=${apiKey}`
}
}
}

View File

@@ -0,0 +1,84 @@
// Real Mopidy JSON-RPC WebSocket client.
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
export function createMopidyClient(baseUrl) {
const wsUrl = `${baseUrl}/mopidy/ws`
let ws = null
let reconnectAttempt = 0
let destroyed = false
let msgId = 1
const pending = new Map()
const listeners = {}
function emit(event, data) {
if (listeners[event]) listeners[event].forEach(fn => fn(data))
}
function connect() {
if (destroyed) return
ws = new WebSocket(wsUrl)
ws.onopen = () => {
reconnectAttempt = 0
emit('connected', null)
}
ws.onmessage = ({ data }) => {
try {
const msg = JSON.parse(data)
if (msg.id !== undefined && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id)
pending.delete(msg.id)
if (msg.error) reject(new Error(msg.error.message))
else resolve(msg.result)
}
// Mopidy events (no id)
if (msg.event) emit(`event:${msg.event}`, msg)
} catch (e) {}
}
ws.onclose = () => {
emit('disconnected', null)
for (const [, { reject }] of pending) reject(new Error('Connection closed'))
pending.clear()
if (!destroyed) scheduleReconnect()
}
ws.onerror = () => {}
}
function scheduleReconnect() {
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
reconnectAttempt++
setTimeout(connect, delay)
}
connect()
return {
call(method, params = {}) {
return new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
return reject(new Error('Mopidy not connected'))
}
const id = msgId++
pending.set(id, { resolve, reject })
ws.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
})
},
on(event, fn) {
if (!listeners[event]) listeners[event] = []
listeners[event].push(fn)
},
off(event, fn) {
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
},
disconnect() {
destroyed = true
ws?.close()
}
}
}

View File

@@ -0,0 +1,82 @@
// Real SignalK WebSocket client with reconnect.
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
export function createSignalKClient(baseUrl) {
const wsUrl = `${baseUrl}/signalk/v1/stream?subscribe=self`
const listeners = {}
let ws = null
let reconnectAttempt = 0
let destroyed = false
function emit(event, data) {
if (listeners[event]) listeners[event].forEach(fn => fn(data))
}
function connect() {
if (destroyed) return
ws = new WebSocket(wsUrl)
ws.onopen = () => {
reconnectAttempt = 0
emit('connected', null)
// Subscribe to relevant paths
ws.send(JSON.stringify({
context: 'vessels.self',
subscribe: [
{ path: 'navigation.speedOverGround' },
{ path: 'navigation.courseOverGroundTrue' },
{ path: 'navigation.headingTrue' },
{ path: 'navigation.position' },
{ path: 'environment.depth.belowKeel' },
{ path: 'environment.wind.speedApparent' },
{ path: 'environment.wind.angleApparent' },
{ path: 'environment.water.temperature' },
{ path: 'environment.outside.temperature' },
{ path: 'propulsion.main.revolutions' },
{ path: 'electrical.batteries.starter.voltage' },
{ path: 'electrical.batteries.house.voltage' },
{ path: 'steering.rudderAngle' },
{ path: 'tanks.fuel.0.currentLevel' },
]
}))
}
ws.onmessage = ({ data }) => {
try {
const msg = JSON.parse(data)
if (msg.updates) emit('delta', msg)
} catch (e) { /* ignore parse errors */ }
}
ws.onclose = () => {
emit('disconnected', null)
if (!destroyed) scheduleReconnect()
}
ws.onerror = () => { /* onclose will handle reconnect */ }
}
function scheduleReconnect() {
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
reconnectAttempt++
setTimeout(connect, delay)
}
connect()
return {
on(event, fn) {
if (!listeners[event]) listeners[event] = []
listeners[event].push(fn)
},
off(event, fn) {
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
},
disconnect() {
destroyed = true
ws?.close()
}
}
}

View File

@@ -0,0 +1,85 @@
// Real Snapcast JSON-RPC WebSocket client with reconnect + request/response matching.
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
export function createSnapcastClient(wsUrl) {
let ws = null
let reconnectAttempt = 0
let destroyed = false
let msgId = 1
const pending = new Map()
const listeners = {}
function emit(event, data) {
if (listeners[event]) listeners[event].forEach(fn => fn(data))
}
function connect() {
if (destroyed) return
ws = new WebSocket(wsUrl)
ws.onopen = () => {
reconnectAttempt = 0
emit('connected', null)
}
ws.onmessage = ({ data }) => {
try {
const msg = JSON.parse(data)
// JSON-RPC response
if (msg.id !== undefined && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id)
pending.delete(msg.id)
if (msg.error) reject(new Error(msg.error.message))
else resolve(msg.result)
}
// Server-sent notification
if (msg.method) emit('update', msg)
} catch (e) { /* ignore */ }
}
ws.onclose = () => {
emit('disconnected', null)
// Reject all pending requests
for (const [, { reject }] of pending) reject(new Error('Connection closed'))
pending.clear()
if (!destroyed) scheduleReconnect()
}
ws.onerror = () => {}
}
function scheduleReconnect() {
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]
reconnectAttempt++
setTimeout(connect, delay)
}
connect()
return {
call(method, params = {}) {
return new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
return reject(new Error('Snapcast not connected'))
}
const id = msgId++
pending.set(id, { resolve, reject })
ws.send(JSON.stringify({ id, jsonrpc: '2.0', method, params }))
})
},
on(event, fn) {
if (!listeners[event]) listeners[event] = []
listeners[event].push(fn)
},
off(event, fn) {
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
},
disconnect() {
destroyed = true
ws?.close()
}
}
}

View File

@@ -0,0 +1,53 @@
import { useState, useEffect } from 'react'
import { getApi } from '../../mock/index.js'
export default function LibraryBrowser() {
const [tracks, setTracks] = useState([])
const [loading, setLoading] = useState(true)
const { mopidy } = getApi()
useEffect(() => {
mopidy.call('tracklist.get_tracks')
.then(t => { setTracks(t || []); setLoading(false) })
.catch(() => setLoading(false))
}, [])
async function playTrack(uri) {
await mopidy.call('tracklist.clear')
await mopidy.call('tracklist.add', { uris: [uri] })
await mopidy.call('playback.play')
}
if (loading) return <div style={{ color: 'var(--muted)', padding: 16 }}>Loading library</div>
if (!tracks.length) return <div style={{ color: 'var(--muted)', padding: 16 }}>No tracks found. Add music to ./music</div>
return (
<div style={styles.list}>
{tracks.map((track, i) => (
<button key={track.uri || i} style={styles.row} onClick={() => playTrack(track.uri)}>
<span style={styles.num}>{i + 1}</span>
<div style={styles.meta}>
<span style={styles.name}>{track.name}</span>
<span style={styles.artist}>{track.artists?.map(a => a.name).join(', ')}</span>
</div>
<span style={styles.album}>{track.album?.name}</span>
</button>
))}
</div>
)
}
const styles = {
list: { display: 'flex', flexDirection: 'column', gap: 2, overflow: 'auto', maxHeight: 400 },
row: {
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 12px', borderRadius: 'var(--radius)',
background: 'none', border: '1px solid transparent',
color: 'var(--text)', textAlign: 'left', minHeight: 48,
},
num: { fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--muted)', minWidth: 20 },
meta: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 },
name: { fontSize: 13, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
artist: { fontSize: 11, color: 'var(--muted)' },
album: { fontSize: 11, color: 'var(--muted)', minWidth: 100, textAlign: 'right', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
}

View File

@@ -0,0 +1,90 @@
import { usePlayer } from '../../hooks/usePlayer.js'
function formatTime(ms) {
if (!ms) return '0:00'
const s = Math.floor(ms / 1000)
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
}
export default function NowPlaying({ compact = false }) {
const { currentTrack, state, position, play, pause, next, previous, connected } = usePlayer()
if (!connected) {
return (
<div style={styles.container}>
<span style={{ color: 'var(--muted)', fontSize: 13 }}>Audio not connected</span>
</div>
)
}
const progress = currentTrack?.duration
? Math.min(100, (position / currentTrack.duration) * 100)
: 0
return (
<div style={{ ...styles.container, ...(compact ? styles.compact : {}) }}>
{/* Cover placeholder */}
<div style={styles.cover}>
{currentTrack?.coverUrl
? <img src={currentTrack.coverUrl} alt="cover" style={styles.coverImg} />
: <span style={styles.coverIcon}></span>}
</div>
<div style={styles.info}>
<div style={styles.title}>{currentTrack?.title || 'Nothing playing'}</div>
<div style={styles.artist}>{currentTrack?.artist || ''}</div>
{!compact && <div style={styles.album}>{currentTrack?.album || ''}</div>}
{/* Progress bar */}
{currentTrack && (
<div style={styles.progressRow}>
<span style={styles.timeText}>{formatTime(position)}</span>
<div style={styles.progressBg}>
<div style={{ ...styles.progressFill, width: `${progress}%` }} />
</div>
<span style={styles.timeText}>{formatTime(currentTrack.duration)}</span>
</div>
)}
{/* Controls */}
<div style={styles.controls}>
<button style={styles.btn} onClick={previous}></button>
<button style={{ ...styles.btn, ...styles.playBtn }}
onClick={state === 'playing' ? pause : play}>
{state === 'playing' ? '⏸' : '▶'}
</button>
<button style={styles.btn} onClick={next}></button>
</div>
</div>
</div>
)
}
const styles = {
container: {
display: 'flex', gap: 16, padding: 16,
background: 'var(--surface)', borderRadius: 'var(--radius)',
border: '1px solid var(--border)',
alignItems: 'center',
},
compact: { padding: '10px 14px' },
cover: {
width: 64, height: 64, flexShrink: 0,
background: 'var(--surface2)', borderRadius: 6,
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
},
coverImg: { width: '100%', height: '100%', objectFit: 'cover' },
coverIcon: { fontSize: 28, color: 'var(--muted)' },
info: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 },
title: { fontWeight: 600, fontSize: 14, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
artist: { fontSize: 12, color: 'var(--muted)' },
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' },
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 },
}

View File

@@ -0,0 +1,61 @@
import { useState } from 'react'
import { getApi } from '../../mock/index.js'
const BUILT_IN_STATIONS = [
{ name: 'SWR3', uri: 'http://stream.swr3.de/swr3/mp3-128/stream.mp3' },
{ name: 'NDR 1 Welle Nord', uri: 'http://ndr.de/ndr1welle-nord-128.mp3' },
{ name: 'Deutschlandfunk', uri: 'https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3' },
{ name: 'KISS FM', uri: 'http://topstream.kissfm.de/kissfm' },
]
export default function RadioBrowser() {
const [playing, setPlaying] = useState(null)
const { mopidy } = getApi()
async function playStation(uri, name) {
try {
await mopidy.call('tracklist.clear')
await mopidy.call('tracklist.add', { uris: [uri] })
await mopidy.call('playback.play')
setPlaying(uri)
} catch (e) {
console.error('Radio play error:', e)
}
}
return (
<div style={styles.container}>
<div style={styles.title}>Radio Stations</div>
{BUILT_IN_STATIONS.map(s => (
<button
key={s.uri}
style={{
...styles.station,
...(playing === s.uri ? styles.active : {}),
}}
onClick={() => playStation(s.uri, s.name)}
>
<span style={styles.dot}></span>
<span style={styles.stationName}>{s.name}</span>
{playing === s.uri && <span style={styles.live}>LIVE</span>}
</button>
))}
</div>
)
}
const styles = {
container: { display: 'flex', flexDirection: 'column', gap: 6 },
title: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 4 },
station: {
display: 'flex', alignItems: 'center', gap: 12,
padding: '12px 16px', background: 'var(--surface)',
border: '1px solid var(--border)', borderRadius: 'var(--radius)',
color: 'var(--text)', textAlign: 'left', minHeight: 48,
transition: 'border-color 0.15s',
},
active: { borderColor: 'var(--accent)', background: 'var(--surface2)' },
dot: { color: 'var(--muted)', fontSize: 10 },
stationName: { flex: 1, fontSize: 14 },
live: { fontSize: 9, padding: '2px 5px', background: '#ef444422', color: 'var(--danger)', borderRadius: 3, fontWeight: 700 },
}

View File

@@ -0,0 +1,37 @@
const SOURCES = [
{ id: 'Spotify', label: 'Spotify', color: 'var(--spotify)', icon: '🎵' },
{ id: 'AirPlay', label: 'AirPlay', color: 'var(--airplay)', icon: '📡' },
{ id: 'Mopidy', label: 'Mopidy', color: 'var(--accent)', icon: '📻' },
]
export default function SourcePicker({ activeSource, onSelect }) {
return (
<div style={styles.row}>
{SOURCES.map(s => (
<button
key={s.id}
style={{
...styles.btn,
...(activeSource === s.id ? { background: s.color + '22', borderColor: s.color, color: s.color } : {}),
}}
onClick={() => onSelect(s.id)}
>
<span>{s.icon}</span>
<span style={{ fontSize: 12 }}>{s.label}</span>
</button>
))}
</div>
)
}
const styles = {
row: { display: 'flex', gap: 8 },
btn: {
flex: 1, height: 52, flexDirection: 'column',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius)', color: 'var(--muted)',
transition: 'all 0.15s',
minHeight: 52,
},
}

View File

@@ -0,0 +1,53 @@
export default function ZoneCard({ zone, onVolume, onMute, onSource }) {
const { id, name, active, volume, muted, source } = zone
return (
<div style={{
...styles.card,
opacity: active ? 1 : 0.45,
borderColor: active && !muted ? 'var(--accent)' : 'var(--border)',
}}>
<div style={styles.header}>
<span style={styles.name}>{name}</span>
<div style={styles.badges}>
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
{active ? 'ON' : 'OFF'}
</span>
</div>
</div>
<div style={styles.source}>{source}</div>
<div style={styles.volumeRow}>
<button style={styles.muteBtn} onClick={() => onMute(id, !muted)}>
{muted ? '🔇' : '🔊'}
</button>
<input
type="range" min={0} max={100} value={muted ? 0 : volume}
onChange={e => onVolume(id, Number(e.target.value))}
style={{ flex: 1 }}
/>
<span style={styles.volVal}>{muted ? '' : volume}</span>
</div>
</div>
)
}
const styles = {
card: {
padding: 14,
background: 'var(--surface)',
borderRadius: 'var(--radius)',
border: '1px solid var(--border)',
display: 'flex', flexDirection: 'column', gap: 10,
transition: 'border-color 0.2s, opacity 0.2s',
},
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
name: { fontWeight: 600, fontSize: 14 },
badges: { display: 'flex', gap: 4 },
badge: { fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 700 },
source: { fontSize: 11, color: 'var(--muted)' },
volumeRow: { display: 'flex', alignItems: 'center', gap: 10 },
muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44 },
volVal: { fontFamily: 'var(--font-mono)', fontSize: 13, minWidth: 26, textAlign: 'right', color: 'var(--muted)' },
}

View File

@@ -0,0 +1,32 @@
import { useZones } from '../../hooks/useZones.js'
import ZoneCard from './ZoneCard.jsx'
export default function ZoneGrid() {
const { zones, setVolume, setMuted, setSource } = useZones()
if (!zones.length) {
return <div style={{ color: 'var(--muted)', padding: 24, textAlign: 'center' }}>Loading zones</div>
}
return (
<div style={styles.grid}>
{zones.map(zone => (
<ZoneCard
key={zone.id}
zone={zone}
onVolume={setVolume}
onMute={setMuted}
onSource={setSource}
/>
))}
</div>
)
}
const styles = {
grid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
gap: 12,
}
}

View File

@@ -0,0 +1,94 @@
// Animated SVG compass rose.
const CX = 96, CY = 96, R = 80
const CARDINALS = [
{ label: 'N', angle: 0 },
{ label: 'E', angle: 90 },
{ label: 'S', angle: 180 },
{ label: 'W', angle: 270 },
]
function polarXY(cx, cy, r, angleDeg) {
const rad = (angleDeg - 90) * Math.PI / 180
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
}
export default function Compass({ heading = 0, cog }) {
const hdg = heading ?? 0
const hasCog = cog != null
return (
<svg width={192} height={192} viewBox="0 0 192 192">
{/* Outer ring */}
<circle cx={CX} cy={CY} r={R} fill="none" stroke="var(--border)" strokeWidth={2} />
<circle cx={CX} cy={CY} r={R - 12} fill="none" stroke="var(--border)" strokeWidth={1} />
{/* Rotating rose */}
<g style={{
transformOrigin: `${CX}px ${CY}px`,
transform: `rotate(${-hdg}deg)`,
transition: 'transform 0.8s ease',
}}>
{/* 36 tick marks */}
{Array.from({ length: 36 }, (_, i) => {
const angle = i * 10
const outer = polarXY(CX, CY, R, angle)
const inner = polarXY(CX, CY, R - (i % 3 === 0 ? 10 : 5), angle)
return (
<line key={i}
x1={outer.x} y1={outer.y}
x2={inner.x} y2={inner.y}
stroke={i % 9 === 0 ? 'var(--accent)' : 'var(--muted)'}
strokeWidth={i % 9 === 0 ? 2 : 1}
/>
)
})}
{/* Cardinal labels */}
{CARDINALS.map(c => {
const p = polarXY(CX, CY, R - 22, c.angle)
return (
<text key={c.label}
x={p.x} y={p.y + 4}
textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={12} fontWeight={700}
fill={c.label === 'N' ? 'var(--danger)' : 'var(--text)'}
>
{c.label}
</text>
)
})}
</g>
{/* Fixed lubber line (ship's bow = top) */}
<line x1={CX} y1={CY - R + 2} x2={CX} y2={CY - R + 16}
stroke="var(--accent)" strokeWidth={3} strokeLinecap="round" />
{/* COG indicator */}
{hasCog && (() => {
const cogAngle = cog - hdg
const tip = polarXY(CX, CY, R - 6, cogAngle)
return (
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y}
stroke="var(--warning)" strokeWidth={2} strokeDasharray="4,3"
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.8s ease' }}
/>
)
})()}
{/* Center */}
<circle cx={CX} cy={CY} r={4} fill="var(--accent)" />
{/* Heading value */}
<text x={CX} y={CY + 26} textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)">
{Math.round(hdg).toString().padStart(3, '0')}°
</text>
<text x={CX} y={186} textAnchor="middle"
fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
HEADING
</text>
</svg>
)
}

View File

@@ -0,0 +1,16 @@
// Depth sounder with alarm threshold.
import Gauge from './Gauge.jsx'
export default function DepthSounder({ depth }) {
return (
<Gauge
value={depth}
min={0}
max={30}
label="Depth"
unit="m"
warning={3}
danger={1.5}
/>
)
}

View File

@@ -0,0 +1,118 @@
// Analog round gauge with animated needle.
const R = 80
const CX = 96
const CY = 96
const START_ANGLE = -225
const SWEEP = 270
function polarToXY(cx, cy, r, angleDeg) {
const rad = (angleDeg - 90) * Math.PI / 180
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
}
function arcPath(cx, cy, r, startDeg, endDeg) {
const start = polarToXY(cx, cy, r, startDeg)
const end = polarToXY(cx, cy, r, endDeg)
const large = endDeg - startDeg > 180 ? 1 : 0
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 1 ${end.x} ${end.y}`
}
function buildTicks(startDeg, sweep, count) {
return Array.from({ length: count + 1 }, (_, i) => {
const angle = startDeg + (sweep / count) * i
const outer = polarToXY(CX, CY, R, angle)
const inner = polarToXY(CX, CY, R - (i % 2 === 0 ? 10 : 6), angle)
return { outer, inner, major: i % 2 === 0 }
})
}
export default function Gauge({ value, min = 0, max = 10, label = '', unit = '', danger, warning }) {
const clampedVal = Math.min(max, Math.max(min, value ?? min))
const ratio = (clampedVal - min) / (max - min)
const needleAngle = START_ANGLE + ratio * SWEEP
const warnRatio = warning != null ? (warning - min) / (max - min) : null
const dangRatio = danger != null ? (danger - min) / (max - min) : null
const ticks = buildTicks(START_ANGLE, SWEEP, 10)
const needleTip = polarToXY(CX, CY, R - 12, needleAngle)
const needleBase1 = polarToXY(CX, CY, 6, needleAngle + 90)
const needleBase2 = polarToXY(CX, CY, 6, needleAngle - 90)
const isWarning = warning != null && clampedVal >= warning
const isDanger = danger != null && clampedVal >= danger
const needleColor = isDanger ? 'var(--danger)' : isWarning ? 'var(--warning)' : 'var(--accent)'
return (
<svg width={192} height={192} viewBox="0 0 192 192" style={{ overflow: 'visible' }}>
{/* Background arc */}
<path
d={arcPath(CX, CY, R, START_ANGLE, START_ANGLE + SWEEP)}
fill="none" stroke="var(--border)" strokeWidth={8} strokeLinecap="round"
/>
{/* Warning zone */}
{warnRatio != null && (
<path
d={arcPath(CX, CY, R,
START_ANGLE + warnRatio * SWEEP,
START_ANGLE + (dangRatio ?? 1) * SWEEP)}
fill="none" stroke="#f59e0b33" strokeWidth={8}
/>
)}
{/* Danger zone */}
{dangRatio != null && (
<path
d={arcPath(CX, CY, R, START_ANGLE + dangRatio * SWEEP, START_ANGLE + SWEEP)}
fill="none" stroke="#ef444433" strokeWidth={8}
/>
)}
{/* Progress arc */}
<path
d={arcPath(CX, CY, R, START_ANGLE, needleAngle)}
fill="none" stroke={needleColor} strokeWidth={4} strokeLinecap="round"
style={{ transition: 'all 0.6s ease' }}
/>
{/* Ticks */}
{ticks.map((t, i) => (
<line key={i}
x1={t.outer.x} y1={t.outer.y}
x2={t.inner.x} y2={t.inner.y}
stroke={t.major ? 'var(--muted)' : 'var(--border)'}
strokeWidth={t.major ? 1.5 : 1}
/>
))}
{/* Needle */}
<polygon
points={`${needleTip.x},${needleTip.y} ${needleBase1.x},${needleBase1.y} ${needleBase2.x},${needleBase2.y}`}
fill={needleColor}
style={{ transition: 'all 0.6s ease', transformOrigin: `${CX}px ${CY}px` }}
/>
{/* Center cap */}
<circle cx={CX} cy={CY} r={6} fill="var(--surface2)" stroke={needleColor} strokeWidth={2} />
{/* Value text */}
<text x={CX} y={CY + 28} textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)"
style={{ transition: 'all 0.3s' }}>
{value != null ? (Number.isInteger(max - min) && max - min <= 20
? Math.round(clampedVal)
: clampedVal.toFixed(1)) : '--'}
</text>
<text x={CX} y={CY + 42} textAnchor="middle" fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
{unit}
</text>
{/* Label */}
<text x={CX} y={186} textAnchor="middle" fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
{label.toUpperCase()}
</text>
</svg>
)
}

View File

@@ -0,0 +1,14 @@
// Speed through water / SOG gauge.
import Gauge from './Gauge.jsx'
export default function SpeedLog({ sog }) {
return (
<Gauge
value={sog}
min={0}
max={12}
label="Speed"
unit="kn"
/>
)
}

View File

@@ -0,0 +1,76 @@
// Wind rose showing apparent wind angle and speed.
const CX = 96, CY = 96, R = 70
function polarXY(cx, cy, r, angleDeg) {
const rad = (angleDeg - 90) * Math.PI / 180
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
}
export default function WindRose({ windAngle = 0, windSpeed = 0 }) {
const angle = windAngle ?? 0
const speed = windSpeed ?? 0
const tipLen = Math.min(R - 10, 20 + speed * 2.5)
const tip = polarXY(CX, CY, tipLen, angle)
const left = polarXY(CX, CY, 10, angle + 120)
const right = polarXY(CX, CY, 10, angle - 120)
const color = speed > 18 ? 'var(--danger)' : speed > 12 ? 'var(--warning)' : 'var(--accent)'
return (
<svg width={192} height={192} viewBox="0 0 192 192">
{/* Background rings */}
<circle cx={CX} cy={CY} r={R} fill="none" stroke="var(--border)" strokeWidth={1} />
<circle cx={CX} cy={CY} r={R * 0.6} fill="none" stroke="var(--border)" strokeWidth={1} strokeDasharray="3,4" />
{/* Dividers every 45° */}
{Array.from({ length: 8 }, (_, i) => {
const p = polarXY(CX, CY, R, i * 45)
return <line key={i} x1={CX} y1={CY} x2={p.x} y2={p.y}
stroke="var(--border)" strokeWidth={1} />
})}
{/* Wind arrow */}
<g style={{ transition: 'all 0.6s ease' }}>
<polygon
points={`${tip.x},${tip.y} ${left.x},${left.y} ${right.x},${right.y}`}
fill={color} opacity={0.85}
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.6s ease' }}
/>
<line x1={CX} y1={CY} x2={tip.x} y2={tip.y}
stroke={color} strokeWidth={2}
style={{ transformOrigin: `${CX}px ${CY}px`, transition: 'all 0.6s ease' }}
/>
</g>
<circle cx={CX} cy={CY} r={5} fill="var(--surface2)" stroke={color} strokeWidth={2} />
{/* Speed value */}
<text x={CX} y={CY + 26} textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={18} fill="var(--text)">
{speed.toFixed(1)}
</text>
<text x={CX} y={CY + 40} textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
kn
</text>
{/* Labels */}
{[['N', 0], ['E', 90], ['S', 180], ['W', 270]].map(([l, a]) => {
const p = polarXY(CX, CY, R + 12, a)
return (
<text key={l} x={p.x} y={p.y + 4} textAnchor="middle"
fontFamily="var(--font-mono)" fontSize={10} fill="var(--muted)">
{l}
</text>
)
})}
<text x={CX} y={186} textAnchor="middle"
fontFamily="var(--font-ui)" fontSize={11} fill="var(--muted)" fontWeight={600}>
WIND
</text>
</svg>
)
}

View File

@@ -0,0 +1,56 @@
const TABS = [
{ id: 'overview', label: 'Overview', icon: '◈' },
{ id: 'navigation', label: 'Navigation', icon: '⊕' },
{ id: 'audio', label: 'Audio', icon: '♫' },
{ id: 'systems', label: 'Systems', icon: '⚙' },
]
export default function TabNav({ activeTab, onTabChange }) {
return (
<nav style={styles.nav}>
{TABS.map(tab => (
<button
key={tab.id}
style={{
...styles.tab,
...(activeTab === tab.id ? styles.active : {}),
}}
onClick={() => onTabChange(tab.id)}
>
<span style={styles.icon}>{tab.icon}</span>
<span style={styles.label}>{tab.label}</span>
</button>
))}
</nav>
)
}
const styles = {
nav: {
display: 'flex',
background: 'var(--surface)',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
},
tab: {
flex: 1,
height: 48,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
fontSize: 11,
color: 'var(--muted)',
borderRadius: 0,
borderBottom: '2px solid transparent',
transition: 'color 0.15s, border-color 0.15s',
minHeight: 48,
},
active: {
color: 'var(--accent)',
borderBottom: '2px solid var(--accent)',
},
icon: { fontSize: 16 },
label: { fontSize: 10, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase' },
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react'
import { useNMEA } from '../../hooks/useNMEA.js'
const isDev = import.meta.env.DEV
function formatTime() {
return new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
}
export default function TopBar() {
const { sog, heading, connected } = useNMEA()
const [time, setTime] = useState(formatTime())
// Clock tick
useState(() => {
const t = setInterval(() => setTime(formatTime()), 5000)
return () => clearInterval(t)
})
return (
<header style={styles.bar}>
<div style={styles.left}>
<span style={styles.logo}> Bordanlage</span>
{isDev && <span style={styles.devBadge}>DEV · MOCK DATA</span>}
</div>
<div style={styles.center}>
{connected && sog != null && (
<span style={styles.stat}>
<span style={styles.val}>{sog.toFixed(1)}</span>
<span style={styles.unit}>kn</span>
</span>
)}
{connected && heading != null && (
<span style={styles.stat}>
<span style={styles.val}>{Math.round(heading)}°</span>
<span style={styles.unit}>HDG</span>
</span>
)}
{!connected && (
<span style={{ color: 'var(--muted)', fontSize: 13 }}>No signal</span>
)}
</div>
<div style={styles.right}>
<span style={styles.time}>{time}</span>
</div>
</header>
)
}
const styles = {
bar: {
height: 52,
background: 'var(--surface)',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
padding: '0 16px',
gap: 16,
flexShrink: 0,
},
left: { display: 'flex', alignItems: 'center', gap: 10, flex: 1 },
center: { display: 'flex', gap: 20, alignItems: 'center' },
right: { flex: 1, display: 'flex', justifyContent: 'flex-end' },
logo: { fontWeight: 700, fontSize: 15, color: 'var(--accent)', letterSpacing: '0.04em' },
devBadge: {
fontSize: 10, fontWeight: 600, padding: '2px 7px',
background: '#f59e0b22', color: 'var(--warning)',
border: '1px solid var(--warning)', borderRadius: 4,
letterSpacing: '0.06em',
},
stat: { display: 'flex', alignItems: 'baseline', gap: 3 },
val: { fontFamily: 'var(--font-mono)', fontSize: 16, color: 'var(--text)' },
unit: { fontSize: 10, color: 'var(--muted)' },
time: { fontFamily: 'var(--font-mono)', fontSize: 14, color: 'var(--muted)' },
}

View File

@@ -0,0 +1,37 @@
import { useNMEA } from '../../hooks/useNMEA.js'
export default function ChartPlaceholder() {
const { lat, lon } = useNMEA()
const signalkHost = import.meta.env.VITE_SIGNALK_HOST || 'localhost'
// SignalK has a built-in chart viewer
const chartUrl = `http://${signalkHost}:3000/@signalk/freeboard-sk/`
return (
<div style={styles.container}>
<iframe
src={chartUrl}
style={styles.iframe}
title="Chart"
onError={() => {}}
/>
{lat != null && (
<div style={styles.coords}>
<span style={styles.coord}>{lat.toFixed(5)}°N</span>
<span style={styles.coord}>{lon.toFixed(5)}°E</span>
</div>
)}
</div>
)
}
const styles = {
container: { position: 'relative', flex: 1, borderRadius: 'var(--radius)', overflow: 'hidden', border: '1px solid var(--border)' },
iframe: { width: '100%', height: '100%', border: 'none', background: 'var(--surface2)' },
coords: {
position: 'absolute', bottom: 12, left: 12,
background: '#07111fcc', borderRadius: 6, padding: '6px 10px',
display: 'flex', gap: 12,
},
coord: { fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--accent)' },
}

View File

@@ -0,0 +1,67 @@
import { useNMEA } from '../../hooks/useNMEA.js'
import Gauge from '../instruments/Gauge.jsx'
import Compass from '../instruments/Compass.jsx'
import WindRose from '../instruments/WindRose.jsx'
function DataRow({ label, value, unit }) {
return (
<div style={styles.row}>
<span style={styles.label}>{label}</span>
<span style={styles.value}>
{value != null ? `${typeof value === 'number' ? value.toFixed(1) : value}` : '—'}
{unit && <span style={styles.unit}> {unit}</span>}
</span>
</div>
)
}
export default function InstrumentPanel() {
const nmea = useNMEA()
return (
<div style={styles.panel}>
{/* Gauges row */}
<div style={styles.gauges}>
<Compass heading={nmea.heading} cog={nmea.cog} />
<WindRose windAngle={nmea.windAngle} windSpeed={nmea.windSpeed} />
<Gauge value={nmea.depth} min={0} max={30} label="Depth" unit="m" warning={3} danger={1.5} />
<Gauge value={nmea.sog} min={0} max={12} label="SOG" unit="kn" />
</div>
{/* Data table */}
<div style={styles.table}>
<DataRow label="COG" value={nmea.cog != null ? Math.round(nmea.cog) : null} unit="°" />
<DataRow label="Heading" value={nmea.heading != null ? Math.round(nmea.heading) : null} unit="°" />
<DataRow label="SOG" value={nmea.sog} unit="kn" />
<DataRow label="Depth" value={nmea.depth} unit="m" />
<DataRow label="Wind Speed" value={nmea.windSpeed} unit="kn" />
<DataRow label="Wind Angle" value={nmea.windAngle} unit="°" />
<DataRow label="Water Temp" value={nmea.waterTemp} unit="°C" />
<DataRow label="Air Temp" value={nmea.airTemp} unit="°C" />
<DataRow label="RPM" value={nmea.rpm} unit="" />
<DataRow label="Rudder" value={nmea.rudder} unit="°" />
<DataRow label="Fuel" value={nmea.fuel} unit="%" />
<DataRow label="Lat" value={nmea.lat?.toFixed(5)} unit="°N" />
<DataRow label="Lon" value={nmea.lon?.toFixed(5)} unit="°E" />
</div>
</div>
)
}
const styles = {
panel: { display: 'flex', flexDirection: 'column', gap: 16 },
gauges: { display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' },
table: {
display: 'grid', gridTemplateColumns: '1fr 1fr',
gap: '0 24px', background: 'var(--surface)',
borderRadius: 'var(--radius)', padding: 16,
border: '1px solid var(--border)',
},
row: {
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '7px 0', borderBottom: '1px solid var(--border)',
},
label: { fontSize: 12, color: 'var(--muted)' },
value: { fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)' },
unit: { color: 'var(--muted)', fontSize: 11 },
}

View File

@@ -0,0 +1,35 @@
function BatteryBar({ label, voltage, nominal, low, critical }) {
const percent = Math.min(100, Math.max(0, ((voltage - critical) / (nominal - critical)) * 100))
const color = voltage < critical ? 'var(--danger)' : voltage < low ? 'var(--warning)' : 'var(--success)'
return (
<div style={styles.battery}>
<div style={styles.header}>
<span style={styles.label}>{label}</span>
<span style={{ ...styles.voltage, color }}>{voltage != null ? `${voltage.toFixed(2)} V` : '—'}</span>
</div>
<div style={styles.barBg}>
<div style={{ ...styles.barFill, width: `${percent}%`, background: color, transition: 'width 0.6s, background 0.3s' }} />
</div>
</div>
)
}
export default function BatteryStatus({ battery1, battery2 }) {
return (
<div style={styles.container}>
<BatteryBar label="Starter (12V)" voltage={battery1} nominal={12.8} low={12.2} critical={11.8} />
<BatteryBar label="House (24V)" voltage={battery2} nominal={25.6} low={24.8} critical={23.5} />
</div>
)
}
const styles = {
container: { display: 'flex', flexDirection: 'column', gap: 14, padding: 16, background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)' },
battery: { display: 'flex', flexDirection: 'column', gap: 6 },
header: { display: 'flex', justifyContent: 'space-between' },
label: { fontSize: 12, color: 'var(--muted)' },
voltage: { fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 600 },
barBg: { height: 8, background: 'var(--border)', borderRadius: 4, overflow: 'hidden' },
barFill: { height: '100%', borderRadius: 4 },
}

View File

@@ -0,0 +1,22 @@
import Gauge from '../instruments/Gauge.jsx'
import { useNMEA } from '../../hooks/useNMEA.js'
export default function EngineData() {
const { rpm, waterTemp, fuel } = useNMEA()
return (
<div style={styles.grid}>
<Gauge value={rpm} min={0} max={3000} label="RPM" unit="rpm" warning={2500} danger={2800} />
<Gauge value={waterTemp} min={0} max={120} label="Coolant" unit="°C" warning={85} danger={100} />
<Gauge value={fuel} min={0} max={100} label="Fuel" unit="%" warning={20} danger={10} />
</div>
)
}
const styles = {
grid: {
display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center',
padding: 16, background: 'var(--surface)',
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
}
}

View File

@@ -0,0 +1,40 @@
import { useDocker } from '../../hooks/useDocker.js'
const STATUS_COLOR = {
online: 'var(--success)',
offline: 'var(--danger)',
unknown: 'var(--muted)',
}
export default function ServiceHealth() {
const { services, refresh } = useDocker()
return (
<div style={styles.container}>
<div style={styles.header}>
<span style={styles.title}>Services</span>
<button style={styles.refreshBtn} onClick={refresh}> Refresh</button>
</div>
{services.map(s => (
<div key={s.id} style={styles.row}>
<span style={{ ...styles.dot, color: STATUS_COLOR[s.status] }}></span>
<span style={styles.name}>{s.name}</span>
<a href={s.url} target="_blank" rel="noreferrer" style={styles.link}>{s.url.replace(/^https?:\/\//, '')}</a>
<span style={{ ...styles.status, color: STATUS_COLOR[s.status] }}>{s.status}</span>
</div>
))}
</div>
)
}
const styles = {
container: { padding: 16, background: 'var(--surface)', borderRadius: 'var(--radius)', border: '1px solid var(--border)', display: 'flex', flexDirection: 'column', gap: 10 },
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 },
title: { fontWeight: 600, fontSize: 13, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--muted)' },
refreshBtn: { fontSize: 12, color: 'var(--accent)', minWidth: 0, minHeight: 0, padding: '4px 8px', height: 'auto' },
row: { display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0', borderBottom: '1px solid var(--border)' },
dot: { fontSize: 10 },
name: { minWidth: 90, fontWeight: 500, fontSize: 13 },
link: { flex: 1, fontSize: 11, color: 'var(--muted)', textDecoration: 'none', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
status: { fontSize: 11, fontWeight: 600, textTransform: 'uppercase' },
}

View File

@@ -0,0 +1,40 @@
import { useState, useEffect } from 'react'
const SERVICES = [
{ id: 'signalk', name: 'SignalK', url: 'http://localhost:3000/signalk' },
{ id: 'snapserver', name: 'Snapcast', url: 'http://localhost:1780' },
{ id: 'mopidy', name: 'Mopidy', url: 'http://localhost:6680' },
{ id: 'jellyfin', name: 'Jellyfin', url: 'http://localhost:8096' },
{ id: 'portainer', name: 'Portainer', url: 'http://localhost:9000' },
]
async function ping(url) {
try {
const res = await fetch(url, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
return res.ok || res.status < 500
} catch {
return false
}
}
export function useDocker() {
const [services, setServices] = useState(
SERVICES.map(s => ({ ...s, status: 'unknown' }))
)
async function checkAll() {
const results = await Promise.all(SERVICES.map(async s => ({
...s,
status: await ping(s.url) ? 'online' : 'offline',
})))
setServices(results)
}
useEffect(() => {
checkAll()
const timer = setInterval(checkAll, 30000)
return () => clearInterval(timer)
}, [])
return { services, refresh: checkAll }
}

View File

@@ -0,0 +1,93 @@
import { useState, useEffect, useRef } from 'react'
import { getApi } from '../mock/index.js'
const radToDeg = r => r * 180 / Math.PI
const kelvinToC = k => k - 273.15
function parseDelta(updates) {
const values = {}
for (const update of updates) {
for (const { path, value } of (update.values || [])) {
values[path] = value
}
}
return values
}
const DEFAULT_STATE = {
sog: null, cog: null, heading: null, depth: null,
windSpeed: null, windAngle: null, windDirection: null,
lat: null, lon: null, rpm: null,
battery1: null, battery2: null,
waterTemp: null, airTemp: null,
rudder: null, fuel: null,
connected: false,
}
export function useNMEA() {
const [data, setData] = useState(DEFAULT_STATE)
const clientRef = useRef(null)
useEffect(() => {
const { signalk } = getApi()
clientRef.current = signalk
const onDelta = ({ updates }) => {
const v = parseDelta(updates)
setData(prev => {
const next = { ...prev, connected: true }
if (v['navigation.speedOverGround'] != null)
next.sog = v['navigation.speedOverGround'] * 1.944 // m/s → knots
if (v['navigation.courseOverGroundTrue'] != null)
next.cog = radToDeg(v['navigation.courseOverGroundTrue'])
if (v['navigation.headingTrue'] != null)
next.heading = radToDeg(v['navigation.headingTrue'])
if (v['navigation.position'] != null) {
next.lat = v['navigation.position'].latitude
next.lon = v['navigation.position'].longitude
}
if (v['environment.depth.belowKeel'] != null)
next.depth = v['environment.depth.belowKeel']
if (v['environment.wind.speedApparent'] != null)
next.windSpeed = v['environment.wind.speedApparent'] * 1.944
if (v['environment.wind.angleApparent'] != null) {
next.windAngle = radToDeg(v['environment.wind.angleApparent'])
next.windDirection = ((next.heading || 0) + next.windAngle + 360) % 360
}
if (v['propulsion.main.revolutions'] != null)
next.rpm = Math.round(v['propulsion.main.revolutions'] * 60)
if (v['electrical.batteries.starter.voltage'] != null)
next.battery1 = v['electrical.batteries.starter.voltage']
if (v['electrical.batteries.house.voltage'] != null)
next.battery2 = v['electrical.batteries.house.voltage']
if (v['environment.water.temperature'] != null)
next.waterTemp = kelvinToC(v['environment.water.temperature'])
if (v['environment.outside.temperature'] != null)
next.airTemp = kelvinToC(v['environment.outside.temperature'])
if (v['steering.rudderAngle'] != null)
next.rudder = radToDeg(v['steering.rudderAngle'])
if (v['tanks.fuel.0.currentLevel'] != null)
next.fuel = v['tanks.fuel.0.currentLevel'] * 100
return next
})
}
const onDisconnected = () => setData(prev => ({ ...prev, connected: false }))
const onConnected = () => setData(prev => ({ ...prev, connected: true }))
signalk.on('delta', onDelta)
signalk.on('connected', onConnected)
signalk.on('disconnected', onDisconnected)
return () => {
signalk.off('delta', onDelta)
signalk.off('connected', onConnected)
signalk.off('disconnected', onDisconnected)
}
}, [])
return data
}

View File

@@ -0,0 +1,89 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { getApi } from '../mock/index.js'
const DEFAULT = {
currentTrack: null,
state: 'stopped',
position: 0,
activeSource: 'mopidy',
connected: false,
}
export function usePlayer() {
const [player, setPlayer] = useState(DEFAULT)
const { mopidy } = getApi()
const posTimer = useRef(null)
async function refresh() {
try {
const [track, state, position] = await Promise.all([
mopidy.call('playback.get_current_track'),
mopidy.call('playback.get_state'),
mopidy.call('playback.get_time_position'),
])
setPlayer(prev => ({
...prev,
connected: true,
state: state || 'stopped',
position: position || 0,
currentTrack: track ? {
title: track.name,
artist: track.artists?.map(a => a.name).join(', ') || 'Unknown',
album: track.album?.name || '',
duration: track.length || 0,
coverUrl: null,
} : null,
}))
} catch {
setPlayer(prev => ({ ...prev, connected: false }))
}
}
useEffect(() => {
refresh()
const onStateChange = ({ new_state }) =>
setPlayer(prev => ({ ...prev, state: new_state }))
const onTrackChange = ({ tl_track }) => {
const track = tl_track?.track
if (track) setPlayer(prev => ({
...prev,
position: 0,
currentTrack: {
title: track.name,
artist: track.artists?.map(a => a.name).join(', ') || 'Unknown',
album: track.album?.name || '',
duration: track.length || 0,
coverUrl: null,
}
}))
}
mopidy.on('event:playbackStateChanged', onStateChange)
mopidy.on('event:trackPlaybackStarted', onTrackChange)
// Advance position locally
posTimer.current = setInterval(() => {
setPlayer(prev => {
if (prev.state !== 'playing') return prev
return { ...prev, position: prev.position + 1000 }
})
}, 1000)
return () => {
mopidy.off('event:playbackStateChanged', onStateChange)
mopidy.off('event:trackPlaybackStarted', onTrackChange)
clearInterval(posTimer.current)
}
}, [])
const play = useCallback(() => mopidy.call('playback.play'), [])
const pause = useCallback(() => mopidy.call('playback.pause'), [])
const next = useCallback(() => mopidy.call('playback.next'), [])
const previous = useCallback(() => mopidy.call('playback.previous'), [])
const seek = useCallback(pos => mopidy.call('playback.seek', { time_position: pos }), [])
return { ...player, play, pause, next, previous, seek }
}

View File

@@ -0,0 +1,69 @@
import { useState, useEffect, useCallback } from 'react'
import { getApi } from '../mock/index.js'
function parseStatus(status) {
return (status?.server?.groups || []).map(group => {
const client = group.clients?.[0] || {}
return {
id: group.id,
name: client.config?.name || group.id,
active: client.connected ?? false,
volume: client.config?.volume?.percent ?? 50,
muted: client.config?.volume?.muted ?? false,
source: group.stream_id || 'Mopidy',
}
})
}
export function useZones() {
const [zones, setZones] = useState([])
const [connected, setConnected] = useState(false)
const { snapcast } = getApi()
useEffect(() => {
let alive = true
async function loadStatus() {
try {
const status = await snapcast.call('Server.GetStatus')
if (alive) {
setZones(parseStatus(status))
setConnected(true)
}
} catch {
if (alive) setConnected(false)
}
}
const onUpdate = (msg) => {
if (msg?.result?.server) setZones(parseStatus(msg.result))
}
snapcast.on('update', onUpdate)
loadStatus()
return () => {
alive = false
snapcast.off('update', onUpdate)
}
}, [])
const setVolume = useCallback(async (zoneId, volume) => {
await snapcast.call('Client.SetVolume', { id: zoneId, volume: { percent: volume, muted: false } })
}, [])
const setMuted = useCallback(async (zoneId, muted) => {
await snapcast.call('Client.SetMuted', { id: zoneId, muted })
}, [])
const setSource = useCallback(async (zoneId, streamId) => {
await snapcast.call('Group.SetStream', { id: zoneId, stream_id: streamId })
}, [])
const toggleZone = useCallback(async (zoneId) => {
const zone = zones.find(z => z.id === zoneId)
if (zone) await setMuted(zoneId, !zone.muted)
}, [zones, setMuted])
return { zones, connected, setVolume, setMuted, setSource, toggleZone }
}

View File

@@ -0,0 +1,79 @@
/* ─── CSS Custom Properties ───────────────────────────────────────────────── */
:root {
--bg: #07111f;
--surface: #0a1928;
--surface2: #0d2035;
--border: #1e2a3a;
--text: #e2eaf2;
--muted: #4a6080;
--accent: #38bdf8;
--success: #34d399;
--warning: #f59e0b;
--danger: #ef4444;
--spotify: #1DB954;
--airplay: #60a5fa;
--font-ui: 'DM Sans', system-ui, sans-serif;
--font-mono: 'DM Mono', 'Courier New', monospace;
--radius: 8px;
--radius-lg: 16px;
}
/* ─── Reset ───────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root {
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: 16px;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
/* ─── Scrollbar ───────────────────────────────────────────────────────────── */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: var(--surface); }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* ─── Utilities ───────────────────────────────────────────────────────────── */
.mono { font-family: var(--font-mono); }
.muted { color: var(--muted); }
.accent { color: var(--accent); }
button {
cursor: pointer;
background: none;
border: none;
color: inherit;
font-family: inherit;
min-width: 44px;
min-height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius);
transition: background 0.15s, opacity 0.15s;
}
button:active { opacity: 0.7; }
input[type=range] {
-webkit-appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
background: var(--border);
outline: none;
cursor: pointer;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}

View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,38 @@
// Mock router: returns real API clients in production, mock implementations in dev.
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'
const isDev = import.meta.env.DEV
export function createApi() {
if (isDev) {
return {
signalk: createSignalKMock(),
snapcast: createSnapcastMock(),
mopidy: createMopidyMock(),
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'
return {
signalk: createSignalKClient(`ws://${signalkHost}:3000`),
snapcast: createSnapcastClient(`ws://${snapcastHost}:1705`),
mopidy: createMopidyClient(`ws://${mopidyHost}:6680`),
isMock: false,
}
}
// Singleton one API instance for the whole app
let _api = null
export function getApi() {
if (!_api) _api = createApi()
return _api
}

View File

@@ -0,0 +1,149 @@
// Simulates the Mopidy JSON-RPC WebSocket API.
const TRACKS = [
{
uri: 'mock:track:1',
name: 'Ocean Drive',
artists: [{ name: 'Duke Dumont' }],
album: { name: 'Ocean Drive', images: [] },
length: 232000,
},
{
uri: 'mock:track:2',
name: 'Feel It Still',
artists: [{ name: 'Portugal. The Man' }],
album: { name: 'Woodstock', images: [] },
length: 178000,
},
{
uri: 'mock:track:3',
name: 'Sailing',
artists: [{ name: 'Christopher Cross' }],
album: { name: 'Christopher Cross', images: [] },
length: 261000,
},
{
uri: 'mock:track:4',
name: 'Beyond the Sea',
artists: [{ name: 'Bobby Darin' }],
album: { name: 'That\'s All', images: [] },
length: 185000,
},
{
uri: 'mock:track:5',
name: 'Into the Mystic',
artists: [{ name: 'Van Morrison' }],
album: { name: 'Moondance', images: [] },
length: 215000,
},
]
const RADIO_STATIONS = [
{ uri: 'http://stream.swr3.de/swr3/mp3-128/stream.mp3', name: 'SWR3' },
{ uri: 'http://ndr.de/ndr1welle-nord-128.mp3', name: 'NDR 1 Welle Nord' },
{ uri: 'http://live-bauhaus.radiobt.de/bauhaus/mp3-128', name: 'Radio Bauhaus' },
]
export function createMopidyMock() {
let state = 'playing'
let currentIndex = 0
let position = 0
let positionTimer = null
const listeners = {}
function emit(event, data) {
if (listeners[event]) listeners[event].forEach(fn => fn(data))
}
function startTimer() {
if (positionTimer) return
positionTimer = setInterval(() => {
if (state === 'playing') {
position += 1000
const track = TRACKS[currentIndex]
if (position >= track.length) {
currentIndex = (currentIndex + 1) % TRACKS.length
position = 0
emit('event:trackPlaybackStarted', { tl_track: { track: TRACKS[currentIndex] } })
}
}
}, 1000)
}
startTimer()
async function call(method, params = {}) {
await new Promise(r => setTimeout(r, 15))
switch (method) {
case 'playback.get_current_track':
return TRACKS[currentIndex]
case 'playback.get_state':
return state
case 'playback.get_time_position':
return position
case 'playback.play':
state = 'playing'
emit('event:playbackStateChanged', { new_state: 'playing' })
return null
case 'playback.pause':
state = 'paused'
emit('event:playbackStateChanged', { new_state: 'paused' })
return null
case 'playback.stop':
state = 'stopped'
position = 0
emit('event:playbackStateChanged', { new_state: 'stopped' })
return null
case 'playback.next':
currentIndex = (currentIndex + 1) % TRACKS.length
position = 0
state = 'playing'
emit('event:trackPlaybackStarted', { tl_track: { track: TRACKS[currentIndex] } })
return null
case 'playback.previous':
currentIndex = (currentIndex - 1 + TRACKS.length) % TRACKS.length
position = 0
state = 'playing'
emit('event:trackPlaybackStarted', { tl_track: { track: TRACKS[currentIndex] } })
return null
case 'playback.seek':
position = params.time_position || 0
return null
case 'tracklist.get_tracks':
return TRACKS
case 'library.browse':
return TRACKS.map(t => ({ uri: t.uri, name: t.name, type: 'track' }))
case 'library.search':
return [{ tracks: TRACKS }]
default:
return null
}
}
return {
call,
on(event, fn) {
if (!listeners[event]) listeners[event] = []
listeners[event].push(fn)
},
off(event, fn) {
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
},
getTracks: () => TRACKS,
getRadioStations: () => RADIO_STATIONS,
}
}

View File

@@ -0,0 +1,118 @@
// Simulates a SignalK WebSocket delta stream with realistic Baltic Sea boat data.
const INTERVAL_MS = 1000
// Starting position: Kiel Fjord, Baltic Sea
const BASE_LAT = 54.3233
const BASE_LON = 10.1394
function randomWalk(value, min, max, step) {
const delta = (Math.random() - 0.5) * step * 2
return Math.min(max, Math.max(min, value + delta))
}
function degToRad(d) { return d * Math.PI / 180 }
export function createSignalKMock() {
const listeners = {}
let timer = null
let running = false
// Initial state
const state = {
sog: 5.2,
cog: 215,
heading: 217,
depth: 12.4,
windSpeed: 13.5,
windAngle: 42,
rpm: 1800,
battery1: 12.6, // starter
battery2: 25.1, // house (24V)
waterTemp: 17.8,
lat: BASE_LAT,
lon: BASE_LON,
routeIndex: 0,
rudder: 2.5,
airTemp: 14.2,
fuel: 68,
}
// Simulate boat moving along a rough course
function advancePosition() {
const speedMs = state.sog * 0.514 // knots to m/s
const headRad = degToRad(state.heading)
const dLat = (speedMs * Math.cos(headRad) * INTERVAL_MS / 1000) / 111320
const dLon = (speedMs * Math.sin(headRad) * INTERVAL_MS / 1000) / (111320 * Math.cos(degToRad(state.lat)))
state.lat += dLat
state.lon += dLon
}
function buildDelta() {
state.sog = randomWalk(state.sog, 3.5, 8, 0.15)
state.cog = randomWalk(state.cog, 200, 235, 1.5)
state.heading = randomWalk(state.heading, 198, 237, 1.2)
state.depth = randomWalk(state.depth, 6, 25, 0.3)
state.windSpeed = randomWalk(state.windSpeed, 8, 22, 0.8)
state.windAngle = randomWalk(state.windAngle, 25, 70, 2)
state.rpm = Math.round(randomWalk(state.rpm, 1500, 2100, 40))
state.battery1 = randomWalk(state.battery1, 12.2, 12.9, 0.02)
state.battery2 = randomWalk(state.battery2, 24.5, 25.6, 0.04)
state.waterTemp = randomWalk(state.waterTemp, 16, 20, 0.05)
state.rudder = randomWalk(state.rudder, -15, 15, 1.5)
advancePosition()
return {
updates: [{
source: { label: 'mock', type: 'NMEA2000' },
timestamp: new Date().toISOString(),
values: [
{ path: 'navigation.speedOverGround', value: state.sog * 0.514 },
{ path: 'navigation.courseOverGroundTrue', value: degToRad(state.cog) },
{ path: 'navigation.headingTrue', value: degToRad(state.heading) },
{ path: 'navigation.position', value: { latitude: state.lat, longitude: state.lon } },
{ path: 'environment.depth.belowKeel', value: state.depth },
{ path: 'environment.wind.speedApparent', value: state.windSpeed * 0.514 },
{ path: 'environment.wind.angleApparent', value: degToRad(state.windAngle) },
{ path: 'environment.water.temperature', value: state.waterTemp + 273.15 },
{ path: 'environment.outside.temperature', value: state.airTemp + 273.15 },
{ path: 'propulsion.main.revolutions', value: state.rpm / 60 },
{ path: 'electrical.batteries.starter.voltage', value: state.battery1 },
{ path: 'electrical.batteries.house.voltage', value: state.battery2 },
{ path: 'steering.rudderAngle', value: degToRad(state.rudder) },
{ path: 'tanks.fuel.0.currentLevel', value: state.fuel / 100 },
]
}]
}
}
function emit(event, data) {
if (listeners[event]) listeners[event].forEach(fn => fn(data))
}
function start() {
if (running) return
running = true
// Send initial delta immediately
emit('delta', buildDelta())
timer = setInterval(() => emit('delta', buildDelta()), INTERVAL_MS)
}
function stop() {
if (timer) clearInterval(timer)
running = false
}
return {
on(event, fn) {
if (!listeners[event]) listeners[event] = []
listeners[event].push(fn)
if (event === 'delta' && !running) start()
},
off(event, fn) {
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
},
getSnapshot: () => ({ ...state }),
disconnect: stop,
}
}

View File

@@ -0,0 +1,92 @@
// Simulates the Snapcast JSON-RPC API (WebSocket-based).
const STREAMS = [
{ id: 'Spotify', status: 'idle', uri: { name: 'Spotify' } },
{ id: 'AirPlay', status: 'idle', uri: { name: 'AirPlay' } },
{ id: 'Mopidy', status: 'playing', uri: { name: 'Mopidy' } },
]
const initialZones = [
{ id: 'zone-salon', name: 'Salon', volume: 72, muted: false, connected: true, stream: 'Mopidy' },
{ id: 'zone-cockpit', name: 'Cockpit', volume: 58, muted: false, connected: true, stream: 'Mopidy' },
{ id: 'zone-bug', name: 'Bug', volume: 45, muted: true, connected: true, stream: 'Spotify' },
{ id: 'zone-heck', name: 'Heck', volume: 60, muted: false, connected: false, stream: 'Mopidy' },
]
export function createSnapcastMock() {
const zones = initialZones.map(z => ({ ...z }))
const listeners = {}
function emit(event, data) {
if (listeners[event]) listeners[event].forEach(fn => fn(data))
}
function buildStatus() {
return {
server: {
groups: zones.map(z => ({
id: z.id,
name: z.name,
stream_id: z.stream,
clients: [{
id: z.id,
connected: z.connected,
config: {
name: z.name,
volume: { percent: z.volume, muted: z.muted }
}
}]
})),
streams: STREAMS,
version: '0.27.0',
}
}
}
async function call(method, params = {}) {
await new Promise(r => setTimeout(r, 10)) // simulate network
switch (method) {
case 'Server.GetStatus':
return buildStatus()
case 'Server.GetRPCVersion':
return { major: 2, minor: 0, patch: 0 }
case 'Client.SetVolume': {
const z = zones.find(z => z.id === params.id)
if (z) { z.volume = params.volume.percent; z.muted = params.volume.muted }
emit('update', buildStatus())
return {}
}
case 'Client.SetMuted': {
const z = zones.find(z => z.id === params.id)
if (z) z.muted = params.muted
emit('update', buildStatus())
return {}
}
case 'Group.SetStream': {
const z = zones.find(z => z.id === params.id)
if (z) z.stream = params.stream_id
emit('update', buildStatus())
return {}
}
default:
throw new Error(`Snapcast mock: unknown method "${method}"`)
}
}
return {
call,
on(event, fn) {
if (!listeners[event]) listeners[event] = []
listeners[event].push(fn)
},
off(event, fn) {
if (listeners[event]) listeners[event] = listeners[event].filter(l => l !== fn)
},
}
}

View File

@@ -0,0 +1,49 @@
import { useState } from 'react'
import NowPlaying from '../components/audio/NowPlaying.jsx'
import ZoneGrid from '../components/audio/ZoneGrid.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']
export default function Audio() {
const [subTab, setSubTab] = useState('Zones')
const [activeSource, setActiveSource] = useState('Mopidy')
return (
<div style={styles.page}>
<NowPlaying />
<SourcePicker activeSource={activeSource} onSelect={setActiveSource} />
{/* Sub-tabs */}
<div style={styles.subTabs}>
{SUB_TABS.map(t => (
<button key={t} style={{ ...styles.subTab, ...(subTab === t ? styles.subTabActive : {}) }}
onClick={() => setSubTab(t)}>
{t}
</button>
))}
</div>
<div style={styles.content}>
{subTab === 'Zones' && <ZoneGrid />}
{subTab === 'Radio' && <RadioBrowser />}
{subTab === 'Library' && <LibraryBrowser />}
</div>
</div>
)
}
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,
},
subTabActive: { background: 'var(--surface2)', color: 'var(--text)' },
content: { flex: 1 },
}

View File

@@ -0,0 +1,28 @@
import ChartPlaceholder from '../components/nav/ChartPlaceholder.jsx'
import InstrumentPanel from '../components/nav/InstrumentPanel.jsx'
export default function Navigation() {
return (
<div style={styles.layout}>
<div style={styles.chart}>
<ChartPlaceholder />
</div>
<div style={styles.panel}>
<InstrumentPanel />
</div>
</div>
)
}
const styles = {
layout: {
display: 'flex',
gap: 16,
padding: 16,
flex: 1,
overflow: 'hidden',
minHeight: 0,
},
chart: { flex: 2, display: 'flex', minHeight: 0 },
panel: { flex: 1, overflow: 'auto', minHeight: 0 },
}

View File

@@ -0,0 +1,42 @@
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 ZoneGrid from '../components/audio/ZoneGrid.jsx'
export default function Overview() {
const nmea = useNMEA()
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>
</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)',
},
sectionTitle: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 10 },
}

View File

@@ -0,0 +1,32 @@
import { useNMEA } from '../hooks/useNMEA.js'
import BatteryStatus from '../components/systems/BatteryStatus.jsx'
import EngineData from '../components/systems/EngineData.jsx'
import ServiceHealth from '../components/systems/ServiceHealth.jsx'
export default function Systems() {
const { battery1, battery2 } = useNMEA()
return (
<div style={styles.page}>
<section>
<div style={styles.sectionTitle}>Batteries</div>
<BatteryStatus battery1={battery1} battery2={battery2} />
</section>
<section>
<div style={styles.sectionTitle}>Engine</div>
<EngineData />
</section>
<section>
<div style={styles.sectionTitle}>Services</div>
<ServiceHealth />
</section>
</div>
)
}
const styles = {
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 20, overflow: 'auto', flex: 1 },
sectionTitle: { fontWeight: 600, fontSize: 12, color: 'var(--muted)', textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 10 },
}

View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 8080,
proxy: {
'/signalk': { target: 'http://localhost:3000', ws: true },
'/snapcast-ws': { target: 'http://localhost:1780', ws: true },
'/mopidy': { target: 'http://localhost:6680', ws: true },
'/jellyfin': { target: 'http://localhost:8096', changeOrigin: true },
}
}
})

View File

@@ -0,0 +1,58 @@
version: "3.9"
# Development override run without any hardware.
# Usage: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
services:
signalk:
environment:
- SIGNALK_DEMO=true # Built-in demo NMEA data generator
# Spotify Connect still works over TCP on dev (no host network needed)
librespot:
environment:
- LIBRESPOT_DISABLE_DISCOVERY=false
ports:
- "57621:57621/udp"
- "57621:57621/tcp"
# avahi container for AirPlay mDNS discovery on Mac/Windows
avahi:
image: flungo/avahi
restart: unless-stopped
network_mode: host
volumes:
- /var/run/dbus:/var/run/dbus
# Null-player zones no audio hardware needed
zone-salon:
command: snapclient --host snapserver --hostID zone-salon --player null
zone-cockpit:
command: snapclient --host snapserver --hostID zone-cockpit --player null
zone-bug:
command: snapclient --host snapserver --hostID zone-bug --player null
zone-heck:
command: snapclient --host snapserver --hostID zone-heck --player null
# Vite dev server with HMR instead of built nginx image
dashboard:
build: .
image: node:20-alpine
working_dir: /app
volumes:
- ./dashboard:/app
- /app/node_modules
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 8080"
ports:
- "8080:8080"
environment:
- VITE_SNAPCAST_HOST=localhost
- VITE_SIGNALK_HOST=localhost
- VITE_MOPIDY_HOST=localhost
- VITE_JELLYFIN_HOST=localhost
# Override the build-based image
image: node:20-alpine

View File

@@ -0,0 +1,204 @@
version: "3.9"
# Production docker-compose for boat deployment.
# For development use: docker compose -f docker-compose.yml -f docker-compose.dev.yml up
volumes:
signalk-data:
mopidy-data:
jellyfin-config:
jellyfin-cache:
portainer-data:
pipes:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
networks:
bordanlage:
driver: bridge
services:
# ─── Navigation ────────────────────────────────────────────────────────────
signalk:
image: signalk/signalk-server:latest
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- signalk-data:/home/node/.signalk
environment:
- SIGNALK_ADMIN_USERNAME=admin
- SIGNALK_ADMIN_PASSWORD=bordanlage
# Uncomment for real NMEA hardware on boat:
# devices:
# - /dev/ttyUSB0:/dev/ttyUSB0
networks:
- bordanlage
# ─── Audio Sources ─────────────────────────────────────────────────────────
librespot:
image: ghcr.io/librespot-org/librespot:latest
restart: unless-stopped
# On Linux (boat): use host network for mDNS/Spotify discovery
# network_mode: host
ports:
- "57621:57621/udp" # Spotify zeroconf discovery
- "57621:57621/tcp"
environment:
- SPOTIFY_NAME=${SPOTIFY_NAME:-Bordanlage}
- SPOTIFY_BITRATE=${SPOTIFY_BITRATE:-320}
command: >
--name "${SPOTIFY_NAME:-Bordanlage}"
--bitrate ${SPOTIFY_BITRATE:-320}
--backend pipe
--device /tmp/audio/spotify.pcm
--zeroconf-port 57621
--cache-size-limit ${SPOTIFY_CACHE_SIZE:-1024}
volumes:
- pipes:/tmp/audio
networks:
- bordanlage
shairport:
image: mikebrady/shairport-sync:latest
restart: unless-stopped
# On Linux (boat): use host network for mDNS/AirPlay discovery
# network_mode: host
ports:
- "5000:5000/tcp"
- "5000:5000/udp"
- "6001-6011:6001-6011/udp"
volumes:
- ./config/shairport.conf:/etc/shairport-sync.conf:ro
- pipes:/tmp/audio
networks:
- bordanlage
mopidy:
build: ./docker/mopidy
restart: unless-stopped
ports:
- "6680:6680"
volumes:
- ./config/mopidy.conf:/etc/mopidy/mopidy.conf:ro
- mopidy-data:/var/lib/mopidy
- ${MUSIC_PATH:-./music}:/music:ro
- pipes:/tmp/audio
networks:
- bordanlage
# ─── Media Library ─────────────────────────────────────────────────────────
jellyfin:
image: jellyfin/jellyfin:latest
restart: unless-stopped
ports:
- "8096:8096"
volumes:
- jellyfin-config:/config
- jellyfin-cache:/cache
- ${MUSIC_PATH:-./music}:/music:ro
environment:
- JELLYFIN_PublishedServerUrl=http://localhost:8096
# Uncomment for hardware video decoding on boat:
# devices:
# - /dev/dri:/dev/dri
networks:
- bordanlage
# ─── Multiroom Audio ───────────────────────────────────────────────────────
snapserver:
image: ghcr.io/badaix/snapcast:latest
restart: unless-stopped
ports:
- "1704:1704" # Snapcast protocol
- "1705:1705" # Control API (JSON-RPC)
- "1780:1780" # Snapweb UI
volumes:
- ./config/snapserver.conf:/etc/snapserver.conf:ro
- pipes:/tmp/audio
depends_on:
- librespot
- mopidy
command: snapserver -c /etc/snapserver.conf
networks:
- bordanlage
zone-salon:
image: ghcr.io/badaix/snapcast:latest
restart: unless-stopped
depends_on:
- snapserver
# On boat: add --soundcard hw:0,0 and device /dev/snd
# devices:
# - /dev/snd:/dev/snd
command: snapclient --host snapserver --hostID zone-salon --player null
networks:
- bordanlage
zone-cockpit:
image: ghcr.io/badaix/snapcast:latest
restart: unless-stopped
depends_on:
- snapserver
command: snapclient --host snapserver --hostID zone-cockpit --player null
networks:
- bordanlage
zone-bug:
image: ghcr.io/badaix/snapcast:latest
restart: unless-stopped
depends_on:
- snapserver
command: snapclient --host snapserver --hostID zone-bug --player null
networks:
- bordanlage
zone-heck:
image: ghcr.io/badaix/snapcast:latest
restart: unless-stopped
depends_on:
- snapserver
command: snapclient --host snapserver --hostID zone-heck --player null
networks:
- bordanlage
# ─── Management ────────────────────────────────────────────────────────────
portainer:
image: portainer/portainer-ce:latest
restart: unless-stopped
ports:
- "9000:9000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer-data:/data
networks:
- bordanlage
# ─── Dashboard ─────────────────────────────────────────────────────────────
dashboard:
build:
context: ./dashboard
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "8080:80"
environment:
- VITE_SNAPCAST_HOST=${VITE_SNAPCAST_HOST:-localhost}
- VITE_SIGNALK_HOST=${VITE_SIGNALK_HOST:-localhost}
- VITE_MOPIDY_HOST=${VITE_MOPIDY_HOST:-localhost}
- VITE_JELLYFIN_HOST=${VITE_JELLYFIN_HOST:-localhost}
depends_on:
- snapserver
- signalk
- mopidy
networks:
- bordanlage

View File

@@ -0,0 +1,20 @@
FROM ghcr.io/mopidy/mopidy:latest
USER root
RUN pip3 install \
mopidy-iris \
mopidy-local \
mopidy-stream \
mopidy-tunein \
mopidy-podcast \
--break-system-packages
# Ensure audio pipe directory exists at startup
RUN echo '#!/bin/sh\nmkdir -p /tmp/audio\nexec "$@"' > /entrypoint-wrapper.sh \
&& chmod +x /entrypoint-wrapper.sh
USER mopidy
ENTRYPOINT ["/entrypoint-wrapper.sh"]
CMD ["mopidy", "--config", "/etc/mopidy/mopidy.conf"]

View File

@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -e
PIPE_DIR="${PIPE_DIR:-/tmp/audio}"
mkdir -p "$PIPE_DIR"
for pipe in spotify.pcm airplay.pcm mopidy.pcm; do
path="$PIPE_DIR/$pipe"
if [ ! -p "$path" ]; then
mkfifo "$path"
echo "Created pipe: $path"
else
echo "Pipe already exists: $path"
fi
done
echo "All audio pipes ready."

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -e
echo "Setting up production (boat) environment..."
# Copy .env if not exists
if [ ! -f .env ]; then
cp .env.example .env
echo "Created .env please edit SPOTIFY_NAME, BOAT_NAME, BOAT_MMSI"
fi
# Create music directory
mkdir -p music
# Init pipes (persistent on boat)
PIPE_DIR=/tmp/audio bash scripts/init-pipes.sh
echo ""
echo "Edit .env then run 'make boot' to start."
echo "Dashboard: http://<boat-ip>:8080"
echo ""
echo "IMPORTANT: Review docker-compose.yml and uncomment:"
echo " - /dev/ttyUSB0 for NMEA hardware"
echo " - /dev/snd for zone audio output"
echo " - /dev/dri for hardware video decoding"

21
bordanlage/scripts/setup-dev.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -e
echo "Setting up development environment..."
# Copy .env if not exists
if [ ! -f .env ]; then
cp .env.example .env
echo "Created .env from .env.example"
fi
# Create music directory
mkdir -p music
echo "Music directory: ./music (drop your files here)"
# Init pipes
bash scripts/init-pipes.sh
echo ""
echo "Done! Run 'make dev' to start all services."
echo "Dashboard: http://localhost:8080"

482
plan.md Normal file
View File

@@ -0,0 +1,482 @@
# Bordanlage Vollständiges Multiroom Audio + Bootsdaten Dashboard
## Projektbeschreibung
Baue ein vollständiges, produktionsreifes System namens `bordanlage` für ein Boot.
Es besteht aus drei Teilen:
1. **Docker-Stack** alle Backend-Dienste (Audio, Navigation, Management)
2. **React-Dashboard** ein modernes, touch-optimiertes Web-UI für Touchscreen-Display
3. **Dev-Modus** vollständig ohne Hardware lauffähig (Mac & Windows), mit simulierten Daten
---
## Technologie-Stack
### Backend (Docker)
- **Snapcast** (snapserver + snapclient) → synchrones Multiroom-Audio, 4 Zonen
- **librespot** → Spotify Connect (echter Endpunkt, erscheint in Spotify App)
- **shairport-sync** → AirPlay 2 Empfänger (erscheint auf iPhone/Mac)
- **Mopidy** + Plugins → Web Radio (HTTP-Streams) + lokale Musikbibliothek
- **Jellyfin** → Mediathek (Musik, Hörbücher, Videos von Festplatte/USB)
- **SignalK** → NMEA 2000 / NMEA 0183 Gateway (Bootsdaten)
- **Portainer** → Docker Management UI
- **Nginx** → Reverse Proxy, served das Dashboard
### Frontend (React + Vite)
- React 18 + Vite
- Keine UI-Bibliothek eigenes Design-System
- WebSocket-Verbindung zu SignalK für Live-Bootsdaten
- Snapcast JSON-RPC API für Zonen-Steuerung
- Mopidy JSON-RPC API für Musik-Steuerung
- Jellyfin REST API für Mediathek
---
## Projektstruktur
Erstelle folgende Verzeichnisstruktur:
```
bordanlage/
├── docker-compose.yml # Produktion (Boot)
├── docker-compose.dev.yml # Development Override (Mac/Windows)
├── docker-compose.override.yml # Symlink → dev wenn DEV=true
├── .env.example
├── .env
├── Makefile # make dev / make boot / make stop / make logs
├── README.md
├── config/
│ ├── snapserver.conf
│ ├── mopidy.conf
│ ├── shairport.conf
│ └── nginx/
│ └── default.conf
├── dashboard/ # React App
│ ├── package.json
│ ├── vite.config.js
│ ├── index.html
│ └── src/
│ ├── main.jsx
│ ├── App.jsx
│ ├── api/
│ │ ├── snapcast.js # Snapcast JSON-RPC Client
│ │ ├── mopidy.js # Mopidy JSON-RPC Client
│ │ ├── signalk.js # SignalK WebSocket Client
│ │ └── jellyfin.js # Jellyfin REST Client
│ ├── mock/
│ │ ├── index.js # Mock-Router: echte API wenn prod, fake wenn dev
│ │ ├── signalk.mock.js # Simulierte NMEA-Daten mit realistischen Werten
│ │ ├── snapcast.mock.js # Simulierte Zonen, Lautstärke, Quellen
│ │ └── mopidy.mock.js # Simulierte Tracks, Radio, Wiedergabe
│ ├── hooks/
│ │ ├── useNMEA.js # Hook: NMEA-Daten (echt oder mock)
│ │ ├── useZones.js # Hook: Snapcast Zonen
│ │ ├── usePlayer.js # Hook: Wiedergabe-Steuerung
│ │ └── useDocker.js # Hook: Container-Status via Portainer API
│ ├── components/
│ │ ├── layout/
│ │ │ ├── TopBar.jsx
│ │ │ └── TabNav.jsx
│ │ ├── instruments/
│ │ │ ├── Gauge.jsx # Analoges Rundinstrument (SVG)
│ │ │ ├── Compass.jsx # Kompassrose (SVG, animiert)
│ │ │ ├── WindRose.jsx # Windrose (SVG, animiert)
│ │ │ ├── DepthSounder.jsx # Tiefenmesser mit Warngrenze
│ │ │ └── SpeedLog.jsx # Fahrtmesser
│ │ ├── audio/
│ │ │ ├── NowPlaying.jsx # Track-Info, Cover, Playback-Controls
│ │ │ ├── ZoneCard.jsx # Eine Snapcast-Zone
│ │ │ ├── ZoneGrid.jsx # Alle Zonen
│ │ │ ├── SourcePicker.jsx # Quelle wählen (Spotify/AirPlay/Radio/Jellyfin)
│ │ │ ├── RadioBrowser.jsx # Senderliste mit Suche
│ │ │ └── LibraryBrowser.jsx # Jellyfin Mediathek
│ │ ├── nav/
│ │ │ ├── ChartPlaceholder.jsx # Seekarten-Iframe (OpenCPN/SignalK)
│ │ │ └── InstrumentPanel.jsx
│ │ └── systems/
│ │ ├── BatteryStatus.jsx
│ │ ├── EngineData.jsx
│ │ └── ServiceHealth.jsx # Docker-Container Status
│ └── pages/
│ ├── Overview.jsx # Tab 1: Instrumente + Now Playing + Zonen
│ ├── Navigation.jsx # Tab 2: Seekarte + alle Navigationsdaten
│ ├── Audio.jsx # Tab 3: Vollständige Audio-Steuerung
│ └── Systems.jsx # Tab 4: Batterien, Motor, Docker-Status
└── scripts/
├── init-pipes.sh # Named Pipes anlegen
├── setup-dev.sh # Erstkonfiguration Entwicklung
└── setup-boot.sh # Erstkonfiguration echtes Boot
```
---
## Detaillierte Anforderungen
### 1. Docker Compose Produktion (`docker-compose.yml`)
Definiere folgende Services:
**signalk:**
- Image: `signalk/signalk-server:latest`
- Port: 3000
- Volume: signalk-data
- Kommentar für NMEA-Adapter: `/dev/ttyUSB0` (auskommentiert)
**librespot (Spotify Connect):**
- Image: `ghcr.io/librespot-org/librespot:latest`
- Umgebungsvariablen aus `.env`: `SPOTIFY_NAME`, `SPOTIFY_BITRATE` (default: 320)
- Backend: `pipe`, Output: `/tmp/audio/spotify.pcm`
- Volume: pipes
- WICHTIG: Der Service muss wirklich in der Spotify App als Gerät erscheinen.
Stelle sicher, dass `network_mode: host` auf Linux gesetzt ist (Boot).
Auf Mac/Windows alternative Port-Mappings verwenden.
**shairport-sync (AirPlay):**
- Image: `mikebrady/shairport-sync:latest`
- Config: `./config/shairport.conf` (Output auf `/tmp/audio/airplay.pcm`)
- Avahi/mDNS: Auf Linux `network_mode: host`. Auf Mac/Windows eigenen
avahi-daemon Container (`hauscontribs/avahi`) bereitstellen damit
AirPlay discoverable ist.
- Volume: pipes
**mopidy:**
- Image: `ghcr.io/mopidy/mopidy:latest`
- Port: 6680
- Plugins installieren via Custom-Dockerfile in `./docker/mopidy/`:
```
mopidy-iris
mopidy-local
mopidy-stream
mopidy-tunein (Web Radio Suche)
mopidy-podcast
```
- Audio-Output via GStreamer-Pipeline auf `/tmp/audio/mopidy.pcm`
- Volumes: pipes, mopidy-data, music
**jellyfin:**
- Image: `jellyfin/jellyfin:latest`
- Port: 8096
- Volumes: jellyfin-config, jellyfin-cache, music (read-only)
- Hardware-Decoding: `/dev/dri` auskommentiert für Boot
**snapserver:**
- Image: `ghcr.io/badaix/snapcast:latest`
- Ports: 1704 (Protokoll), 1705 (Control API), 1780 (Snapweb)
- Config: `./config/snapserver.conf`
- 3 Streams: Spotify, AirPlay, Mopidy
- depends_on: librespot, mopidy
**zone-salon, zone-cockpit, zone-bug, zone-heck:**
- Je ein `ghcr.io/badaix/snapcast:latest` Container als snapclient
- `--host snapserver --hostID <zonename>`
- Auf dem Boot: `--soundcard hw:N,0` und `/dev/snd` device
- Im Dev-Modus: Ohne Soundkarte (Audio wird simuliert, kein Fehler)
**portainer:**
- Image: `portainer/portainer-ce:latest`
- Port: 9000
- Volume: docker.sock + portainer-data
**dashboard:**
- Custom Dockerfile in `./dashboard/`
- Nginx serviert den gebauten React-Build
- Port: 8080
- Env-Variablen: alle API-URLs
### 2. Docker Compose Dev Override (`docker-compose.dev.yml`)
Überschreibt für lokale Entwicklung ohne Hardware:
- **signalk**: Zusätzliche Umgebungsvariable `SIGNALK_DEMO=true` →
SignalK generiert dann selbst Demo-NMEA-Daten (eingebautes Feature!)
- **snapclients**: Alle 4 Zonen erhalten `command: snapclient --host snapserver --hostID <name> --player null`
(Null-Player: kein Audio-Output, kein Fehler)
- **dashboard**: Statt gebautem Nginx → Vite Dev-Server mit Hot Reload
(`node:20-alpine`, `npm run dev`, Port 8080)
- **librespot**: `LIBRESPOT_DISABLE_DISCOVERY=false` → erscheint trotzdem in Spotify
(funktioniert über TCP auch ohne host network, solange Port 57621 gemappt)
- **shairport**: avahi-container für mDNS-Discovery auf Mac/Windows
### 3. `.env` Konfiguration
```env
# Allgemein
COMPOSE_PROJECT_NAME=bordanlage
DEV=true
# Spotify Connect
SPOTIFY_NAME=Bordanlage
SPOTIFY_BITRATE=320
SPOTIFY_CACHE_SIZE=1024
# Boot-Info
BOAT_NAME=Meine Yacht
BOAT_MMSI=123456789
# Pfade (Musik, Logs)
MUSIC_PATH=./music
```
### 4. Makefile
```makefile
dev:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
boot:
docker compose up -d
stop:
docker compose down
logs:
docker compose logs -f
rebuild:
docker compose build --no-cache
status:
docker compose ps
```
### 5. Dashboard Mock-System
Das Mock-System ist KRITISCH. Es muss folgendes können:
**`src/mock/index.js`:**
Exportiert einen `createApi()`-Factory, der prüft ob `import.meta.env.DEV === true`.
- Im Dev-Modus: gibt Mock-Implementierungen zurück
- Im Prod-Modus: gibt echte API-Clients zurück
**`src/mock/signalk.mock.js`:**
Simuliert einen SignalK WebSocket mit realistischen Bootsdaten.
Werte ändern sich kontinuierlich und realistisch:
- SOG: 47 Knoten, sanfte Schwankungen
- COG/Heading: 200230°, leichte Drift
- Tiefe: 820m, langsame Änderung
- Windgeschwindigkeit: 1018 Knoten, böig
- Windwinkel: 3060° (am Wind), variabel
- Motorendrehzahl: 16002000 RPM
- Batterien: Starter 12.412.8V, Bord 24.825.4V
- Wassertemperatur: 1719°C
- GPS: bewegt sich entlang einer realistischen Route (Ostsee-Koordinaten)
Gibt ein EventEmitter-ähnliches Objekt zurück, das `on('delta', callback)` unterstützt.
Format: echtes SignalK Delta-Format (`{"updates": [{"values": [...]}]}`)
**`src/mock/snapcast.mock.js`:**
Simuliert die Snapcast JSON-RPC API:
- 4 Zonen (Salon, Cockpit, Bug, Heck)
- Salon + Cockpit initial aktiv
- Implementiert: `Server.GetStatus`, `Client.SetVolume`, `Client.SetMuted`,
`Group.SetStream`, `Server.GetRPCVersion`
**`src/mock/mopidy.mock.js`:**
Simuliert die Mopidy WebSocket JSON-RPC API:
- Tracks mit realistischen Metadaten (Titel, Künstler, Album, Dauer)
- Wiedergabestatus: playing/paused/stopped
- Position läuft mit
- Implementiert: `playback.get_current_track`, `playback.get_state`,
`playback.get_time_position`, `playback.play`, `playback.pause`,
`playback.next`, `playback.previous`, `tracklist.get_tracks`,
`library.browse`, `library.search`
### 6. Dashboard API-Clients
**`src/api/snapcast.js`:**
Echter Snapcast JSON-RPC Client über WebSocket (`ws://host:1705`).
Implementiert alle Methoden des Mock-Clients.
Reconnect-Logik mit exponential backoff.
**`src/api/signalk.js`:**
Echter SignalK WebSocket Client (`ws://host:3000/signalk/v1/stream`).
Abonniert alle relevanten Pfade:
- `navigation.speedOverGround`
- `navigation.courseOverGroundTrue`
- `navigation.headingTrue`
- `environment.depth.belowKeel`
- `environment.wind.speedApparent`
- `environment.wind.angleApparent`
- `propulsion.*.revolutions`
- `electrical.batteries.*.voltage`
**`src/api/mopidy.js`:**
Echter Mopidy JSON-RPC Client über WebSocket (`ws://host:6680/mopidy/ws`).
**`src/api/jellyfin.js`:**
Jellyfin REST API Client. Authentifizierung via API-Key aus `.env`.
Implementiert: Musik browsen, Alben, Künstler, Suche, Stream-URL.
### 7. Dashboard Hooks
**`useNMEA()`:**
Verbindet sich mit SignalK (echt oder mock).
Gibt strukturiertes Objekt zurück:
```js
{
sog, cog, heading, depth, windSpeed, windAngle, windDirection,
lat, lon, rpm, battery1, battery2, waterTemp, airTemp, rudder, fuel
}
```
**`useZones()`:**
Verbindet sich mit Snapcast.
Gibt zurück:
```js
{
zones, // Array aller Zonen mit {id, name, active, volume, source, muted}
setVolume, // (zoneId, volume) => void
setMuted, // (zoneId, muted) => void
setSource, // (zoneId, streamId) => void
toggleZone, // (zoneId) => void
}
```
**`usePlayer()`:**
Verbindet sich mit Mopidy und Spotify-Status-API.
Gibt zurück:
```js
{
currentTrack, // {title, artist, album, duration, coverUrl}
state, // 'playing' | 'paused' | 'stopped'
position, // Sekunden
activeSource, // 'spotify' | 'airplay' | 'mopidy' | 'jellyfin'
play, pause, next, previous, seek
}
```
### 8. Dashboard Design
**Farbschema:** Nautisch-dunkel. Hauptfarben:
- Background: `#07111f` (tiefes Marineblau)
- Surface: `#0a1928`
- Border: `#1e2a3a`
- Text primary: `#e2eaf2`
- Text muted: `#4a6080`
- Accent: `#38bdf8` (Cyan/Himmelblau)
- Success: `#34d399`
- Warning: `#f59e0b`
- Danger: `#ef4444`
- Spotify: `#1DB954`
- AirPlay: `#60a5fa`
**Schriften:** `DM Mono` für Instrumentenwerte, `DM Sans` für UI-Text.
**Instrumente (SVG-basiert, animiert):**
- Rundinstrumente mit Zeiger (Gauge.jsx): SOG, Tiefe, RPM, Wassertemp
- Kompassrose (animiert, dreht sich): Heading
- Windrose: Windrichtung + Geschwindigkeit
- Alle Instrumente reagieren flüssig auf Daten-Updates (CSS transitions)
**Touch-Optimierung:**
- Alle Buttons mindestens 44×44px
- Volume Slider touch-fähig (pointer events)
- Keine Hover-only Interaktionen
**Tabs:**
- Übersicht: Alle Instrumente + Now Playing + Zonen-Schnellübersicht
- Navigation: Seekarte (SignalK iframe / Placeholder) + Detaildaten
- Audio: Vollständige Zonen-Steuerung + Quelle + Radio + Bibliothek
- Systeme: Batterien, Motor, Kraftstoff, Container-Status
**DEV-Indikator:**
Wenn `import.meta.env.DEV === true`: kleines Badge "DEV · MOCK DATA" in der TopBar.
### 9. Snapserver Konfiguration
```ini
[stream]
source = pipe:///tmp/audio/spotify.pcm?name=Spotify&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
source = pipe:///tmp/audio/airplay.pcm?name=AirPlay&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
source = pipe:///tmp/audio/mopidy.pcm?name=Mopidy&codec=pcm&sampleformat=44100:16:2&chunk_ms=20
[server]
threads = -1
[http]
enabled = true
bind_to_address = 0.0.0.0
port = 1780
[logging]
sink = system
filter = *:info
```
### 10. Shairport-sync Konfiguration
```conf
general = {
name = "Bordanlage AirPlay";
port = 5000;
interpolation = "auto";
output_backend = "pipe";
};
pipe = {
name = "/tmp/audio/airplay.pcm";
};
```
### 11. README.md
Erstelle eine vollständige README mit:
- Voraussetzungen (Docker Desktop, make)
- Schnellstart (`make dev` → http://localhost:8080)
- Alle Service-URLs
- Anleitung: Wie erscheint der Spotify Connect Endpunkt?
- Anleitung: Wie erscheint der AirPlay Endpunkt?
- Anleitung: Wie füge ich Musik hinzu? (./music Ordner)
- Anleitung: Wie verbinde ich echte NMEA-Hardware?
- Migration Boot: Was muss geändert werden? (network_mode, soundcards, dri)
- Troubleshooting (häufige Fehler auf Mac/Windows)
---
## Implementierungsreihenfolge
1. Projektstruktur und alle Konfigurationsdateien anlegen
2. `docker-compose.yml` + `docker-compose.dev.yml` + `.env` + `Makefile`
3. Mopidy Custom-Dockerfile mit Plugins
4. Dashboard Grundgerüst (Vite + React Setup)
5. Mock-System vollständig implementieren
6. Echte API-Clients implementieren
7. Hooks implementieren (useNMEA, useZones, usePlayer)
8. UI-Komponenten (Instrumente, Audio, Navigation)
9. Pages (Overview, Navigation, Audio, Systems)
10. Dashboard Dockerfile + Nginx-Config
11. README.md
---
## Wichtige Hinweise für die Implementierung
- **Spotify Connect** funktioniert über mDNS. Auf Mac/Windows muss Port 57621 (UDP)
gemappt sein und `--zeroconf-port 57621` an librespot übergeben werden.
Alternativ: Spotify-Nutzer kann den Endpunkt auch manuell über "Gerät verbinden"
mit der IP des Computers finden.
- **AirPlay** braucht Bonjour/mDNS. Auf Mac läuft Bonjour nativ, daher kann
shairport-sync mit `network_mode: host` auf Mac funktionieren wenn
`--host` Netzwerk erlaubt ist. Auf Windows WSL2 muss avahi im Container laufen.
- **Named Pipes** müssen VOR dem Start existieren. Das `init-pipes.sh` Skript
legt sie an. In Docker: via `entrypoint` sicherstellen dass die Pipes existieren
bevor der eigentliche Prozess startet.
- **Multiroom** bedeutet: alle aktiven Snapclients spielen synchron denselben Stream.
Jede Zone kann aber einen anderen Stream (Quelle) zugewiesen bekommen.
- **Fehlertoleranz**: Wenn eine API nicht erreichbar ist (z.B. SignalK offline),
soll das Dashboard nicht abstürzen stattdessen graceful degradation mit
"Nicht verbunden" Anzeige.
Starte jetzt mit der Implementierung. Beginne mit der Verzeichnisstruktur und
arbeite dich dann systematisch durch alle Komponenten.