mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
6eb3829888
commit
a4b4a51cdb
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user