Files
Tobias GesellchenandClaude Sonnet 4.6 b040c8a90c feat(soundtouch-web): add multi-room zone management
Backend:
- GET /api/zone/{id} — zone info enriched with device names and role flags
- POST /api/zone/{id}/add/{slaveId} — add slave (creates zone if standalone)
- POST /api/zone/{id}/remove/{slaveId} — remove slave from zone
- POST /api/zone/{id}/dissolve — dissolve zone to standalone
- POST /api/zone/{id}/leave — slave leaves its zone (backend finds master)

Frontend (Zone.js):
- Standalone: shows "Group with…" button, opens device picker overlay
- Master: member list with per-row Remove, Add speaker, Dissolve buttons
- Slave: shows master name, Leave zone button
- Lazy-loads on device detail open; refreshes after each zone operation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 21:22:57 +02:00

118 lines
4.4 KiB
JavaScript

import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
export function Zone({ deviceId, devices }) {
const [zone, setZone] = useState(null);
const [loading, setLoading] = useState(true);
const [showPicker, setShowPicker] = useState(false);
function refresh() {
api.zone(deviceId).then(resp => {
if (resp.success) setZone(resp.data);
}).finally(() => setLoading(false));
}
useEffect(() => { refresh(); }, [deviceId]);
async function addDevice(slaveId) {
setShowPicker(false);
await api.zoneAdd(deviceId, slaveId);
refresh();
}
async function removeDevice(slaveId) {
await api.zoneRemove(deviceId, slaveId);
refresh();
}
async function dissolve() {
await api.zoneDissolve(deviceId);
refresh();
}
async function leave() {
await api.zoneLeave(deviceId);
refresh();
}
if (loading) return html`
<div class="zone-section">
<div class="section-title">Zone</div>
<div class="loading-bar"></div>
</div>
`;
if (!zone) return null;
// Devices not already in the zone are available to add
const zoneIps = new Set([zone.masterIp, ...(zone.members || []).map(m => m.ip)].filter(Boolean));
const available = Object.entries(devices || {}).filter(([ip]) => !zoneIps.has(ip));
const deviceName = (ip) => devices[ip]?.info?.Name ?? ip;
return html`
<div class="zone-section">
<div class="section-title">Zone</div>
${zone.isStandalone && html`
<div class="zone-row">
<span class="zone-status-label">Standalone</span>
${available.length > 0 && html`
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Group with…</button>
`}
</div>
`}
${zone.isMaster && html`
<div class="zone-members">
<div class="zone-member zone-master-row">
<span class="zone-badge master">Master</span>
<span class="zone-member-name">${deviceName(deviceId)}</span>
</div>
${(zone.members || []).map(m => html`
<div class="zone-member" key=${m.ip}>
<span class="zone-badge slave">Member</span>
<span class="zone-member-name">${m.name || m.ip}</span>
<button class="btn-icon zone-remove" title="Remove from zone"
onClick=${() => removeDevice(m.ip)}>✕</button>
</div>
`)}
<div class="zone-actions">
${available.length > 0 && html`
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Add speaker</button>
`}
<button class="btn-secondary zone-btn" onClick=${dissolve}>Dissolve zone</button>
</div>
</div>
`}
${zone.isSlave && html`
<div class="zone-row">
<span class="zone-badge slave">Member</span>
<span class="zone-member-name">Zone: ${zone.masterName || zone.masterIp}</span>
<button class="btn-secondary zone-btn" onClick=${leave}>Leave zone</button>
</div>
`}
${showPicker && html`
<div class="overlay" onClick=${() => setShowPicker(false)}>
<div class="device-picker" onClick=${e => e.stopPropagation()}>
<div class="picker-title">Add to zone</div>
<div class="picker-devices">
${available.map(([ip, d]) => html`
<button class="picker-device-btn" key=${ip} onClick=${() => addDevice(ip)}>
${d.info?.Name ?? ip}
</button>
`)}
</div>
<button class="btn-secondary picker-cancel" onClick=${() => setShowPicker(false)}>Cancel</button>
</div>
</div>
`}
</div>
`;
}