mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Brings forward the frontend rewrite from the `app` branch
(6723515 + later refinements) onto the relocated package layout.
The Go side untouched — main.go's orchestration, discovery, routes,
and handlers all remain. Only the static-asset layer changes.
Frontend (lives in pkg/service/soundtouchweb/static/):
- index.html (importmap-driven, ES modules, no build step)
- css/app.css (CSS-custom-property design system, dark by default)
- js/api.js (typed-ish fetch wrappers)
- js/app.js (Preact App shell: routing, toast, websocket reconnect)
- js/components/{DeviceList,NowPlaying,Controls,Presets,Sources,
Recents,Zone,TuneInBrowser}.js
- img/favicon.{ico,svg}
- lib/{preact,preact-hooks,htm}.module.js (vendored ES modules)
Backend wiring:
- New pkg/service/soundtouchweb/embed.go exports `StaticFS embed.FS`
via `//go:embed static`. main.go drops its own `//go:embed` and
consumes `soundtouchweb.StaticFS` instead, so the static tree
lives alongside the handlers it serves.
- cmd/soundtouch-web/static/{index.html,css/app.css,js/app.js} are
deleted; the old `cmd/soundtouch-web/static/` directory is empty
now and removed entirely.
Path rename vs. app branch:
- app's importmap pointed at `/static/vendor/preact*.js` and the
vendor files were never committed because `.gitignore:44 vendor/`
silently masked them. Renamed to `/static/lib/` to escape the
global rule and `git add`-ed the three modules.
Known regressions vs. main's vanilla UI (acceptable for this commit;
flag in review or follow-up if any matter):
- Per-card power toggle on the device list — Preact only exposes
power inside the device-detail view, not on the list card.
- WebSocket reconnect uses `location.reload()` after 5s; main had
exponential backoff. Functional, simpler, less elegant.
- Theme icon control absent (Preact UI is dark-only via CSS vars;
no light-mode toggle).
Features carried over and confirmed at the route-shape level:
device list / device detail / nowPlaying / volume+key+power controls
/ presets / sources / TuneIn search + browse + play / discovery /
toasts / WebSocket status updates.
go build ./... clean. go test ./... clean (only pre-existing
TestDocsConsistency fails). golangci-lint run ./... 0 issues.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
4.4 KiB
JavaScript
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>
|
|
`;
|
|
} |