Files
Bose-SoundTouch/pkg/service/soundtouchweb/static/js/components/Controls.js
T
Tobias GesellchenandClaude Opus 4.7 9c5ba43fb3 feat(soundtouch-web): swap vanilla Bootstrap UI for Preact+htm SPA
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>
2026-05-18 22:34:26 +02:00

80 lines
3.2 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 Controls({ deviceId, status }) {
const np = status?.nowPlaying;
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
const actualVolume = status?.volume?.ActualVolume ?? 0;
const isMuted = status?.volume?.MuteEnabled ?? false;
const shuffle = np?.ShuffleSetting ?? 'SHUFFLE_OFF';
const repeat = np?.RepeatSetting ?? 'REPEAT_OFF';
const actualBass = status?.bass?.TargetBass ?? 0;
const hasBass = status?.bass != null;
const [localVolume, setLocalVolume] = useState(actualVolume);
const [localBass, setLocalBass] = useState(actualBass);
useEffect(() => { setLocalVolume(actualVolume); }, [actualVolume]);
useEffect(() => { setLocalBass(actualBass); }, [actualBass]);
const send = (key) => api.key(deviceId, key);
function onVolumeChange(e) {
const val = parseInt(e.target.value, 10);
setLocalVolume(val);
api.volume(deviceId, val);
}
function onBassChange(e) {
const val = parseInt(e.target.value, 10);
setLocalBass(val);
api.bass(deviceId, val);
}
function toggleShuffle() {
send(shuffle === 'SHUFFLE_ON' ? 'SHUFFLE_OFF' : 'SHUFFLE_ON');
}
function cycleRepeat() {
if (repeat === 'REPEAT_OFF') send('REPEAT_ALL');
else if (repeat === 'REPEAT_ALL') send('REPEAT_ONE');
else send('REPEAT_OFF');
}
const repeatIcon = repeat === 'REPEAT_ONE' ? '🔂' : '🔁';
return html`
<div class="controls">
<div class="transport">
<button class="ctrl-btn" onClick=${() => send('PREV_TRACK')} title="Previous">⏮</button>
<button class="ctrl-btn play-btn" onClick=${() => send(isPlaying ? 'PAUSE' : 'PLAY')}>
${isPlaying ? '⏸' : '▶'}
</button>
<button class="ctrl-btn" onClick=${() => send('NEXT_TRACK')} title="Next">⏭</button>
<button class="ctrl-btn ${isMuted ? 'active' : ''}" onClick=${() => send('MUTE')} title="Mute">
${isMuted ? '🔇' : '🔊'}
</button>
<button class="ctrl-btn ${shuffle === 'SHUFFLE_ON' ? 'active' : ''}" onClick=${toggleShuffle} title="Shuffle">🔀</button>
<button class="ctrl-btn ${repeat !== 'REPEAT_OFF' ? 'active' : ''}" onClick=${cycleRepeat} title="Repeat">${repeatIcon}</button>
</div>
<div class="volume-row">
<span class="volume-icon">🔈</span>
<input type="range" class="volume-slider" min="0" max="100"
value=${localVolume} onInput=${onVolumeChange} />
<span class="volume-value">${localVolume}</span>
</div>
${hasBass && html`
<div class="bass-row">
<span class="bass-label">Bass</span>
<input type="range" class="volume-slider" min="-9" max="9"
value=${localBass} onInput=${onBassChange} />
<span class="volume-value">${localBass > 0 ? '+' : ''}${localBass}</span>
</div>
`}
</div>
`;
}