Relates to #571.
## What
Adds a **Name / IP** sort toggle to the Player device list. The choice
is persisted in `localStorage` (`aftertouch_device_sort`), following the
same preference pattern as the service-URL field in `PlayURL.js`.
## Why
The device list was previously ordered only by IP: the service datastore
keys devices by IP address and Go marshals map keys lexicographically,
so the frontend received an already-IP-ordered object and rendered it
as-is. BirdyBA (#571) asked to be able to sort by name instead.
## Changes
- `DeviceList.js`: a `sortEntries()` helper plus a `useState`-backed
toggle seeded from `localStorage`. Name mode sorts by `device.info.name`
(falling back to the IP key when a device has no name yet); IP mode
sorts the IP key **numerically** (`.2` before `.10`), which also tidies
the old lexicographic ordering.
- `css/app.css`: additive `.device-sort` / `.sort-btn` styling, reusing
the existing accent / `.active` look. No existing rules touched.
No backend change: the device name and IP are already in the payload.
## Testing
- `node --check` on `DeviceList.js` passes.
- `make build-player` succeeds (the static tree is `//go:embed`ed into
the binary).
- Manual: open the Player, toggle Name / IP, confirm the order changes
and the choice survives a page reload.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The merge of soundtouch-web into soundtouch-service was asymmetric:
manual device *adds* propagated to the player UI (HandleAddManualDevice
notifies, the hook re-seeds + broadcasts), but *removals* did not. The
datastore-removal handler never notified, and the web registry's sync
only ever added entries — its map was append-only, so a removed device
lingered in the player UI until restart.
This adds the missing removal path:
- DELETE /api/control/devices/{id} (HandleDeleteDevice). The registry is
keyed by host/IP; the datastore by device ID (MAC), so the handler
resolves one to the other via the connection's DeviceInfo, cascades to
the datastore through a new RemoveDeviceHook (embedded build only),
prunes the in-memory entry, and broadcasts the updated list.
- WebApp.RemoveDevice prunes the registry and stops the per-device
goroutines (status poller + WebSocket reconnect loop) via a new
done-channel + Close() on DeviceConnection — previously both ran for
the life of the process.
- Server.RemoveDeviceByID extracts the cross-account lookup + remove from
HandleRemoveDevice and now fires notifyDevicesChanged, so the admin
Devices tab removal also propagates to the player UI.
- Player UI: a quiet per-card Remove control (visible on hover), a
confirm dialog, optimistic prune, and a note that a still-online
device may reappear after the next discovery scan (honest v1 — no
ignore-list).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
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>