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

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