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>
This commit is contained in:
Tobias Gesellchen
2026-05-29 00:28:04 +02:00
co-authored by Claude Sonnet 4.6
parent 6eb3829888
commit a4b4a51cdb
10 changed files with 267 additions and 47 deletions
+14 -40
View File
@@ -1,12 +1,11 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
@@ -105,28 +104,6 @@ func extractPresetParams(c *cli.Context) *presetParams {
}
}
// buildOrionLocation wraps a raw stream URL in the AfterTouch Orion station
// endpoint that the speaker's BMX module expects when playing LOCAL_INTERNET_RADIO
// content. The speaker calls GET on the stored preset location and expects a
// BmxPlaybackResponse JSON — not raw audio bytes — which is why direct stream
// URLs silently fail to start playback.
func buildOrionLocation(serviceURL, name, imageURL, streamURL string) string {
payload := struct {
Name string `json:"name"`
ImageURL string `json:"imageUrl"`
StreamURL string `json:"streamUrl"`
}{
Name: name,
ImageURL: imageURL,
StreamURL: streamURL,
}
data, _ := json.Marshal(payload)
encoded := url.QueryEscape(base64.StdEncoding.EncodeToString(data))
return serviceURL + "/core02/svc-bmx-adapter-orion/prod/orion/station?data=" + encoded
}
// isOrionLocation reports whether location is already an Orion station URL so
// we don't double-wrap it.
func isOrionLocation(location string) bool {
@@ -141,25 +118,22 @@ func resolveLocationAndMetadata(params *presetParams) error {
params.source = resolvedSource
params.location = resolvedLocation
// For LOCAL_INTERNET_RADIO with a raw stream URL, the speaker's BMX module
// calls GET on the preset location expecting a BmxPlaybackResponse JSON (the
// Orion station format). A direct stream URL returns raw audio, which BMX
// cannot parse, so playback silently stays on the previous source.
// Wrap the stream URL in the Orion endpoint when --service-url is provided.
// For LOCAL_INTERNET_RADIO, the speaker's BMX module calls GET on the stored
// location expecting a BmxPlaybackResponse JSON (the Orion station format).
// A direct stream URL returns raw audio, which BMX cannot parse, so playback
// silently stays on the previous source.
if params.source == "LOCAL_INTERNET_RADIO" &&
params.serviceURL != "" &&
!isOrionLocation(params.location) &&
(strings.HasPrefix(params.location, "http://") || strings.HasPrefix(params.location, "https://")) {
params.location = buildOrionLocation(params.serviceURL, params.name, params.artwork, resolvedLocation)
if params.serviceURL != "" {
params.location = bmxpkg.BuildOrionLocation(params.serviceURL, params.name, params.artwork, resolvedLocation)
fmt.Printf(" Wrapped stream URL in Orion location for LOCAL_INTERNET_RADIO\n")
} else if params.source == "LOCAL_INTERNET_RADIO" &&
params.serviceURL == "" &&
!isOrionLocation(params.location) &&
(strings.HasPrefix(params.location, "http://") || strings.HasPrefix(params.location, "https://")) {
fmt.Printf(" ⚠️ --service-url not set: storing raw stream URL as location.\n")
fmt.Printf(" The speaker's BMX module expects an Orion station URL, not raw audio.\n")
fmt.Printf(" Re-run with --service-url <https://your-aftertouch-host> to fix this.\n")
fmt.Printf(" Wrapped stream URL in Orion location for LOCAL_INTERNET_RADIO\n")
} else {
fmt.Printf(" ⚠️ --service-url not set: storing raw stream URL as location.\n")
fmt.Printf(" The speaker's BMX module expects an Orion station URL, not raw audio.\n")
fmt.Printf(" Re-run with --service-url <https://your-aftertouch-host> to fix this.\n")
}
}
// If metadata (name or artwork) is missing, try to fetch it
+7
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"runtime/debug"
"strings"
"time"
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
@@ -75,6 +76,11 @@ func main() {
Usage: "SoundTouch device IP address(es) to add manually (can be specified multiple times)",
EnvVars: []string{"SOUNDTOUCH_DEVICES"},
},
&cli.StringFlag{
Name: "service-url",
Usage: "AfterTouch service base URL (e.g. https://soundtouch.local). Required for custom stream URLs to work as presets via LOCAL_INTERNET_RADIO",
EnvVars: []string{"SERVICE_URL"},
},
},
Action: func(c *cli.Context) error {
port := c.String("port")
@@ -108,6 +114,7 @@ func main() {
webApp.Commit = commit
webApp.Date = date
webApp.RepoURL = repoURL
webApp.ServiceURL = strings.TrimRight(c.String("service-url"), "/")
discoveryService := soundtouchweb.NewDiscoveryService(ifaceName)
+27
View File
@@ -4,11 +4,38 @@
package bmx
import (
"encoding/base64"
"encoding/json"
"net/url"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
// BuildOrionLocation wraps a raw stream URL in the AfterTouch Orion station
// endpoint that the speaker's BMX module expects when playing LOCAL_INTERNET_RADIO
// content. The speaker calls GET on the stored location expecting a
// BmxPlaybackResponse JSON — not raw audio bytes.
func BuildOrionLocation(serviceURL, name, imageURL, streamURL string) string {
payload := struct {
Name string `json:"name"`
ImageURL string `json:"imageUrl"`
StreamURL string `json:"streamUrl"`
}{
Name: name,
ImageURL: imageURL,
StreamURL: streamURL,
}
data, err := json.Marshal(payload)
if err != nil {
return ""
}
encoded := url.QueryEscape(base64.StdEncoding.EncodeToString(data))
return serviceURL + "/core02/svc-bmx-adapter-orion/prod/orion/station?data=" + encoded
}
// BuildCustomStreamResponse builds a playback response from streamUrl, imageUrl, and name.
func BuildCustomStreamResponse(streamURL, imageURL, name string) (*models.BmxPlaybackResponse, error) {
streamList := []models.Stream{
+81 -4
View File
@@ -35,10 +35,11 @@ type WebApp struct {
WSClients map[*websocket.Conn]bool
WSMutex sync.RWMutex
Version string
Commit string
Date string
RepoURL string
Version string
Commit string
Date string
RepoURL string
ServiceURL string
discoveryStatus atomic.Value // stores *webtypes.DiscoveryStatus
}
@@ -1084,6 +1085,81 @@ func (app *WebApp) HandleDevicePlay(w http.ResponseWriter, r *http.Request) {
}
}
// HandlePlayURL plays a custom stream URL on a device. When ServiceURL is
// configured the stream is wrapped in the Orion location format so the
// speaker's BMX module receives JSON instead of raw audio bytes. This also
// ensures that the ★ preset save flow stores a working location.
func (app *WebApp) HandlePlayURL(w http.ResponseWriter, r *http.Request) {
deviceID := chi.URLParam(r, "id")
device, exists := app.GetDevice(deviceID)
if !exists {
app.sendError(w, "Device not found", http.StatusNotFound)
return
}
if device.Client == nil {
app.sendError(w, "Device client not available", http.StatusInternalServerError)
return
}
var req struct {
URL string `json:"url"`
Name string `json:"name"`
ImageURL string `json:"imageUrl"`
ServiceURL string `json:"serviceUrl"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
app.sendError(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.URL == "" {
app.sendError(w, "url is required", http.StatusBadRequest)
return
}
// Server-side --service-url wins; fall back to client-supplied value.
serviceURL := app.ServiceURL
if serviceURL == "" {
serviceURL = strings.TrimRight(req.ServiceURL, "/")
}
if serviceURL == "" {
app.sendError(w,
"AfterTouch service URL is required for LOCAL_INTERNET_RADIO playback. "+
"Start soundtouch-web with --service-url <https://your-aftertouch-host> or enter it in the Play URL settings.",
http.StatusBadRequest)
return
}
location := bmxpkg.BuildOrionLocation(serviceURL, req.Name, req.ImageURL, req.URL)
contentItem := &models.ContentItem{
Source: "LOCAL_INTERNET_RADIO",
Type: "stationurl",
Location: location,
ItemName: req.Name,
IsPresetable: true,
}
if err := device.Client.SelectContentItem(contentItem); err != nil {
app.sendError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{
Success: true,
Data: map[string]string{"message": "Playing " + req.Name},
}); encErr != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// HandleAPIVersion returns the current version of the application.
func (app *WebApp) HandleAPIVersion(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -1095,6 +1171,7 @@ func (app *WebApp) HandleAPIVersion(w http.ResponseWriter, _ *http.Request) {
"repo_url": app.RepoURL,
"release_url": app.RepoURL + "/releases/tag/" + app.Version,
"commit_url": app.RepoURL + "/commit/" + app.Commit,
"service_url": app.ServiceURL,
}
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{Success: true, Data: versionInfo}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+4
View File
@@ -76,12 +76,16 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov
r.Get("/api/radiobrowser/search", app.HandleRadioBrowserSearch)
r.Post("/api/radiobrowser/play/{id}", app.HandlePlayRadioBrowser)
// Custom URL playback
r.Post("/api/play-url/{id}", app.HandlePlayURL)
// SPA routes — serve index.html for client-side routing
r.Get("/", app.serveIndex)
r.Get("/devices", app.serveIndex)
r.Get("/device/*", app.serveIndex)
r.Get("/tunein", app.serveIndex)
r.Get("/radiobrowser", app.serveIndex)
r.Get("/playurl", app.serveIndex)
}
func (app *WebApp) serveIndex(w http.ResponseWriter, _ *http.Request) {
+6 -3
View File
@@ -197,7 +197,8 @@ img { display: block; max-width: 100%; }
.nav-links a.active .nav-tunein-icon,
.nav-links a.active .nav-rb-icon,
.nav-links a.active .nav-device-icon {
.nav-links a.active .nav-device-icon,
.nav-links a.active .nav-url-icon {
filter: none;
opacity: 1;
}
@@ -210,12 +211,13 @@ img { display: block; max-width: 100%; }
}
.nav-links a.active .nav-tunein-icon,
.nav-links a.active .nav-rb-icon,
.nav-links a.active .nav-device-icon {
.nav-links a.active .nav-device-icon,
.nav-links a.active .nav-url-icon {
filter: invert(1);
}
}
.nav-tunein-icon, .nav-rb-icon, .nav-device-icon { height: 20px; display: block; filter: var(--nav-icon-filter); opacity: .75; transition: filter .15s; }
.nav-tunein-icon, .nav-rb-icon, .nav-device-icon, .nav-url-icon { height: 20px; display: block; filter: var(--nav-icon-filter); opacity: .75; transition: filter .15s; }
.nav-discover-icon { height: 24px; display: block; filter: var(--nav-icon-filter); opacity: .75; transition: filter .15s; }
.nav-discover-icon.buzzing { animation: buzzing 0.3s linear infinite; opacity: 1; }
@@ -229,6 +231,7 @@ img { display: block; max-width: 100%; }
.nav-links a:hover .nav-tunein-icon, .nav-links a.active .nav-tunein-icon,
.nav-links a:hover .nav-rb-icon, .nav-links a.active .nav-rb-icon,
.nav-links a:hover .nav-device-icon, .nav-links a.active .nav-device-icon,
.nav-links a:hover .nav-url-icon, .nav-links a.active .nav-url-icon,
.nav-links .btn-icon:hover .nav-discover-icon { opacity: 1; }
/* ── Main content ─────────────────────────────────────────────────────────── */
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
</svg>

After

Width:  |  Height:  |  Size: 333 B

@@ -45,4 +45,9 @@ export const api = {
headers: JSON_HEADERS,
body: JSON.stringify(item),
}),
playURL: (deviceId, url, name, imageUrl, serviceUrl) => req(`/api/play-url/${deviceId}`, {
method: 'POST',
headers: JSON_HEADERS,
body: JSON.stringify({ url, name, imageUrl, serviceUrl }),
}),
};
@@ -10,6 +10,7 @@ import { Zone } from './components/Zone.js';
import { Recents } from './components/Recents.js';
import { TuneInBrowser } from './components/TuneInBrowser.js';
import { RadioBrowser } from './components/RadioBrowser.js';
import { PlayURL } from './components/PlayURL.js';
import { api } from './api.js';
const html = htm.bind(h);
@@ -68,6 +69,7 @@ function App() {
}
if (page === 'tunein') return 'TuneIn';
if (page === 'radiobrowser') return 'RadioBrowser';
if (page === 'playurl') return 'Play URL';
return 'AfterTouch';
};
@@ -169,6 +171,12 @@ function App() {
>
<img src="/static/img/radiobrowser-mono.svg" alt="RadioBrowser" class="nav-rb-icon" />
</a>
<a href="#" class="${page === 'playurl' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('playurl'); }}
title="Play URL"
>
<img src="/static/img/link-mono.svg" alt="Play URL" class="nav-url-icon" />
</a>
<span class="nav-separator">|</span>
<button class="btn-icon" onClick=${discover} title="Discover">
<img src="/static/img/knob-mono.svg" alt="Discover" class="nav-discover-icon ${isDiscovering ? 'buzzing' : ''}" />
@@ -196,6 +204,8 @@ function App() {
<${TuneInBrowser} key="tunein-browser" devices=${devices} />
` : page === 'radiobrowser' ? html`
<${RadioBrowser} key="radiobrowser-browser" devices=${devices} />
` : page === 'playurl' ? html`
<${PlayURL} key="play-url" devices=${devices} serverServiceUrl=${version?.service_url || ''} />
` : null}
</main>
@@ -0,0 +1,109 @@
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import htm from 'htm';
import { api } from '../api.js';
const html = htm.bind(h);
const LS_KEY = 'aftertouch_service_url';
export function PlayURL({ devices, serverServiceUrl }) {
const [url, setUrl] = useState('');
const [name, setName] = useState('');
const [serviceUrl, setServiceUrl] = useState(() => localStorage.getItem(LS_KEY) || '');
const [pendingPlay, setPendingPlay] = useState(null);
const [status, setStatus] = useState(null);
useEffect(() => {
if (serverServiceUrl && !localStorage.getItem(LS_KEY)) {
setServiceUrl(serverServiceUrl);
}
}, [serverServiceUrl]);
function onServiceUrlChange(val) {
setServiceUrl(val);
if (val) {
localStorage.setItem(LS_KEY, val);
} else {
localStorage.removeItem(LS_KEY);
}
}
function startPlay() {
const trimmedUrl = url.trim();
if (!trimmedUrl) return;
setStatus(null);
setPendingPlay({ url: trimmedUrl, name: name.trim() || trimmedUrl });
}
async function playOn(deviceId) {
const item = pendingPlay;
setPendingPlay(null);
setStatus('Playing…');
try {
const resp = await api.playURL(deviceId, item.url, item.name, '', serviceUrl.trim());
setStatus(resp.success ? 'Playing — use ★ on the device page to save as preset' : 'Error: ' + (resp.error || 'Unknown error'));
} catch (e) {
setStatus('Error: ' + e.message);
}
}
const deviceEntries = Object.entries(devices);
return html`
<div class="tunein-browser">
<div class="tunein-toolbar">
<input
type="url"
class="tunein-search-input"
placeholder="Stream URL (http://…)"
value=${url}
onInput=${(e) => setUrl(e.target.value)}
onKeyDown=${(e) => e.key === 'Enter' && startPlay()}
/>
<input
type="text"
class="tunein-search-input"
placeholder="Name (optional)"
value=${name}
style="max-width:160px"
onInput=${(e) => setName(e.target.value)}
onKeyDown=${(e) => e.key === 'Enter' && startPlay()}
/>
<button class="btn-primary" onClick=${startPlay} disabled=${!url.trim()}> Play</button>
</div>
<div class="tunein-toolbar" style="margin-top:.4rem">
<input
type="url"
class="tunein-search-input"
placeholder="AfterTouch URL (https://…)"
value=${serviceUrl}
onInput=${(e) => onServiceUrlChange(e.target.value)}
title="AfterTouch service base URL — required for LOCAL_INTERNET_RADIO playback and preset save"
/>
</div>
${status && html`<div class="track-meta" style="margin-top:.6rem">${status}</div>`}
${pendingPlay ? html`
<div class="overlay" onClick=${() => setPendingPlay(null)}>
<div class="device-picker" onClick=${(e) => e.stopPropagation()}>
<h3 class="picker-title">Play on device</h3>
<p class="picker-item-name">${pendingPlay.name}</p>
<div class="picker-devices">
${deviceEntries.length === 0 ? html`<p class="picker-no-devices">No devices found. Try discovering first.</p>` : null}
${deviceEntries.map(([id, d]) => html`
<button class="picker-device-btn" key=${id} onClick=${() => playOn(id)}>
<div class="picker-device-info">
<span class="picker-device-name">${d.info?.name || id}</span>
<span class="picker-device-ip">${d.info?.ip_address || ''}</span>
</div>
</button>
`)}
</div>
<button class="btn-secondary picker-cancel" onClick=${() => setPendingPlay(null)}>Cancel</button>
</div>
</div>
` : null}
</div>
`;
}