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"