commit 946c0a53774a7a8cfa45b252bebdc4b7b7aaa48a Author: denshooter Date: Thu Mar 26 11:47:33 2026 +0100 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 diff --git a/bordanlage/.env b/bordanlage/.env new file mode 100644 index 0000000..453368a --- /dev/null +++ b/bordanlage/.env @@ -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 diff --git a/bordanlage/.env.example b/bordanlage/.env.example new file mode 100644 index 0000000..453368a --- /dev/null +++ b/bordanlage/.env.example @@ -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 diff --git a/bordanlage/Makefile b/bordanlage/Makefile new file mode 100644 index 0000000..6e17da5 --- /dev/null +++ b/bordanlage/Makefile @@ -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 diff --git a/bordanlage/README.md b/bordanlage/README.md new file mode 100644 index 0000000..91ecf0c --- /dev/null +++ b/bordanlage/README.md @@ -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 (1–2 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` diff --git a/bordanlage/config/mopidy.conf b/bordanlage/config/mopidy.conf new file mode 100644 index 0000000..4f6fd42 --- /dev/null +++ b/bordanlage/config/mopidy.conf @@ -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 diff --git a/bordanlage/config/nginx/default.conf b/bordanlage/config/nginx/default.conf new file mode 100644 index 0000000..a5ab934 --- /dev/null +++ b/bordanlage/config/nginx/default.conf @@ -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; +} diff --git a/bordanlage/config/shairport.conf b/bordanlage/config/shairport.conf new file mode 100644 index 0000000..5b7ba5e --- /dev/null +++ b/bordanlage/config/shairport.conf @@ -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; +}; diff --git a/bordanlage/config/snapserver.conf b/bordanlage/config/snapserver.conf new file mode 100644 index 0000000..b62fd34 --- /dev/null +++ b/bordanlage/config/snapserver.conf @@ -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 diff --git a/bordanlage/dashboard/Dockerfile b/bordanlage/dashboard/Dockerfile new file mode 100644 index 0000000..d8ce2fb --- /dev/null +++ b/bordanlage/dashboard/Dockerfile @@ -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;"] diff --git a/bordanlage/dashboard/index.html b/bordanlage/dashboard/index.html new file mode 100644 index 0000000..a39d3a6 --- /dev/null +++ b/bordanlage/dashboard/index.html @@ -0,0 +1,15 @@ + + + + + + Bordanlage + + + + + +
+ + + diff --git a/bordanlage/dashboard/nginx.conf b/bordanlage/dashboard/nginx.conf new file mode 100644 index 0000000..c41f49a --- /dev/null +++ b/bordanlage/dashboard/nginx.conf @@ -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; +} diff --git a/bordanlage/dashboard/package.json b/bordanlage/dashboard/package.json new file mode 100644 index 0000000..edd5813 --- /dev/null +++ b/bordanlage/dashboard/package.json @@ -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" + } +} diff --git a/bordanlage/dashboard/src/App.jsx b/bordanlage/dashboard/src/App.jsx new file mode 100644 index 0000000..5e9d6d1 --- /dev/null +++ b/bordanlage/dashboard/src/App.jsx @@ -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 ( +
+ + +
+ +
+
+ ) +} + +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, + }, +} diff --git a/bordanlage/dashboard/src/api/jellyfin.js b/bordanlage/dashboard/src/api/jellyfin.js new file mode 100644 index 0000000..a12d3da --- /dev/null +++ b/bordanlage/dashboard/src/api/jellyfin.js @@ -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}` + } + } +} diff --git a/bordanlage/dashboard/src/api/mopidy.js b/bordanlage/dashboard/src/api/mopidy.js new file mode 100644 index 0000000..a355a5d --- /dev/null +++ b/bordanlage/dashboard/src/api/mopidy.js @@ -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() + } + } +} diff --git a/bordanlage/dashboard/src/api/signalk.js b/bordanlage/dashboard/src/api/signalk.js new file mode 100644 index 0000000..4f71753 --- /dev/null +++ b/bordanlage/dashboard/src/api/signalk.js @@ -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() + } + } +} diff --git a/bordanlage/dashboard/src/api/snapcast.js b/bordanlage/dashboard/src/api/snapcast.js new file mode 100644 index 0000000..dcc155b --- /dev/null +++ b/bordanlage/dashboard/src/api/snapcast.js @@ -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() + } + } +} diff --git a/bordanlage/dashboard/src/components/audio/LibraryBrowser.jsx b/bordanlage/dashboard/src/components/audio/LibraryBrowser.jsx new file mode 100644 index 0000000..41e712b --- /dev/null +++ b/bordanlage/dashboard/src/components/audio/LibraryBrowser.jsx @@ -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
Loading library…
+ if (!tracks.length) return
No tracks found. Add music to ./music
+ + return ( +
+ {tracks.map((track, i) => ( + + ))} +
+ ) +} + +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' }, +} diff --git a/bordanlage/dashboard/src/components/audio/NowPlaying.jsx b/bordanlage/dashboard/src/components/audio/NowPlaying.jsx new file mode 100644 index 0000000..8480400 --- /dev/null +++ b/bordanlage/dashboard/src/components/audio/NowPlaying.jsx @@ -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 ( +
+ Audio not connected +
+ ) + } + + const progress = currentTrack?.duration + ? Math.min(100, (position / currentTrack.duration) * 100) + : 0 + + return ( +
+ {/* Cover placeholder */} +
+ {currentTrack?.coverUrl + ? cover + : } +
+ +
+
{currentTrack?.title || 'Nothing playing'}
+
{currentTrack?.artist || ''}
+ {!compact &&
{currentTrack?.album || ''}
} + + {/* Progress bar */} + {currentTrack && ( +
+ {formatTime(position)} +
+
+
+ {formatTime(currentTrack.duration)} +
+ )} + + {/* Controls */} +
+ + + +
+
+
+ ) +} + +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 }, +} diff --git a/bordanlage/dashboard/src/components/audio/RadioBrowser.jsx b/bordanlage/dashboard/src/components/audio/RadioBrowser.jsx new file mode 100644 index 0000000..03a24c1 --- /dev/null +++ b/bordanlage/dashboard/src/components/audio/RadioBrowser.jsx @@ -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 ( +
+
Radio Stations
+ {BUILT_IN_STATIONS.map(s => ( + + ))} +
+ ) +} + +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 }, +} diff --git a/bordanlage/dashboard/src/components/audio/SourcePicker.jsx b/bordanlage/dashboard/src/components/audio/SourcePicker.jsx new file mode 100644 index 0000000..ba89c29 --- /dev/null +++ b/bordanlage/dashboard/src/components/audio/SourcePicker.jsx @@ -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 ( +
+ {SOURCES.map(s => ( + + ))} +
+ ) +} + +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, + }, +} diff --git a/bordanlage/dashboard/src/components/audio/ZoneCard.jsx b/bordanlage/dashboard/src/components/audio/ZoneCard.jsx new file mode 100644 index 0000000..331a859 --- /dev/null +++ b/bordanlage/dashboard/src/components/audio/ZoneCard.jsx @@ -0,0 +1,53 @@ +export default function ZoneCard({ zone, onVolume, onMute, onSource }) { + const { id, name, active, volume, muted, source } = zone + + return ( +
+
+ {name} +
+ + {active ? 'ON' : 'OFF'} + +
+
+ +
{source}
+ +
+ + onVolume(id, Number(e.target.value))} + style={{ flex: 1 }} + /> + {muted ? '–' : volume} +
+
+ ) +} + +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)' }, +} diff --git a/bordanlage/dashboard/src/components/audio/ZoneGrid.jsx b/bordanlage/dashboard/src/components/audio/ZoneGrid.jsx new file mode 100644 index 0000000..fa9d199 --- /dev/null +++ b/bordanlage/dashboard/src/components/audio/ZoneGrid.jsx @@ -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
Loading zones…
+ } + + return ( +
+ {zones.map(zone => ( + + ))} +
+ ) +} + +const styles = { + grid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', + gap: 12, + } +} diff --git a/bordanlage/dashboard/src/components/instruments/Compass.jsx b/bordanlage/dashboard/src/components/instruments/Compass.jsx new file mode 100644 index 0000000..391ab33 --- /dev/null +++ b/bordanlage/dashboard/src/components/instruments/Compass.jsx @@ -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 ( + + {/* Outer ring */} + + + + {/* Rotating rose */} + + {/* 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 ( + + ) + })} + + {/* Cardinal labels */} + {CARDINALS.map(c => { + const p = polarXY(CX, CY, R - 22, c.angle) + return ( + + {c.label} + + ) + })} + + + {/* Fixed lubber line (ship's bow = top) */} + + + {/* COG indicator */} + {hasCog && (() => { + const cogAngle = cog - hdg + const tip = polarXY(CX, CY, R - 6, cogAngle) + return ( + + ) + })()} + + {/* Center */} + + + {/* Heading value */} + + {Math.round(hdg).toString().padStart(3, '0')}° + + + HEADING + + + ) +} diff --git a/bordanlage/dashboard/src/components/instruments/DepthSounder.jsx b/bordanlage/dashboard/src/components/instruments/DepthSounder.jsx new file mode 100644 index 0000000..feb9000 --- /dev/null +++ b/bordanlage/dashboard/src/components/instruments/DepthSounder.jsx @@ -0,0 +1,16 @@ +// Depth sounder with alarm threshold. +import Gauge from './Gauge.jsx' + +export default function DepthSounder({ depth }) { + return ( + + ) +} diff --git a/bordanlage/dashboard/src/components/instruments/Gauge.jsx b/bordanlage/dashboard/src/components/instruments/Gauge.jsx new file mode 100644 index 0000000..eed9028 --- /dev/null +++ b/bordanlage/dashboard/src/components/instruments/Gauge.jsx @@ -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 ( + + {/* Background arc */} + + + {/* Warning zone */} + {warnRatio != null && ( + + )} + {/* Danger zone */} + {dangRatio != null && ( + + )} + + {/* Progress arc */} + + + {/* Ticks */} + {ticks.map((t, i) => ( + + ))} + + {/* Needle */} + + + {/* Center cap */} + + + {/* Value text */} + + {value != null ? (Number.isInteger(max - min) && max - min <= 20 + ? Math.round(clampedVal) + : clampedVal.toFixed(1)) : '--'} + + + {unit} + + + {/* Label */} + + {label.toUpperCase()} + + + ) +} diff --git a/bordanlage/dashboard/src/components/instruments/SpeedLog.jsx b/bordanlage/dashboard/src/components/instruments/SpeedLog.jsx new file mode 100644 index 0000000..b9b21df --- /dev/null +++ b/bordanlage/dashboard/src/components/instruments/SpeedLog.jsx @@ -0,0 +1,14 @@ +// Speed through water / SOG gauge. +import Gauge from './Gauge.jsx' + +export default function SpeedLog({ sog }) { + return ( + + ) +} diff --git a/bordanlage/dashboard/src/components/instruments/WindRose.jsx b/bordanlage/dashboard/src/components/instruments/WindRose.jsx new file mode 100644 index 0000000..a1a074a --- /dev/null +++ b/bordanlage/dashboard/src/components/instruments/WindRose.jsx @@ -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 ( + + {/* Background rings */} + + + + {/* Dividers every 45° */} + {Array.from({ length: 8 }, (_, i) => { + const p = polarXY(CX, CY, R, i * 45) + return + })} + + {/* Wind arrow */} + + + + + + + + {/* Speed value */} + + {speed.toFixed(1)} + + + kn + + + {/* Labels */} + {[['N', 0], ['E', 90], ['S', 180], ['W', 270]].map(([l, a]) => { + const p = polarXY(CX, CY, R + 12, a) + return ( + + {l} + + ) + })} + + + WIND + + + ) +} diff --git a/bordanlage/dashboard/src/components/layout/TabNav.jsx b/bordanlage/dashboard/src/components/layout/TabNav.jsx new file mode 100644 index 0000000..f4e6afd --- /dev/null +++ b/bordanlage/dashboard/src/components/layout/TabNav.jsx @@ -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 ( + + ) +} + +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' }, +} diff --git a/bordanlage/dashboard/src/components/layout/TopBar.jsx b/bordanlage/dashboard/src/components/layout/TopBar.jsx new file mode 100644 index 0000000..ce96224 --- /dev/null +++ b/bordanlage/dashboard/src/components/layout/TopBar.jsx @@ -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 ( +
+
+ ⚓ Bordanlage + {isDev && DEV · MOCK DATA} +
+ +
+ {connected && sog != null && ( + + {sog.toFixed(1)} + kn + + )} + {connected && heading != null && ( + + {Math.round(heading)}° + HDG + + )} + {!connected && ( + No signal + )} +
+ +
+ {time} +
+
+ ) +} + +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)' }, +} diff --git a/bordanlage/dashboard/src/components/nav/ChartPlaceholder.jsx b/bordanlage/dashboard/src/components/nav/ChartPlaceholder.jsx new file mode 100644 index 0000000..148c8ba --- /dev/null +++ b/bordanlage/dashboard/src/components/nav/ChartPlaceholder.jsx @@ -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 ( +
+