12 Commits
Author SHA1 Message Date
Tobias GesellchenandClaude Opus 4.8 ab7c857630 feat(player): Library tab UI for DLNA browsing and playback
Adds the soundtouch-player "Library" tab (Preact + htm), implementing the
discover -> add server -> browse -> play flow over the device-scoped
library API. Device is picked up front (browsing is speaker-native), then:
find LAN servers (SSDP) and add one to the speaker, open a registered
server, navigate folders via a breadcrumb, and play a track via native
STORED_MUSIC. Mirrors the TuneIn/RadioBrowser components and reuses their
CSS classes; marked BETA. api.js gains libraryDiscover/Servers/AddServer/
RemoveServer/Browse/Play; app.js gets the nav entry, title, and route.

Validated end to end through the running player against real hardware
(FRITZ!Box media server -> ST10): now_playing source=STORED_MUSIC
status=PLAY_STATE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:50:49 +02:00
Tobias GesellchenandClaude Opus 4.8 b861c11d37 feat(web): remove devices from the player UI (refs #451)
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>
2026-06-07 15:08:26 +02:00
Tobias GesellchenandClaude Opus 4.8 d9581dc10f refactor(web): group content sources under a /providers infix (refs #451)
Model tunein, radiobrowser, playurl and tts as content "providers" and
give them a uniform /providers namespace, so the surface is consistent
and extensible (Spotify/Amazon slot in later as new providers).

Two kinds of provider operation fall out naturally:

  - Browsable providers (a catalog you search/navigate) expose global
    browse routes:
      GET /api/control/providers/tunein/{search,search/next,navigate,navigate/*}
      GET /api/control/providers/radiobrowser/search
  - Every provider plays on a device via a uniform `play` verb:
      POST /api/control/devices/{id}/providers/tunein/play
      POST /api/control/devices/{id}/providers/radiobrowser/play
      POST /api/control/devices/{id}/providers/url/play      (was play-url)
      POST /api/control/devices/{id}/providers/tts/play       (was speak)

Input providers (url, tts) have no catalog, so they appear only as a
device play. The generic POST /devices/{id}/play (raw ContentItem) stays
the low-level primitive, not a provider. /providers stays a literal
namespace with literal provider children (no {provider} param), so there
is still zero static-vs-param ambiguity.

The bundled api.js is updated in lockstep. mount_test.go now asserts the
provider routes exist and the pre-infix flat paths are gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:05:59 +02:00
Tobias GesellchenandClaude Opus 4.8 a16b4babcb refactor(web): nest control API under /api/control/* (refs #451)
Restructure soundtouch-web's control API to the post-merge canonical
shape so folding -web into -service later is a near-additive mount.
Device-scoped actions now nest under /api/control/devices/{id}/...,
making every direct child of /api/control a literal namespace (devices,
tunein, radiobrowser, version, discover) with no static-vs-param sibling
ambiguity. Browse/search endpoints (tunein, radiobrowser) stay global.

This is a direct migration (no dual-mount, no deprecation middleware):
-web's only client is its own bundled frontend, so a reload picks up the
new paths. The bundled api.js/app.js are updated in lockstep.

Add mount_test.go: the first test that exercises Mount() itself. It
walks the registered routes to assert (a) registration never panics and
(b) the invariant that every web /api/* route lives under /api/control/*
so no flat route is left behind. Handler unit tests call handlers
directly with injected params, so their request-path literals were
cosmetic; updated to the new nested shape for accurate documentation.

SPA routes and the main /ws socket are unchanged here; they move in
follow-up steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 23:05:59 +02:00
Tobias GesellchenandClaude Opus 4.8 258cc6198f fix(web): stop TTS proxy from using a browser-supplied service URL (SSRF)
CodeQL flagged "uncontrolled data used in network request": the
soundtouch-web TTS proxy built its outbound request URL from the
client-supplied serviceUrl, letting any LAN caller use the endpoint as an
SSRF proxy. The proxy target must be the operator-configured --service-url.

- handler: use only app.ServiceURL; drop the client-supplied serviceUrl
  field and fallback.
- web TTS view: show the configured service URL read-only with an
  explanation of why it can't be edited here (Play URL differs — its URL
  is handed to the speaker, not fetched by soundtouch-web, so no SSRF).
- api.speak no longer sends serviceUrl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 8f2939a9a6 feat(tts): configure Google Cloud TTS from the settings UI; group integrations into collapsible panels
The Google Cloud TTS API key (and app_key / provider / language / voice /
volume) can now be set in the service settings page, persisted to
settings.json, and applied at runtime — same model as Spotify/Amazon
(CLI/env wins at startup, else persisted; secrets masked as "***" over
the wire; a save triggers ReinitTTSService without a restart).

To keep the settings page from bloating as integrations grow, Spotify,
Amazon, and Google Cloud TTS are now collapsible <details> panels under
an "Integrations" heading, each showing an Active/Saved/Inactive badge in
its summary that stays visible when collapsed. Adding a future provider
(e.g. Apple Music) is now just another panel.

Provider construction moved from cmd initTTSService into
handlers.Server.ReinitTTSService so the UI can re-apply changes; the
tts-provider flag default is now empty (empty => translate) so a value
saved in the UI can take effect.

Also: the soundtouch-web TTS source view now shows the AfterTouch service
URL with an override (shared with Play URL via localStorage), and
/api/device-speak accepts a serviceUrl override, mirroring Play URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Opus 4.8 c852d07da1 feat(tts): add Google Cloud Text-to-Speech via a pluggable provider
Adds text-to-speech that synthesizes higher-quality audio (Google Cloud
TTS) and plays it on a speaker via the /speaker endpoint. Because Cloud
TTS returns audio bytes (not a fetchable URL), the service caches the
clip and hosts it at GET /media/tts/{id}, mirroring the "ding" endpoint,
then points the speaker at that local URL.

The design is a pluggable Provider interface (pkg/service/tts) wrapping
two modes:
- translate: hands the speaker the (undocumented) Google Translate URL
  directly (no credentials), reusing models.BuildTranslateTTSURL.
- google-cloud: REST API key auth (no SDK/gRPC), bytes cached locally.

Surfaces:
- service: POST /mgmt/tts/speak, GET /mgmt/tts/config, GET /media/tts/{id};
  configured via TTS_PROVIDER / TTS_GOOGLE_API_KEY / TTS_LANGUAGE /
  TTS_VOICE / TTS_APP_KEY / TTS_VOLUME.
- CLI: `soundtouch-cli tts speak` (calls the service with mgmt Basic Auth).
- web: a "TTS" source view (like Play URL / TuneIn), proxied to the
  service via /api/device-speak/{id}.

The /speaker app_key requirement and model limitations still apply; see
docs/content/docs/reference/SPEAKER-ENDPOINT.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:31 +02:00
Tobias GesellchenandClaude Sonnet 4.6 a4b4a51cdb feat(web): add Play URL view for custom stream playback
Adds a top-level "Play URL" view (nav icon: link) so users can paste an
arbitrary stream URL and play it on any discovered device — same
browse-globally-pick-device pattern as TuneIn and RadioBrowser.

- pkg/service/bmx: extract BuildOrionLocation (encode side), shared by
  CLI and web handler; check json.Marshal error (errchkjson)
- cmd/soundtouch-cli: use bmxpkg.BuildOrionLocation instead of local
  copy; merge dual LOCAL_INTERNET_RADIO branches to reduce cyclomatic
  complexity (gocyclo)
- cmd/soundtouch-web: add --service-url / SERVICE_URL flag; expose it
  in WebApp.ServiceURL
- soundtouchweb handler: HandlePlayURL wraps raw stream in Orion
  location when ServiceURL is set (client-supplied fallback when not);
  exposes service_url in /api/version for frontend pre-fill
- soundtouchweb mount: POST /api/play-url/{id}, GET /playurl SPA route
- frontend: PlayURL.js component with device-picker overlay; AfterTouch
  URL persisted to localStorage, pre-filled from server when no override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 00:28:04 +02:00
Tobias GesellchenandClaude Sonnet 4.6 e117b472b1 feat(soundtouch-web): add save-as-preset from Now Playing and preset tiles
Two complementary ways to save what's currently playing to a preset slot
without leaving the web UI:

★ Star button (Now Playing card)
  A semi-transparent star appears in the top-right corner of the Now
  Playing card whenever a device is selected and something is playing.
  Clicking it opens a slot picker (1–6); selecting a slot calls
  POST /api/control/{id}/storepreset?id={slot}.  The star turns gold
  when the current ContentItem is already mapped to at least one preset,
  matching the preset list by Source + Location.  An outside-click
  closes the picker without saving.

+ button (preset tiles)
  While content is playing each of the six preset tiles shows a small +
  button on hover.  Clicking it saves directly to that slot — no picker
  needed.  The button cycles through +  →  ✓  →  (reset) states with
  a 1.5 s success flash and shows ✗ briefly on error.

Backend (handler.go):
  New "storepreset" case in handleControlAction dispatches to
  handleStorePreset, which validates the ?id= query param (1-6) and
  calls device.Client.StoreCurrentAsPreset(presetID).

Frontend (api.js):
  storePreset(deviceId, slotId) helper added.

CSS (app.css):
  .preset-slot-wrap wrapper + .preset-save-btn styles for the + button,
  source-specific --slot-color custom properties for border accents,
  .now-playing-fav-wrap / .now-playing-fav-btn / .now-playing-fav-overlay
  for the star button and its popover (right-aligned, z-index: 50).
  position: relative added to .now-playing so the star can be absolutely
  positioned without being clipped by .track-info overflow: hidden.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 22:47:13 +02:00
Tobias GesellchenandClaude Sonnet 4.6 11a6515f4d feat(tunein): add section-grouped results and load-more pagination
TuneIn's profiles API caps initial results at ~10 per container (Stations,
Shows, etc.) and exposes a Pivots.More.Url cursor for the remainder. This
change wires that cursor through the stack so users can load additional
results without leaving the search view.

- tuneInSearchSection now extracts Pivots.More.Url as bmx_next when
  itemToken is present; absent for containers already at their limit
- TuneInSearchNext fetches the cursor URL, which returns a flat Items[]
  (not nested containers), and maps Station/Program/Topic items using
  the existing play/profile builders
- New GET /v1/search/next and /api/tunein/search/next endpoints with
  matching handlers in both service paths
- TuneInBrowser: flat items state replaced with per-section sections
  state; each section shows a header label and a Load more button when
  a cursor is available; browse/navigate mode is unaffected

Relates to #336.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 14:25:01 +02:00
Tobias Gesellchen b95bdae751 feat: Add RadioBrowser integration alongside TuneIn support
- 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
2026-05-18 22:34:26 +02:00
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