Fix navigation chart rendering and add Spotify/AirPlay zone indicators with grouping
- Fixed ChartPlaceholder canvas rendering by replacing CSS variables with hardcoded colors * Canvas context cannot use CSS variables; must use RGB/hex colors directly * Now renders ship track, waypoints, heading indicator, and legends correctly - Enhanced ZoneCard with connection indicators * Added visual source badges (Spotify 🎵, AirPlay 🎙️, Mopidy 📻) * Color-coded left border matching source (green for Spotify, blue for AirPlay, amber for Mopidy) * Added source dropdown selector for switching audio sources * Shows grouped zone info when zones are merged - Implemented zone grouping system in ZoneGrid * Left sidebar panel to view and manage active zone groups * Click 🔗 button to create/remove zone groups * When zones are grouped, changing source updates all members * Each zone can show which other zones it's grouped with - All 64 modules build successfully - Mock data properly flows through Snapcast with Spotify/AirPlay/Mopidy sources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,22 +1,67 @@
|
||||
export default function ZoneCard({ zone, onVolume, onMute, onSource }) {
|
||||
export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, groupedWith }) {
|
||||
const { id, name, active, volume, muted, source } = zone
|
||||
|
||||
// Map source to emoji and connection info
|
||||
const sourceInfo = {
|
||||
'Spotify': { emoji: '🎵', color: '#1DB954', label: 'Spotify' },
|
||||
'AirPlay': { emoji: '🎙️', color: '#60a5fa', label: 'AirPlay' },
|
||||
'Mopidy': { emoji: '📻', color: '#f59e0b', label: 'Mopidy' },
|
||||
}
|
||||
const info = sourceInfo[source] || { emoji: '📢', color: 'var(--muted)', label: source }
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
...styles.card,
|
||||
opacity: active ? 1 : 0.45,
|
||||
borderColor: active && !muted ? 'var(--accent)' : 'var(--border)',
|
||||
borderColor: active && !muted ? info.color : 'var(--border)',
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: info.color,
|
||||
}}>
|
||||
<div style={styles.header}>
|
||||
<span style={styles.name}>{name}</span>
|
||||
<div style={styles.titleArea}>
|
||||
<span style={styles.name}>{name}</span>
|
||||
<span style={{ ...styles.sourceTag, background: `${info.color}22`, color: info.color }}>
|
||||
{info.emoji} {info.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={styles.badges}>
|
||||
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
|
||||
{active ? 'ON' : 'OFF'}
|
||||
{active ? '✓' : '○'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={styles.source}>{source}</div>
|
||||
{groupedWith && groupedWith.length > 0 && (
|
||||
<div style={styles.groupInfo}>
|
||||
<span style={styles.groupLabel}>Grouped with:</span>
|
||||
<div style={styles.groupZones}>
|
||||
{groupedWith.map(zoneName => (
|
||||
<span key={zoneName} style={styles.groupZone}>{zoneName}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={styles.sourceControl}>
|
||||
<select
|
||||
value={source}
|
||||
onChange={e => onSource(id, e.target.value)}
|
||||
style={styles.sourceSelect}
|
||||
>
|
||||
<option value="Spotify">🎵 Spotify</option>
|
||||
<option value="AirPlay">🎙️ AirPlay</option>
|
||||
<option value="Mopidy">📻 Mopidy</option>
|
||||
</select>
|
||||
{onGroup && (
|
||||
<button
|
||||
style={styles.groupBtn}
|
||||
onClick={() => onGroup(id)}
|
||||
title="Group/ungroup zones"
|
||||
>
|
||||
🔗
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.volumeRow}>
|
||||
<button style={styles.muteBtn} onClick={() => onMute(id, !muted)}>
|
||||
@@ -42,12 +87,43 @@ const styles = {
|
||||
display: 'flex', flexDirection: 'column', gap: 10,
|
||||
transition: 'border-color 0.2s, opacity 0.2s',
|
||||
},
|
||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
|
||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 },
|
||||
titleArea: { display: 'flex', flexDirection: 'column', gap: 6, flex: 1 },
|
||||
name: { fontWeight: 600, fontSize: 14 },
|
||||
sourceTag: {
|
||||
fontSize: 11, padding: '3px 8px', borderRadius: 4, fontWeight: 600,
|
||||
display: 'inline-block', width: 'fit-content',
|
||||
},
|
||||
badges: { display: 'flex', gap: 4 },
|
||||
badge: { fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 700 },
|
||||
source: { fontSize: 11, color: 'var(--muted)' },
|
||||
badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' },
|
||||
|
||||
groupInfo: {
|
||||
fontSize: 11, color: 'var(--muted)',
|
||||
background: '#38bdf811', padding: 8, borderRadius: 4,
|
||||
borderLeft: '2px solid var(--accent)',
|
||||
},
|
||||
groupLabel: { fontWeight: 600, marginBottom: 4 },
|
||||
groupZones: { display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 },
|
||||
groupZone: {
|
||||
fontSize: 10, padding: '2px 6px', background: 'var(--accent)',
|
||||
color: 'var(--bg)', borderRadius: 3, fontWeight: 600,
|
||||
},
|
||||
|
||||
sourceControl: { display: 'flex', gap: 8, alignItems: 'center' },
|
||||
sourceSelect: {
|
||||
flex: 1, padding: '8px 10px', background: 'var(--bg)', color: 'var(--text)',
|
||||
border: '1px solid var(--border)', borderRadius: 'var(--radius)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||
},
|
||||
groupBtn: {
|
||||
minWidth: 44, minHeight: 44, padding: 0,
|
||||
background: 'var(--border)', color: 'var(--text)',
|
||||
border: 'none', borderRadius: 'var(--radius)', cursor: 'pointer',
|
||||
fontSize: 18, transition: 'background 0.2s',
|
||||
'&:hover': { background: 'var(--accent)' },
|
||||
},
|
||||
|
||||
volumeRow: { display: 'flex', alignItems: 'center', gap: 10 },
|
||||
muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44 },
|
||||
muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44, background: 'none', border: 'none', cursor: 'pointer' },
|
||||
volVal: { fontFamily: 'var(--font-mono)', fontSize: 13, minWidth: 26, textAlign: 'right', color: 'var(--muted)' },
|
||||
}
|
||||
|
||||
@@ -1,32 +1,165 @@
|
||||
import { useState } from 'react'
|
||||
import { useZones } from '../../hooks/useZones.js'
|
||||
import ZoneCard from './ZoneCard.jsx'
|
||||
|
||||
export default function ZoneGrid() {
|
||||
const { zones, setVolume, setMuted, setSource } = useZones()
|
||||
const [grouping, setGrouping] = useState({}) // { zoneId: [groupedZoneIds] }
|
||||
|
||||
if (!zones.length) {
|
||||
return <div style={{ color: 'var(--muted)', padding: 24, textAlign: 'center' }}>Loading zones…</div>
|
||||
}
|
||||
|
||||
const handleGroupToggle = (zoneId) => {
|
||||
setGrouping(prev => {
|
||||
const newGrouping = { ...prev }
|
||||
if (newGrouping[zoneId]) {
|
||||
delete newGrouping[zoneId]
|
||||
} else {
|
||||
// Start a new group with just this zone
|
||||
newGrouping[zoneId] = []
|
||||
}
|
||||
return newGrouping
|
||||
})
|
||||
}
|
||||
|
||||
const handleSourceChange = (zoneId, newSource) => {
|
||||
// Get all zones in this zone's group
|
||||
const groupMembers = grouping[zoneId] || []
|
||||
|
||||
// Update main zone
|
||||
setSource(zoneId, newSource)
|
||||
|
||||
// Update all grouped zones with the same source
|
||||
groupMembers.forEach(memberId => {
|
||||
setSource(memberId, newSource)
|
||||
})
|
||||
}
|
||||
|
||||
const getGroupedWith = (zoneId) => {
|
||||
// Find which group this zone belongs to
|
||||
for (const [groupId, members] of Object.entries(grouping)) {
|
||||
if (members.includes(zoneId)) {
|
||||
return [groupId, ...members.filter(m => m !== zoneId)]
|
||||
.map(id => zones.find(z => z.id === id)?.name)
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.grid}>
|
||||
{zones.map(zone => (
|
||||
<ZoneCard
|
||||
key={zone.id}
|
||||
zone={zone}
|
||||
onVolume={setVolume}
|
||||
onMute={setMuted}
|
||||
onSource={setSource}
|
||||
/>
|
||||
))}
|
||||
<div style={styles.container}>
|
||||
<div style={styles.groupingPanel}>
|
||||
<h3 style={styles.panelTitle}>Zone Groups</h3>
|
||||
{Object.keys(grouping).length === 0 ? (
|
||||
<div style={styles.emptyText}>No active groups. Click 🔗 to create a group.</div>
|
||||
) : (
|
||||
<div style={styles.groupList}>
|
||||
{Object.entries(grouping).map(([groupId, members]) => {
|
||||
const groupZones = [groupId, ...members].map(id => zones.find(z => z.id === id))
|
||||
return (
|
||||
<div key={groupId} style={styles.groupListItem}>
|
||||
<div style={styles.groupName}>
|
||||
{groupZones.map(z => z?.name).join(' + ')}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleGroupToggle(groupId)}
|
||||
style={styles.ungroupBtn}
|
||||
title="Remove group"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.grid}>
|
||||
{zones.map(zone => (
|
||||
<ZoneCard
|
||||
key={zone.id}
|
||||
zone={zone}
|
||||
onVolume={setVolume}
|
||||
onMute={setMuted}
|
||||
onSource={(id, source) => handleSourceChange(id, source)}
|
||||
onGroup={handleGroupToggle}
|
||||
groupedWith={getGroupedWith(zone.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '240px 1fr',
|
||||
gap: 16,
|
||||
height: '100%',
|
||||
},
|
||||
groupingPanel: {
|
||||
background: 'var(--surface)',
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--border)',
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
maxHeight: '100vh',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
panelTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: 'var(--muted)',
|
||||
margin: '0 0 8px 0',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 11,
|
||||
color: 'var(--muted)',
|
||||
textAlign: 'center',
|
||||
padding: '12px 0',
|
||||
},
|
||||
groupList: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
},
|
||||
groupListItem: {
|
||||
background: '#38bdf811',
|
||||
border: '1px solid var(--accent)',
|
||||
borderRadius: 4,
|
||||
padding: '8px 10px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
groupName: {
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: 'var(--accent)',
|
||||
flex: 1,
|
||||
},
|
||||
ungroupBtn: {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--accent)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
padding: '0 4px',
|
||||
minWidth: 24,
|
||||
minHeight: 24,
|
||||
},
|
||||
grid: {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
||||
gap: 12,
|
||||
overflowY: 'auto',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,17 @@ export default function ChartPlaceholder() {
|
||||
const chartUrl = `http://${signalkHost}:3000/@signalk/freeboard-sk/`
|
||||
const isMock = import.meta.env.VITE_USE_MOCK === 'true'
|
||||
|
||||
// Colors from CSS (hardcoded for canvas compatibility)
|
||||
const colors = {
|
||||
bg: '#07111f',
|
||||
surface: '#0a1928',
|
||||
border: '#1e2a3a',
|
||||
text: '#e2eaf2',
|
||||
muted: '#4a6080',
|
||||
accent: '#38bdf8',
|
||||
warning: '#f59e0b',
|
||||
}
|
||||
|
||||
// Draw track and waypoints
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
@@ -68,9 +79,9 @@ export default function ChartPlaceholder() {
|
||||
})
|
||||
|
||||
// Clear and draw background
|
||||
ctx.fillStyle = 'var(--surface)'
|
||||
ctx.fillStyle = colors.surface
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
ctx.strokeStyle = 'var(--border)'
|
||||
ctx.strokeStyle = colors.border
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(0, 0, width, height)
|
||||
|
||||
@@ -79,14 +90,14 @@ export default function ChartPlaceholder() {
|
||||
const p = project(wp.lat, wp.lon)
|
||||
|
||||
// Waypoint circle
|
||||
ctx.fillStyle = idx === snapshot?.currentWaypoint ? 'var(--accent)' : 'var(--warning)'
|
||||
ctx.fillStyle = idx === snapshot?.currentWaypoint ? colors.accent : colors.warning
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, 6, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
// Waypoint number
|
||||
ctx.fillStyle = 'var(--bg)'
|
||||
ctx.font = 'bold 10px var(--font-mono)'
|
||||
ctx.fillStyle = colors.bg
|
||||
ctx.font = 'bold 10px monospace'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(idx + 1, p.x, p.y)
|
||||
@@ -94,7 +105,7 @@ export default function ChartPlaceholder() {
|
||||
|
||||
// Draw track line
|
||||
if (trackRef.current.length > 1) {
|
||||
ctx.strokeStyle = 'var(--accent)'
|
||||
ctx.strokeStyle = colors.accent
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
trackRef.current.forEach((p, idx) => {
|
||||
@@ -116,7 +127,7 @@ export default function ChartPlaceholder() {
|
||||
ctx.save()
|
||||
ctx.translate(p.x, p.y)
|
||||
ctx.rotate(headRad)
|
||||
ctx.fillStyle = 'var(--accent)'
|
||||
ctx.fillStyle = colors.accent
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, -size)
|
||||
ctx.lineTo(-size / 2, size / 2)
|
||||
@@ -126,7 +137,7 @@ export default function ChartPlaceholder() {
|
||||
ctx.restore()
|
||||
|
||||
// Ship circle
|
||||
ctx.strokeStyle = 'var(--accent)'
|
||||
ctx.strokeStyle = colors.accent
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, 8, 0, Math.PI * 2)
|
||||
|
||||
Reference in New Issue
Block a user