mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b040c8a90c | ||
|
|
3122c4ed3a | ||
|
|
2edcc14342 | ||
|
|
6723515f54 | ||
|
|
396b359c11 | ||
|
|
969bdf8704 | ||
|
|
ac5e67d198 | ||
|
|
ea4d8bacac |
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.2-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.3-alpine AS builder
|
||||
|
||||
# Declare automatic platform ARGs to make them available in build stage
|
||||
# See https://docs.docker.com/reference/dockerfile#automatic-platform-args-in-the-global-scope
|
||||
|
||||
@@ -240,6 +240,12 @@ func main() {
|
||||
Value: true,
|
||||
EnvVars: []string{"RECORD_INTERACTIONS"},
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "discovery-enabled",
|
||||
Usage: "Enable periodic device discovery",
|
||||
Value: true,
|
||||
EnvVars: []string{"DISCOVERY_ENABLED"},
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "discovery-interval",
|
||||
Usage: "Device discovery interval",
|
||||
@@ -395,7 +401,7 @@ func main() {
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date, repoURL)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, config.discoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
|
||||
server.SetInternalPaths(persisted.InternalPaths)
|
||||
@@ -526,6 +532,7 @@ type serviceConfig struct {
|
||||
mirrorEndpoints []string
|
||||
skipMirrorEndpoints []string
|
||||
internalPaths []string
|
||||
discoveryEnabled bool
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
spotifyClientID string
|
||||
@@ -590,6 +597,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
dnsUpstream := c.String("dns-upstream")
|
||||
dnsBind := c.String("dns-bind")
|
||||
|
||||
discoveryEnabled := c.Bool("discovery-enabled")
|
||||
discoveryIntervalStr := c.String("discovery-interval")
|
||||
|
||||
discoveryInterval, err := time.ParseDuration(discoveryIntervalStr)
|
||||
@@ -638,6 +646,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
mirrorEndpoints: mirrorEndpoints,
|
||||
skipMirrorEndpoints: skipMirrorEndpoints,
|
||||
internalPaths: internalPaths,
|
||||
discoveryEnabled: discoveryEnabled,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
spotifyClientID: spotifyClientID,
|
||||
@@ -720,6 +729,7 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
config.httpsServerURL = persisted.HTTPServerURL
|
||||
}
|
||||
|
||||
config.discoveryEnabled = persisted.DiscoveryEnabled
|
||||
if persisted.DiscoveryInterval != "" {
|
||||
if d, durErr := time.ParseDuration(persisted.DiscoveryInterval); durErr == nil {
|
||||
config.discoveryInterval = d
|
||||
@@ -786,8 +796,8 @@ func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datast
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryEnabled: config.discoveryEnabled,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
|
||||
@@ -1,648 +0,0 @@
|
||||
// Package handlers contains HTTP handlers for the SoundTouch web UI.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
bmxpkg "github.com/gesellix/bose-soundtouch/pkg/service/bmx"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// WebApp holds the application state and dependencies
|
||||
type WebApp struct {
|
||||
Devices map[string]*webtypes.DeviceConnection
|
||||
Upgrader websocket.Upgrader
|
||||
WSClients map[*websocket.Conn]bool
|
||||
WSMutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewWebApp creates a new WebApp instance for SPA mode
|
||||
func NewWebApp() *WebApp {
|
||||
return &WebApp{
|
||||
Devices: make(map[string]*webtypes.DeviceConnection),
|
||||
WSClients: make(map[*websocket.Conn]bool),
|
||||
Upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(_ *http.Request) bool { return true },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevices returns all devices as JSON
|
||||
func (app *WebApp) HandleAPIDevices(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Return all devices as JSON
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDevice returns a specific device as JSON
|
||||
func (app *WebApp) HandleAPIDevice(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update device status to get fresh power state
|
||||
app.UpdateDeviceStatus(deviceID, device)
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIControl handles device control commands
|
||||
func (app *WebApp) HandleAPIControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
action := chi.URLParam(r, "action")
|
||||
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
app.handleControlAction(w, r, action, device)
|
||||
}
|
||||
|
||||
// handleControlAction processes different control actions
|
||||
func (app *WebApp) handleControlAction(w http.ResponseWriter, r *http.Request, action string, device *webtypes.DeviceConnection) {
|
||||
switch action {
|
||||
case "play":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Play()
|
||||
app.sendControlResponse(w, err, "Started playback")
|
||||
case "pause":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Pause()
|
||||
app.sendControlResponse(w, err, "Paused playback")
|
||||
case "stop":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.Stop()
|
||||
app.sendControlResponse(w, err, "Stopped playback")
|
||||
case "next":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.NextTrack()
|
||||
app.sendControlResponse(w, err, "Next track")
|
||||
case "previous":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.PrevTrack()
|
||||
app.sendControlResponse(w, err, "Previous track")
|
||||
case "volume":
|
||||
app.handleVolumeControl(w, r, device)
|
||||
case "mute":
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SendKey(models.KeyMute)
|
||||
app.sendControlResponse(w, err, "Toggled mute")
|
||||
case "preset":
|
||||
app.handlePresetControl(w, r, device)
|
||||
case "bass":
|
||||
app.handleBassControl(w, r, device)
|
||||
case "source":
|
||||
app.handleSourceControl(w, r, device)
|
||||
default:
|
||||
app.sendError(w, "Unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// handleVolumeControl processes volume control requests
|
||||
func (app *WebApp) handleVolumeControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for volume control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var volumeReq webtypes.VolumeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&volumeReq); err != nil {
|
||||
app.sendError(w, "Invalid volume data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if volumeReq.Level < 0 || volumeReq.Level > 100 {
|
||||
app.sendError(w, "Volume must be between 0 and 100", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetVolume(volumeReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeReq.Level))
|
||||
}
|
||||
|
||||
// handlePresetControl processes preset control requests
|
||||
func (app *WebApp) handlePresetControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
presetParam := r.URL.Query().Get("id")
|
||||
if presetParam == "" {
|
||||
app.sendError(w, "Preset ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
presetID, err := strconv.Atoi(presetParam)
|
||||
if err != nil {
|
||||
app.sendError(w, "Invalid preset ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err = device.Client.SelectPreset(presetID)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected preset %d", presetID))
|
||||
}
|
||||
|
||||
// handleBassControl processes bass control requests
|
||||
func (app *WebApp) handleBassControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "POST required for bass control", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var bassReq webtypes.BassRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&bassReq); err != nil {
|
||||
app.sendError(w, "Invalid bass data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if bassReq.Level < -9 || bassReq.Level > 9 {
|
||||
app.sendError(w, "Bass must be between -9 and 9", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SetBass(bassReq.Level)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Bass set to %d", bassReq.Level))
|
||||
}
|
||||
|
||||
// handleSourceControl processes source control requests
|
||||
func (app *WebApp) handleSourceControl(w http.ResponseWriter, r *http.Request, device *webtypes.DeviceConnection) {
|
||||
sourceParam := r.URL.Query().Get("name")
|
||||
if sourceParam == "" {
|
||||
app.sendError(w, "Source name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
err := device.Client.SelectSource(sourceParam, "")
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Selected source %s", sourceParam))
|
||||
}
|
||||
|
||||
// sendControlResponse sends a control command response
|
||||
func (app *WebApp) sendControlResponse(w http.ResponseWriter, err error, successMessage string) {
|
||||
if err != nil {
|
||||
app.sendError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": successMessage},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// sendError sends an error response
|
||||
func (app *WebApp) sendError(w http.ResponseWriter, message string, statusCode int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode error response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDeviceKey handles sending key commands to devices
|
||||
func (app *WebApp) HandleDeviceKey(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
key := chi.URLParam(r, "key")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err := device.Client.SendKey(key)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Sent key command: %s", key))
|
||||
}
|
||||
|
||||
// HandleDirectVolumeControl handles direct volume setting via URL parameter
|
||||
func (app *WebApp) HandleDirectVolumeControl(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
volumeLevel, err := strconv.Atoi(chi.URLParam(r, "volume"))
|
||||
if err != nil || volumeLevel < 0 || volumeLevel > 100 {
|
||||
app.sendError(w, "Invalid volume level (0-100)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
err = device.Client.SetVolume(volumeLevel)
|
||||
app.sendControlResponse(w, err, fmt.Sprintf("Volume set to %d", volumeLevel))
|
||||
}
|
||||
|
||||
// HandleDevicePower handles power toggle commands for devices
|
||||
func (app *WebApp) HandleDevicePower(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, "Device not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect WebSocket for real-time updates if not already connected
|
||||
if device.WebSocket == nil {
|
||||
go app.ConnectDeviceWebSocket(deviceID, device)
|
||||
}
|
||||
|
||||
if device.Client == nil {
|
||||
app.sendError(w, "Device client not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Send POWER key command to toggle device power
|
||||
err := device.Client.SendKey("POWER")
|
||||
app.sendControlResponse(w, err, "Power toggle command sent")
|
||||
}
|
||||
|
||||
// HandleDevicePowerStatus handles lightweight power status check
|
||||
func (app *WebApp) HandleDevicePowerStatus(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
|
||||
device, exists := app.Devices[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
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
// Quick power status check by getting now playing
|
||||
nowPlaying, err := device.Client.GetNowPlaying()
|
||||
if err != nil {
|
||||
app.sendControlResponse(w, err, "Failed to get power status")
|
||||
return
|
||||
}
|
||||
|
||||
isPoweredOn := nowPlaying != nil && nowPlaying.Source != "STANDBY"
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]interface{}{
|
||||
"deviceId": deviceID,
|
||||
"isPoweredOn": isPoweredOn,
|
||||
"source": nowPlaying.Source,
|
||||
},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceList sends updated device list to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDeviceList() {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send device update to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDiscoveryStatus sends discovery progress updates to all connected WebSocket clients
|
||||
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
message := webtypes.WebSocketMessage{
|
||||
Type: "discovery_status",
|
||||
Data: map[string]interface{}{
|
||||
"status": status,
|
||||
"deviceCount": deviceCount,
|
||||
},
|
||||
}
|
||||
|
||||
// Send to all connected clients
|
||||
var failedClients []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(message); err != nil {
|
||||
log.Printf("Failed to send discovery status to WebSocket client: %v", err)
|
||||
// Mark for removal to avoid modifying map during iteration
|
||||
failedClients = append(failedClients, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove failed clients
|
||||
for _, client := range failedClients {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInSearch handles TuneIn search requests, proxying directly to the bmx package.
|
||||
func (app *WebApp) HandleTuneInSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
app.sendError(w, "query parameter 'q' is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := bmxpkg.TuneInSearch(query)
|
||||
if 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: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleTuneInNavigate handles TuneIn browse/navigate requests, proxying directly to the bmx package.
|
||||
// Supported path suffixes (relative to /api/tunein/navigate):
|
||||
// - (empty) → top-level browse
|
||||
// - /{encodedURI} → browse the given TuneIn URI
|
||||
// - /sub/{n}/{encodedURI} → single subsection
|
||||
// - /profiles/{type}/{id}/{encodedURI} → artist/program profile
|
||||
func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) {
|
||||
wildcard := chi.URLParam(r, "*")
|
||||
|
||||
var (
|
||||
resp interface{}
|
||||
err error
|
||||
)
|
||||
|
||||
if wildcard == "" {
|
||||
resp, err = bmxpkg.TuneInNavigate("", nil)
|
||||
} else {
|
||||
firstSlash := strings.Index(wildcard, "/")
|
||||
if firstSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
pfx := wildcard[:firstSlash]
|
||||
rest := wildcard[firstSlash+1:]
|
||||
|
||||
switch pfx {
|
||||
case "sub":
|
||||
secondSlash := strings.Index(rest, "/")
|
||||
if secondSlash == -1 {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest, nil)
|
||||
} else {
|
||||
n, parseErr := strconv.Atoi(rest[:secondSlash])
|
||||
if parseErr != nil {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigate(rest[secondSlash+1:], &n)
|
||||
}
|
||||
}
|
||||
case "profiles":
|
||||
parts := strings.SplitN(rest, "/", 3)
|
||||
if len(parts) < 3 {
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
} else {
|
||||
resp, err = bmxpkg.TuneInNavigateProfile(parts[2])
|
||||
}
|
||||
default:
|
||||
resp, err = bmxpkg.TuneInNavigate(wildcard, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if 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: resp}); encErr != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePlayTuneIn plays a TuneIn content item on a specific device via POST /select.
|
||||
func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
app.sendError(w, "Device ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
device, exists := app.Devices[deviceID]
|
||||
if !exists {
|
||||
app.sendError(w, fmt.Sprintf("Device '%s' not found", deviceID), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Location string `json:"location"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
ContainerArt string `json:"containerArt"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
app.sendError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Location == "" {
|
||||
app.sendError(w, "location is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
itemType := req.Type
|
||||
if itemType == "" {
|
||||
itemType = "stationurl"
|
||||
}
|
||||
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
Type: itemType,
|
||||
Location: req.Location,
|
||||
ItemName: req.Name,
|
||||
IsPresetable: true,
|
||||
ContainerArt: req.ContainerArt,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
+4
-164
@@ -2,26 +2,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/config"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
app := &cli.App{
|
||||
Name: "soundtouch-web",
|
||||
@@ -49,36 +38,10 @@ func main() {
|
||||
addr = bindAddr + ":" + port
|
||||
}
|
||||
|
||||
// Create web app without templates (SPA mode)
|
||||
webApp := handlers.NewWebApp()
|
||||
webApp := soundtouchweb.New()
|
||||
|
||||
// Initialize discovery service
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
log.Printf("Failed to load config: %v, using defaults", err)
|
||||
|
||||
cfg = config.DefaultConfig()
|
||||
}
|
||||
|
||||
cfg.DiscoveryTimeout = 10 * time.Second
|
||||
cfg.CacheEnabled = true
|
||||
|
||||
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
||||
|
||||
// Discover devices on startup
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
|
||||
|
||||
discoverDevices(ctx, webApp, discoveryService)
|
||||
|
||||
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
|
||||
webApp.BroadcastDeviceList()
|
||||
}()
|
||||
|
||||
r := setupRoutes(webApp, discoveryService)
|
||||
r := chi.NewRouter()
|
||||
webApp.Mount(r)
|
||||
|
||||
log.Printf("SoundTouch Web UI starting on http://%s", addr)
|
||||
|
||||
@@ -90,126 +53,3 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Static assets (embedded in binary)
|
||||
subFS, _ := fs.Sub(staticFS, "static")
|
||||
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
|
||||
|
||||
// Serve index.html for SPA routes
|
||||
serveIndex := func(w http.ResponseWriter, _ *http.Request) {
|
||||
data, _ := staticFS.ReadFile("static/index.html")
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
// WebSocket endpoint
|
||||
r.Get("/ws", app.HandleWebSocket)
|
||||
|
||||
// API endpoints
|
||||
r.Get("/api/devices", app.HandleAPIDevices)
|
||||
r.Get("/api/device/{id}", app.HandleAPIDevice)
|
||||
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
|
||||
app.HandleAPIDiscover(w, r)
|
||||
// Trigger discovery
|
||||
//nolint:contextcheck // Context is created within goroutine
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Broadcast discovery start
|
||||
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
||||
|
||||
discoverDevices(ctx, app, discoveryService)
|
||||
|
||||
// Broadcast discovery completion and updated device list
|
||||
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
||||
app.BroadcastDeviceList()
|
||||
}()
|
||||
})
|
||||
|
||||
// Device control endpoints (GET for most actions, POST for volume/bass)
|
||||
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
|
||||
|
||||
// TuneIn browse, search, and playback
|
||||
r.Get("/api/tunein/search", app.HandleTuneInSearch)
|
||||
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
|
||||
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
|
||||
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
|
||||
|
||||
// Enhanced device control endpoints
|
||||
r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey)
|
||||
r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl)
|
||||
r.Post("/api/device-power/{id}", app.HandleDevicePower)
|
||||
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
|
||||
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
|
||||
|
||||
// SPA routes - serve index.html for client-side routing
|
||||
r.Get("/", serveIndex)
|
||||
r.Get("/devices", serveIndex)
|
||||
r.Get("/device/*", serveIndex)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
|
||||
log.Println("Starting device discovery...")
|
||||
|
||||
devices, err := discoveryService.DiscoverDevices(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Discovery failed: %v", err)
|
||||
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Found %d devices", len(devices))
|
||||
|
||||
for _, device := range devices {
|
||||
deviceID := device.Host // Use host as unique ID for now
|
||||
|
||||
// Skip if we already have this device
|
||||
if _, exists := app.Devices[deviceID]; exists {
|
||||
app.Devices[deviceID].LastSeen = time.Now()
|
||||
continue
|
||||
}
|
||||
|
||||
// Create new device connection
|
||||
clientConfig := &client.Config{
|
||||
Host: device.Host,
|
||||
Port: device.Port,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
soundTouchClient := client.NewClient(clientConfig)
|
||||
|
||||
// Get device info
|
||||
deviceInfo, err := soundTouchClient.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get device info for %s: %v", device.Host, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create device connection
|
||||
conn := &webtypes.DeviceConnection{
|
||||
Client: soundTouchClient,
|
||||
DeviceInfo: deviceInfo,
|
||||
LastSeen: time.Now(),
|
||||
Status: webtypes.DeviceStatus{
|
||||
IsConnected: false,
|
||||
LastActivity: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
// Initial status fetch asynchronously to avoid blocking discovery
|
||||
go app.UpdateDeviceStatus(deviceID, conn)
|
||||
|
||||
app.Devices[deviceID] = conn
|
||||
|
||||
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -55,22 +55,19 @@ func TestSPARouting(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", tt.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Simulate SPA routing handler
|
||||
spaHandler := func(w http.ResponseWriter, r *http.Request) {
|
||||
// If it's an API route, let it pass through
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/ws") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the SPA index.html content (simulated)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
<title>SoundTouch Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">SPA Content</div>
|
||||
@@ -100,7 +97,7 @@ func TestSPARouting(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAPIEndpoints(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -127,7 +124,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
name: "device API with ID",
|
||||
path: "/api/device/test-device",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusNotFound, // Device won't exist in test
|
||||
expectedStatus: http.StatusNotFound,
|
||||
expectedJSON: true,
|
||||
},
|
||||
}
|
||||
@@ -160,7 +157,6 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
}
|
||||
|
||||
// Validate JSON response structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Invalid JSON response: %v", err)
|
||||
@@ -171,7 +167,7 @@ func TestAPIEndpoints(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAPIResponseFormat(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/devices", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -183,7 +179,6 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
t.Fatalf("Failed to decode JSON response: %v", err)
|
||||
}
|
||||
|
||||
// Check API response structure
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success=true, got success=%v", response.Success)
|
||||
}
|
||||
@@ -192,7 +187,6 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
t.Errorf("Expected data field to be present")
|
||||
}
|
||||
|
||||
// Data should be an empty map for no devices
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Errorf("Expected data to be a map, got %T", response.Data)
|
||||
@@ -204,7 +198,7 @@ func TestAPIResponseFormat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestControlAPIValidation(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -249,7 +243,6 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
// Add a mock device for testing unknown action validation
|
||||
mockDevice := &webtypes.DeviceConnection{
|
||||
Client: nil,
|
||||
DeviceInfo: &models.DeviceInfo{Name: "Test Device"},
|
||||
@@ -278,7 +271,6 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
t.Errorf("Test %s: Expected status %d, got %d. Response: %s", tt.name, tt.expectedStatus, w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Validate error response format
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Expected JSON content type, got %s", contentType)
|
||||
@@ -301,9 +293,8 @@ func TestControlAPIValidation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebSocketUpgrade(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
// Test WebSocket upgrade request
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
req.Header.Set("Connection", "upgrade")
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
@@ -312,16 +303,11 @@ func TestWebSocketUpgrade(t *testing.T) {
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// The actual WebSocket upgrade will fail in test environment,
|
||||
// but we can check that the handler exists and accepts the request
|
||||
app.HandleWebSocket(w, req)
|
||||
|
||||
// In a real test environment, this would fail with a websocket upgrade error
|
||||
// We're just checking the handler doesn't panic and processes the request
|
||||
}
|
||||
|
||||
func TestJSONAPIConsistency(t *testing.T) {
|
||||
app := handlers.NewWebApp()
|
||||
app := soundtouchweb.NewWebApp()
|
||||
|
||||
endpoints := []string{
|
||||
"/api/devices",
|
||||
@@ -344,19 +330,16 @@ func TestJSONAPIConsistency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// All API endpoints should return JSON
|
||||
contentType := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "application/json") {
|
||||
t.Errorf("Endpoint %s should return JSON, got %s", endpoint, contentType)
|
||||
}
|
||||
|
||||
// All responses should follow APIResponse structure
|
||||
var response webtypes.APIResponse
|
||||
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
|
||||
t.Errorf("Endpoint %s returned invalid JSON: %v", endpoint, err)
|
||||
}
|
||||
|
||||
// Response should have either data or error
|
||||
if response.Success && response.Data == nil {
|
||||
t.Errorf("Endpoint %s: success response should have data", endpoint)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,200 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoundTouch Control Center</title>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="/static/css/app.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="#" onclick="showPage('devices')">
|
||||
<i class="bi bi-speaker"></i>
|
||||
SoundTouch Control
|
||||
</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="showPage('devices')"
|
||||
title="Home"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link tunein-nav-link"
|
||||
href="#"
|
||||
onclick="showPage('tunein')"
|
||||
title="TuneIn Browse"
|
||||
>
|
||||
<img
|
||||
src="/static/img/tunein-mono.svg"
|
||||
alt="TuneIn"
|
||||
class="tunein-nav-icon"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
class="nav-link"
|
||||
href="#"
|
||||
onclick="discoverDevices()"
|
||||
title="Discover Devices"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
</a>
|
||||
<button
|
||||
class="theme-toggle nav-link"
|
||||
onclick="toggleTheme()"
|
||||
title="Toggle Dark Mode"
|
||||
>
|
||||
<i id="theme-icon" class="bi bi-moon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container mt-4">
|
||||
<!-- Device List Page -->
|
||||
<div id="devices-page" class="page active">
|
||||
<div
|
||||
class="d-flex justify-content-between align-items-center mb-4"
|
||||
>
|
||||
<h2>Your SoundTouch Devices</h2>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Discover Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="devices-loading" class="loading-spinner"></div>
|
||||
|
||||
<div id="devices-list" class="row">
|
||||
<!-- Device cards will be inserted here by JavaScript -->
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="no-devices"
|
||||
style="display: none"
|
||||
class="text-center py-5"
|
||||
>
|
||||
<i class="bi bi-speaker display-1 text-muted"></i>
|
||||
<h4 class="mt-3">No Devices Found</h4>
|
||||
<p class="text-muted">
|
||||
Click "Discover Devices" to search for SoundTouch
|
||||
speakers on your network.
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick="discoverDevices()">
|
||||
<i class="bi bi-search"></i>
|
||||
Start Discovery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TuneIn Browse Page -->
|
||||
<div id="tunein-page" class="page">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><img src="/static/img/tunein-dark.svg" alt="TuneIn" class="tunein-heading-icon me-2" />TuneIn Browse</h2>
|
||||
</div>
|
||||
|
||||
<div class="tunein-search-bar mb-3">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="tunein-search-input"
|
||||
class="form-control"
|
||||
placeholder="Search stations, podcasts..."
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="tuneInSearch(document.getElementById('tunein-search-input').value)"
|
||||
>
|
||||
<i class="bi bi-search"></i>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="tuneInBrowse()"
|
||||
title="Browse top level"
|
||||
>
|
||||
<i class="bi bi-house"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="tunein-breadcrumb" class="mb-3" style="display: none">
|
||||
<!-- filled by JavaScript -->
|
||||
</nav>
|
||||
|
||||
<div id="tunein-results">
|
||||
<!-- filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Control Page -->
|
||||
<div id="device-page" class="page">
|
||||
<div class="back-button">
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="showPage('devices')"
|
||||
>
|
||||
<i class="bi bi-arrow-left"></i>
|
||||
Back to Devices
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="device-content">
|
||||
<!-- Device control content will be inserted here by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container text-center">
|
||||
<small>
|
||||
SoundTouch Web Control Interface -
|
||||
<a
|
||||
href="https://github.com/gesellix/Bose-SoundTouch"
|
||||
target="_blank"
|
||||
class="text-decoration-none"
|
||||
>
|
||||
Open Source Project
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="toast-container"></div>
|
||||
|
||||
<!-- Device picker for TuneIn playback -->
|
||||
<div class="modal fade" id="devicePickerModal" tabindex="-1" aria-labelledby="devicePickerLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-sm">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title" id="devicePickerLabel">
|
||||
<i class="bi bi-speaker me-2"></i>Play on device
|
||||
</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-2" id="devicePickerList">
|
||||
<!-- device buttons filled by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Application JavaScript -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
accounts/
|
||||
backend/
|
||||
certs/
|
||||
default/
|
||||
dns/
|
||||
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
- AMAZON_PROFILE_URL=http://amazon-mock:8080/user/profile
|
||||
|
||||
spotify-mock:
|
||||
image: golang:1.26.2-alpine
|
||||
image: golang:1.26.3-alpine
|
||||
container_name: spotify-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
- soundtouch-test-net
|
||||
|
||||
amazon-mock:
|
||||
image: golang:1.26.2-alpine
|
||||
image: golang:1.26.3-alpine
|
||||
container_name: amazon-mock
|
||||
working_dir: /app
|
||||
volumes:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module navigation-station-demo
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.57.0
|
||||
require github.com/gesellix/bose-soundtouch v0.71.2
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module preset-management-example
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
require github.com/gesellix/bose-soundtouch v0.57.0
|
||||
require github.com/gesellix/bose-soundtouch v0.71.2
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3 // indirect
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/gesellix/bose-soundtouch
|
||||
|
||||
go 1.26.2
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
|
||||
@@ -783,7 +783,11 @@ func (c *Client) SelectSource(source, sourceAccount string) error {
|
||||
case "BLUETOOTH":
|
||||
contentItem.ItemName = "Bluetooth"
|
||||
case "AUX":
|
||||
contentItem.ItemName = "AUX Input"
|
||||
contentItem.ItemName = "AUX IN"
|
||||
// The speaker rejects AUX with empty sourceAccount as INVALID_SOURCE.
|
||||
if contentItem.SourceAccount == "" {
|
||||
contentItem.SourceAccount = "AUX"
|
||||
}
|
||||
case "TUNEIN":
|
||||
contentItem.ItemName = "TuneIn"
|
||||
case "PANDORA":
|
||||
@@ -818,7 +822,7 @@ func (c *Client) SelectBluetooth() error {
|
||||
return c.SelectSource("BLUETOOTH", "")
|
||||
}
|
||||
|
||||
// SelectAux is a convenience method to select AUX input
|
||||
// SelectAux is a convenience method to select AUX input.
|
||||
func (c *Client) SelectAux() error {
|
||||
return c.SelectSource("AUX", "")
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestClient_SelectSource(t *testing.T) {
|
||||
{
|
||||
name: "Valid AUX source",
|
||||
source: "AUX",
|
||||
sourceAccount: "",
|
||||
sourceAccount: "AUX",
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
@@ -305,7 +305,7 @@ func TestClient_ConvenienceSourceMethods(t *testing.T) {
|
||||
method: "aux",
|
||||
sourceAccount: "",
|
||||
expectedSource: "AUX",
|
||||
expectedAccount: "",
|
||||
expectedAccount: "AUX",
|
||||
},
|
||||
{
|
||||
name: "SelectTuneIn",
|
||||
@@ -530,7 +530,7 @@ func getExpectedItemName(source string) string {
|
||||
case "BLUETOOTH":
|
||||
return "Bluetooth"
|
||||
case "AUX":
|
||||
return "AUX Input"
|
||||
return "AUX IN"
|
||||
case "TUNEIN":
|
||||
return "TuneIn"
|
||||
case "PANDORA":
|
||||
|
||||
@@ -256,11 +256,15 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
s.serverURL = settings.ServerURL
|
||||
|
||||
s.discoveryEnabled = settings.DiscoveryEnabled
|
||||
if settings.DiscoveryInterval != "" {
|
||||
s.discoveryInterval = interval
|
||||
}
|
||||
|
||||
s.discoveryEnabled = settings.DiscoveryEnabled
|
||||
if s.discoveryInterval == 0 {
|
||||
s.discoveryEnabled = false
|
||||
}
|
||||
|
||||
s.dnsEnabled = settings.DNSEnabled
|
||||
|
||||
// Handle comma-separated upstream DNS servers
|
||||
|
||||
+44
-56
@@ -37,12 +37,6 @@ const (
|
||||
// SoundTouchSdkPrivateCfgPath is the path to the speaker's private configuration file on device.
|
||||
const SoundTouchSdkPrivateCfgPath = "/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml"
|
||||
|
||||
// SoundTouchSdkPrivateCfgOverridePath is the path to the speaker's override configuration file on device.
|
||||
// The firmware reads this file in preference to SoundTouchSdkPrivateCfgPath when it exists.
|
||||
// Writing here is safer than editing the original: a malformed override cannot cause a reboot loop
|
||||
// because the device falls back to the untouched original. (Credit: Ueberbose team via soundcork.)
|
||||
const SoundTouchSdkPrivateCfgOverridePath = "/mnt/nv/OverrideSdkPrivateCfg.xml"
|
||||
|
||||
// PrivateCfg represents the SoundTouchSdkPrivateCfg XML structure.
|
||||
type PrivateCfg struct {
|
||||
XMLName xml.Name `xml:"SoundTouchSdkPrivateCfg" json:"-"`
|
||||
@@ -505,26 +499,10 @@ func (m *Manager) populateDeviceInfo(summary *MigrationSummary, deviceIP string)
|
||||
|
||||
// checkCurrentConfig reads and validates the current speaker configuration
|
||||
func (m *Manager) checkCurrentConfig(summary *MigrationSummary, deviceIP string) (string, error) {
|
||||
path := SoundTouchSdkPrivateCfgPath
|
||||
client := m.NewSSH(deviceIP)
|
||||
|
||||
// Check for override config (new-style XML migration) — the device prefers this over the original.
|
||||
// Test existence first: client.Run uses CombinedOutput, so a missing file's stderr would otherwise
|
||||
// be returned as a non-empty config and surfaced to the UI.
|
||||
if _, checkErr := client.Run(fmt.Sprintf("[ -f %s ]", SoundTouchSdkPrivateCfgOverridePath)); checkErr == nil {
|
||||
if overrideCfg, _ := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgOverridePath)); overrideCfg != "" {
|
||||
summary.SSHSuccess = true
|
||||
// The untouched factory config at the original path is the OriginalConfig.
|
||||
if origCfg, _ := client.Run(fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgPath)); origCfg != "" {
|
||||
summary.OriginalConfig = origCfg
|
||||
}
|
||||
|
||||
return overrideCfg, nil
|
||||
}
|
||||
}
|
||||
|
||||
path := SoundTouchSdkPrivateCfgPath
|
||||
|
||||
// Check if .original exists (legacy migration: original file was edited directly)
|
||||
// Check if .original exists
|
||||
if _, checkErr := client.Run(fmt.Sprintf("[ -f %s.original ]", path)); checkErr == nil {
|
||||
if originalConfig, _ := client.Run(fmt.Sprintf("cat %s.original", path)); originalConfig != "" {
|
||||
summary.OriginalConfig = originalConfig
|
||||
@@ -749,7 +727,7 @@ func (m *Manager) checkDNSPreFlight() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options map[string]string, client SSHClient, _ string) (string, error) {
|
||||
func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options map[string]string, client SSHClient, rwCmd string) (string, error) {
|
||||
var logs string
|
||||
|
||||
out, err := m.EnsureRemoteServices(deviceIP)
|
||||
@@ -800,17 +778,45 @@ func (m *Manager) migrateViaXML(deviceIP, targetURL, proxyURL string, options ma
|
||||
// Add XML header
|
||||
xmlContent = append([]byte("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"), xmlContent...)
|
||||
|
||||
// Write to the override path; the original at SoundTouchSdkPrivateCfgPath is left untouched.
|
||||
// /mnt/nv is always writable so no rw remount is needed here.
|
||||
remotePath := SoundTouchSdkPrivateCfgOverridePath
|
||||
// 0. Backup original config if it doesn't exist
|
||||
remotePath := SoundTouchSdkPrivateCfgPath
|
||||
|
||||
if backupOut, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err != nil {
|
||||
logs += fmt.Sprintf("Backing up original config to %s.original (check: %s)\n", remotePath, backupOut)
|
||||
fmt.Printf("Backing up original config to %s.original\n", remotePath)
|
||||
|
||||
if output, err := client.Run(fmt.Sprintf("%s && cp %s %s.original", rwCmd, remotePath, remotePath)); err != nil {
|
||||
logs += fmt.Sprintf("cp backup failed: %v (output: %s)\n", err, output)
|
||||
fmt.Printf("cp backup failed: %v (output: %s)\n", err, output)
|
||||
|
||||
if config, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil && config != "" {
|
||||
if err := client.UploadContent([]byte(config), remotePath+".original"); err != nil {
|
||||
logs += "failed to upload backup config: " + err.Error() + "\n"
|
||||
return logs, fmt.Errorf("cannot create backup of %s before migration: %w", remotePath, err)
|
||||
}
|
||||
|
||||
logs += "Uploaded backup config via fallback\n"
|
||||
} else {
|
||||
return logs, fmt.Errorf("cannot create backup of %s before migration: failed to read original config", remotePath)
|
||||
}
|
||||
} else {
|
||||
logs += "Copied backup config to .original\n"
|
||||
}
|
||||
} else {
|
||||
logs += "Backup .original already exists\n"
|
||||
}
|
||||
|
||||
// 1. Upload the configuration
|
||||
out, _ = client.Run(rwCmd)
|
||||
|
||||
logs += rwCmd + ": " + out + "\n"
|
||||
if err := client.UploadContent(xmlContent, remotePath); err != nil {
|
||||
return logs, fmt.Errorf("failed to upload config: %w", err)
|
||||
}
|
||||
|
||||
logs += "Uploaded new configuration to " + remotePath + "\n"
|
||||
|
||||
// Verify the configuration on device
|
||||
// 2. Verify the configuration on device
|
||||
if verification, err := client.Run(fmt.Sprintf("cat %s", remotePath)); err == nil {
|
||||
if !strings.Contains(verification, cfg.MargeServerUrl) {
|
||||
return logs, fmt.Errorf("verification failed: uploaded config on %s does not contain expected margeServerUrl", deviceIP)
|
||||
@@ -1499,36 +1505,18 @@ func (m *Manager) RevertMigration(deviceIP string) (string, error) {
|
||||
func (m *Manager) revertXMLConfig(client SSHClient, rwCmd string) (string, error) {
|
||||
var logs string
|
||||
|
||||
reverted := false
|
||||
remotePath := SoundTouchSdkPrivateCfgPath
|
||||
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", remotePath)); err == nil {
|
||||
logs += fmt.Sprintf("Reverting %s from backup\n", remotePath)
|
||||
fmt.Printf("Reverting %s from backup\n", remotePath)
|
||||
out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, remotePath, remotePath))
|
||||
|
||||
// Remove override file if it exists (new-style XML migration).
|
||||
if _, err := client.Run(fmt.Sprintf("[ -f %s ]", SoundTouchSdkPrivateCfgOverridePath)); err == nil {
|
||||
logs += fmt.Sprintf("Removing override config %s\n", SoundTouchSdkPrivateCfgOverridePath)
|
||||
out, err := client.Run(fmt.Sprintf("rm -f %s", SoundTouchSdkPrivateCfgOverridePath))
|
||||
|
||||
logs += fmt.Sprintf("rm %s: %s\n", SoundTouchSdkPrivateCfgOverridePath, out)
|
||||
logs += fmt.Sprintf("cp %s.original %s: %s\n", remotePath, remotePath, out)
|
||||
if err != nil {
|
||||
return logs, fmt.Errorf("failed to remove override config: %w", err)
|
||||
return logs, fmt.Errorf("failed to revert %s: %w", remotePath, err)
|
||||
}
|
||||
|
||||
reverted = true
|
||||
}
|
||||
|
||||
// Restore from .original backup if present (legacy migration: original file was edited directly).
|
||||
if _, err := client.Run(fmt.Sprintf("[ -f %s.original ]", SoundTouchSdkPrivateCfgPath)); err == nil {
|
||||
logs += fmt.Sprintf("Reverting %s from legacy backup\n", SoundTouchSdkPrivateCfgPath)
|
||||
out, err := client.Run(fmt.Sprintf("%s && cp %s.original %s", rwCmd, SoundTouchSdkPrivateCfgPath, SoundTouchSdkPrivateCfgPath))
|
||||
|
||||
logs += fmt.Sprintf("cp %s.original %s: %s\n", SoundTouchSdkPrivateCfgPath, SoundTouchSdkPrivateCfgPath, out)
|
||||
if err != nil {
|
||||
return logs, fmt.Errorf("failed to revert %s: %w", SoundTouchSdkPrivateCfgPath, err)
|
||||
}
|
||||
|
||||
reverted = true
|
||||
}
|
||||
|
||||
if !reverted {
|
||||
return logs, fmt.Errorf("nothing to revert: no override config at %s or backup at %s.original", SoundTouchSdkPrivateCfgOverridePath, SoundTouchSdkPrivateCfgPath)
|
||||
} else {
|
||||
return logs, fmt.Errorf("backup %s.original not found, cannot revert", remotePath)
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
|
||||
@@ -1535,11 +1535,9 @@ func TestCheckIsMigrated(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestCheckCurrentConfig_OverrideMissing reproduces issue #214: when the override
|
||||
// file does not exist, client.Run returns the cat stderr ("cat: can't open ...")
|
||||
// via CombinedOutput. The previous implementation treated that non-empty stderr
|
||||
// as a valid config and surfaced it to the UI. Existence must be tested first.
|
||||
func TestCheckCurrentConfig_OverrideMissing(t *testing.T) {
|
||||
// TestCheckCurrentConfig_ReadsOriginalPath verifies that checkCurrentConfig reads
|
||||
// from SoundTouchSdkPrivateCfgPath on an unmigrated device (issue #214 regression test).
|
||||
func TestCheckCurrentConfig_ReadsOriginalPath(t *testing.T) {
|
||||
m := NewManager("http://aftertouch:8000", nil, nil)
|
||||
|
||||
originalCfg := "<SoundTouchSdkPrivateCfg><margeServerUrl>http://streaming.bose.com</margeServerUrl></SoundTouchSdkPrivateCfg>"
|
||||
@@ -1547,13 +1545,6 @@ func TestCheckCurrentConfig_OverrideMissing(t *testing.T) {
|
||||
m.NewSSH = func(host string) SSHClient {
|
||||
return &mockSSH{
|
||||
runFunc: func(command string) (string, error) {
|
||||
if command == fmt.Sprintf("[ -f %s ]", SoundTouchSdkPrivateCfgOverridePath) {
|
||||
return "", fmt.Errorf("exit status 1")
|
||||
}
|
||||
if command == fmt.Sprintf("cat %s", SoundTouchSdkPrivateCfgOverridePath) {
|
||||
return fmt.Sprintf("cat: can't open '%s': No such file or directory\n", SoundTouchSdkPrivateCfgOverridePath),
|
||||
fmt.Errorf("exit status 1")
|
||||
}
|
||||
if strings.HasPrefix(command, "[ -f ") && strings.Contains(command, ".original") {
|
||||
return "", fmt.Errorf("exit status 1")
|
||||
}
|
||||
@@ -1573,9 +1564,6 @@ func TestCheckCurrentConfig_OverrideMissing(t *testing.T) {
|
||||
if cfg != originalCfg {
|
||||
t.Errorf("Expected current config to be the original SoundTouchSdkPrivateCfg.xml, got %q", cfg)
|
||||
}
|
||||
if strings.Contains(cfg, "No such file or directory") {
|
||||
t.Errorf("Current config must not contain cat stderr from missing override file: %q", cfg)
|
||||
}
|
||||
if !summary.SSHSuccess {
|
||||
t.Errorf("Expected SSHSuccess to be true when original config is readable")
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -1,5 +1,4 @@
|
||||
// Package handlers contains tests for HTTP handlers.
|
||||
package handlers
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -10,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
/* ── Reset & Base ─────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f5f5;
|
||||
--surface: #ffffff;
|
||||
--border: #e0e0e0;
|
||||
--text: #1a1a1a;
|
||||
--text-dim: #666;
|
||||
--accent: #000000;
|
||||
--accent-fg: #ffffff;
|
||||
--online: #22c55e;
|
||||
--offline: #9ca3af;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,.10), 0 1px 2px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111;
|
||||
--surface: #1e1e1e;
|
||||
--border: #333;
|
||||
--text: #f0f0f0;
|
||||
--text-dim: #aaa;
|
||||
--accent: #e0e0e0;
|
||||
--accent-fg:#111;
|
||||
}
|
||||
}
|
||||
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; cursor: pointer; }
|
||||
button { cursor: pointer; font: inherit; border: none; background: none; }
|
||||
ul { list-style: none; }
|
||||
img { display: block; max-width: 100%; }
|
||||
|
||||
/* ── Layout ──────────────────────────────────────────────────────────────── */
|
||||
.app { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
|
||||
/* ── Navbar ──────────────────────────────────────────────────────────────── */
|
||||
.navbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1.25rem;
|
||||
height: 52px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.brand { font-size: 1.1rem; font-weight: 600; letter-spacing: .02em; }
|
||||
|
||||
.nav-links { display: flex; align-items: center; gap: .75rem; }
|
||||
|
||||
.nav-links a, .nav-links .btn-icon {
|
||||
color: var(--accent-fg);
|
||||
opacity: .75;
|
||||
font-size: .9rem;
|
||||
padding: .25rem .5rem;
|
||||
border-radius: 4px;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.nav-links a:hover, .nav-links .btn-icon:hover, .nav-links a.active { opacity: 1; }
|
||||
|
||||
.nav-tunein-icon { height: 18px; display: inline-block; filter: brightness(0) invert(1); opacity: .75; }
|
||||
.nav-links a:hover .nav-tunein-icon, .nav-links a.active .nav-tunein-icon { opacity: 1; }
|
||||
|
||||
/* ── Main content ─────────────────────────────────────────────────────────── */
|
||||
.main-content { flex: 1; padding: 1.5rem 1.25rem; max-width: 960px; width: 100%; margin: 0 auto; }
|
||||
|
||||
/* ── Page header ──────────────────────────────────────────────────────────── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.page-header h2 { font-size: 1.4rem; font-weight: 600; flex: 1; }
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────────────────── */
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
padding: .45rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: .875rem;
|
||||
font-weight: 500;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn-primary:hover { opacity: .85; }
|
||||
|
||||
.btn-secondary {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: .45rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: .875rem;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg); }
|
||||
|
||||
.btn-icon {
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
padding: .25rem .4rem;
|
||||
border-radius: 4px;
|
||||
line-height: 1;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
.btn-icon:hover { opacity: .7; }
|
||||
|
||||
.back-btn {
|
||||
color: var(--text-dim);
|
||||
font-size: .875rem;
|
||||
padding: .3rem .6rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.back-btn:hover { background: var(--bg); }
|
||||
|
||||
/* ── Device grid ─────────────────────────────────────────────────────────── */
|
||||
.device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.device-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: box-shadow .15s, transform .1s;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.device-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.12); transform: translateY(-1px); }
|
||||
|
||||
.device-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: .25rem; }
|
||||
.device-name { font-weight: 600; font-size: .95rem; }
|
||||
.device-type { font-size: .8rem; color: var(--text-dim); margin-bottom: .5rem; }
|
||||
|
||||
.device-indicator {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.device-indicator.online { background: var(--online); }
|
||||
.device-indicator.offline { background: var(--offline); }
|
||||
|
||||
.now-playing-mini { font-size: .8rem; color: var(--text-dim); margin-top: .25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.play-status { margin-right: .3rem; }
|
||||
.standby-label { font-size: .8rem; color: var(--text-dim); margin-top: .25rem; }
|
||||
|
||||
/* ── Device detail ───────────────────────────────────────────────────────── */
|
||||
.device-detail { max-width: 560px; }
|
||||
|
||||
/* ── Now playing ─────────────────────────────────────────────────────────── */
|
||||
.now-playing {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
box-shadow: var(--shadow);
|
||||
min-height: 80px;
|
||||
align-items: center;
|
||||
}
|
||||
.now-playing.standby { color: var(--text-dim); font-size: .9rem; }
|
||||
|
||||
.album-art { width: 64px; height: 64px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||
|
||||
.track-info { flex: 1; overflow: hidden; }
|
||||
.track-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.track-artist { font-size: .875rem; color: var(--text-dim); margin-top: .15rem; }
|
||||
.track-album { font-size: .8rem; color: var(--text-dim); }
|
||||
.track-meta { display: flex; align-items: center; gap: .5rem; margin-top: .25rem; }
|
||||
.track-source { font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.buffering-badge { font-size: .7rem; color: var(--text-dim); background: var(--bg); border-radius: 4px; padding: .1rem .35rem; }
|
||||
|
||||
/* ── Transport controls ──────────────────────────────────────────────────── */
|
||||
.controls {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.transport { display: flex; align-items: center; gap: .5rem; margin-bottom: .75rem; }
|
||||
|
||||
.ctrl-btn {
|
||||
font-size: 1.25rem;
|
||||
padding: .4rem .7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
transition: background .12s;
|
||||
}
|
||||
.ctrl-btn:hover { background: var(--bg); }
|
||||
.ctrl-btn.play-btn { font-size: 1.5rem; padding: .4rem .9rem; }
|
||||
.ctrl-btn.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
|
||||
.volume-row { display: flex; align-items: center; gap: .75rem; }
|
||||
.volume-icon { font-size: 1rem; }
|
||||
.volume-slider { flex: 1; accent-color: var(--accent); }
|
||||
.volume-value { font-size: .8rem; color: var(--text-dim); min-width: 2.5ch; text-align: right; }
|
||||
|
||||
.bass-row { display: flex; align-items: center; gap: .75rem; margin-top: .5rem; }
|
||||
.bass-label { font-size: .8rem; color: var(--text-dim); width: 2.5ch; }
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────────────────────────── */
|
||||
.progress-row { margin-top: .35rem; display: flex; align-items: center; gap: .5rem; }
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width .9s linear;
|
||||
}
|
||||
.progress-time { font-size: .7rem; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
/* ── Presets ─────────────────────────────────────────────────────────────── */
|
||||
.presets-section, .sources-section { margin-top: 1.25rem; }
|
||||
.section-title { font-size: .8rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--text-dim); margin-bottom: .6rem; }
|
||||
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: .4rem;
|
||||
}
|
||||
|
||||
.preset-slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .25rem;
|
||||
padding: .4rem .2rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background .1s, box-shadow .1s;
|
||||
min-height: 72px;
|
||||
}
|
||||
.preset-slot:hover:not(:disabled) { background: var(--bg); box-shadow: var(--shadow); }
|
||||
.preset-slot:disabled { opacity: .4; cursor: default; }
|
||||
.preset-slot.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.preset-slot.active .preset-name { color: var(--accent-fg); }
|
||||
|
||||
.preset-art { width: 36px; height: 36px; border-radius: 4px; object-fit: cover; }
|
||||
.preset-source-label { font-size: .6rem; font-weight: 600; text-transform: uppercase; opacity: .6; }
|
||||
.preset-name { font-size: .65rem; text-align: center; line-height: 1.2; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; word-break: break-word; color: var(--text-dim); }
|
||||
.preset-num {
|
||||
position: absolute; top: 2px; right: 4px;
|
||||
font-size: .6rem; font-weight: 700; color: var(--text-dim); opacity: .5;
|
||||
}
|
||||
|
||||
/* ── Sources ─────────────────────────────────────────────────────────────── */
|
||||
.source-list { display: flex; flex-wrap: wrap; gap: .4rem; }
|
||||
|
||||
.source-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .35rem;
|
||||
padding: .35rem .7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
font-size: .8rem;
|
||||
transition: background .1s, border-color .1s;
|
||||
}
|
||||
.source-btn:hover { background: var(--bg); }
|
||||
.source-btn.active { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
.source-btn.local { border-style: dashed; }
|
||||
.source-icon { font-size: .9rem; line-height: 1; }
|
||||
.source-name { font-weight: 500; }
|
||||
|
||||
/* ── Zone ────────────────────────────────────────────────────────────────── */
|
||||
.zone-section { margin-top: 1.25rem; }
|
||||
|
||||
.zone-row { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||||
.zone-status-label { font-size: .875rem; color: var(--text-dim); }
|
||||
|
||||
.zone-members { display: flex; flex-direction: column; gap: .3rem; }
|
||||
.zone-member {
|
||||
display: flex; align-items: center; gap: .6rem;
|
||||
padding: .4rem .6rem;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.zone-master-row { background: var(--bg); }
|
||||
|
||||
.zone-badge {
|
||||
font-size: .65rem; font-weight: 700; text-transform: uppercase;
|
||||
letter-spacing: .05em; padding: .15rem .4rem; border-radius: 3px; flex-shrink: 0;
|
||||
}
|
||||
.zone-badge.master { background: var(--accent); color: var(--accent-fg); }
|
||||
.zone-badge.slave { background: var(--border); color: var(--text-dim); }
|
||||
|
||||
.zone-member-name { flex: 1; font-size: .875rem; }
|
||||
.zone-remove { font-size: .75rem; color: var(--text-dim); padding: .15rem .35rem; }
|
||||
.zone-remove:hover { color: var(--text); }
|
||||
|
||||
.zone-actions { display: flex; gap: .5rem; margin-top: .25rem; flex-wrap: wrap; }
|
||||
.zone-btn { font-size: .8rem; padding: .3rem .7rem; }
|
||||
|
||||
/* ── Recents ─────────────────────────────────────────────────────────────── */
|
||||
.recents-section { margin-top: 1.25rem; }
|
||||
|
||||
.recents-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.recent-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
width: 100%;
|
||||
padding: .5rem .6rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
transition: background .1s;
|
||||
}
|
||||
.recent-item:hover { background: var(--bg); }
|
||||
|
||||
.recent-art {
|
||||
width: 40px; height: 40px; border-radius: 4px;
|
||||
object-fit: cover; flex-shrink: 0;
|
||||
}
|
||||
.recent-art-empty {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg); font-size: 1.1rem;
|
||||
}
|
||||
.recent-info { flex: 1; overflow: hidden; }
|
||||
.recent-name { display: block; font-size: .875rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.recent-source { display: block; font-size: .75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: .05em; margin-top: .1rem; }
|
||||
.recent-play { color: var(--text-dim); font-size: .75rem; flex-shrink: 0; opacity: .5; }
|
||||
.recent-item:hover .recent-play { opacity: 1; }
|
||||
|
||||
/* ── TuneIn ──────────────────────────────────────────────────────────────── */
|
||||
.tunein-toolbar { display: flex; gap: .5rem; margin-bottom: 1rem; }
|
||||
.tunein-search-input {
|
||||
flex: 1;
|
||||
padding: .45rem .75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: .875rem;
|
||||
}
|
||||
.tunein-search-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
|
||||
.breadcrumb { display: flex; align-items: center; gap: .4rem; margin-bottom: .75rem; font-size: .85rem; flex-wrap: wrap; }
|
||||
.breadcrumb-sep { color: var(--text-dim); }
|
||||
.breadcrumb-link { color: var(--text-dim); cursor: pointer; }
|
||||
.breadcrumb-link:hover { text-decoration: underline; }
|
||||
.breadcrumb-current { font-weight: 500; }
|
||||
|
||||
.loading-bar {
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, var(--accent) 0%, transparent 100%);
|
||||
border-radius: 1px;
|
||||
margin-bottom: 1rem;
|
||||
animation: loading 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes loading { 0%,100% { opacity: .4; } 50% { opacity: 1; } }
|
||||
|
||||
.tunein-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
|
||||
.tunein-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .75rem;
|
||||
padding: .6rem .75rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: background .1s;
|
||||
}
|
||||
.tunein-item:hover { background: var(--bg); }
|
||||
|
||||
.tunein-thumb { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; flex-shrink: 0; }
|
||||
.tunein-item-info { flex: 1; overflow: hidden; }
|
||||
.tunein-item-name { display: block; font-size: .9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tunein-item-desc { display: block; font-size: .75rem; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tunein-item-arrow { color: var(--text-dim); font-size: .9rem; flex-shrink: 0; }
|
||||
|
||||
/* ── Device picker overlay ───────────────────────────────────────────────── */
|
||||
.overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.45);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.device-picker {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,.2);
|
||||
}
|
||||
.picker-title { font-weight: 600; margin-bottom: .25rem; }
|
||||
.picker-item-name { font-size: .875rem; color: var(--text-dim); margin-bottom: 1rem; }
|
||||
.picker-devices { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; }
|
||||
.picker-device-btn {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: .6rem 1rem;
|
||||
text-align: left;
|
||||
font-size: .9rem;
|
||||
transition: background .1s;
|
||||
}
|
||||
.picker-device-btn:hover { background: var(--border); }
|
||||
.picker-cancel { width: 100%; }
|
||||
.picker-no-devices { font-size: .875rem; color: var(--text-dim); text-align: center; padding: .5rem 0; }
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||
.empty-state { text-align: center; padding: 4rem 1rem; color: var(--text-dim); }
|
||||
.empty-icon { font-size: 3rem; margin-bottom: 1rem; opacity: .4; }
|
||||
.empty-state p { margin-bottom: 1.5rem; }
|
||||
|
||||
/* ── Toast ───────────────────────────────────────────────────────────────── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
padding: .6rem 1.25rem;
|
||||
border-radius: 999px;
|
||||
font-size: .875rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.2);
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
animation: fade-in .2s ease;
|
||||
}
|
||||
@keyframes fade-in { from { opacity: 0; transform: translateX(-50%) translateY(8px); } }
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 859 B |
@@ -0,0 +1,9 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Morse 'S' (drei Punkte) -->
|
||||
<circle cx="8" cy="10" r="3" fill="#0055aa"/>
|
||||
<circle cx="16" cy="10" r="3" fill="#0055aa"/>
|
||||
<circle cx="24" cy="10" r="3" fill="#0055aa"/>
|
||||
|
||||
<!-- Morse 'T' (ein langer Strich) -->
|
||||
<rect x="5" y="18" width="22" height="6" rx="2" fill="#ffcc00"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 381 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoundTouch Web</title>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"preact": "/static/vendor/preact.module.js",
|
||||
"preact/hooks": "/static/vendor/preact-hooks.module.js",
|
||||
"htm": "/static/vendor/htm.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/img/favicon.svg" />
|
||||
<link rel="alternate icon" href="/static/img/favicon.ico" />
|
||||
<link rel="stylesheet" href="/static/css/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/static/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
|
||||
async function req(url, opts = {}) {
|
||||
const r = await fetch(url, opts);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
devices: () => req('/api/devices'),
|
||||
device: (id) => req(`/api/device/${id}`),
|
||||
discover: () => req('/api/discover', { method: 'POST' }),
|
||||
key: (id, key) => req(`/api/device-key/${id}/${key}`, { method: 'POST' }),
|
||||
volume: (id, level) => req(`/api/device-volume/${id}/${level}`, { method: 'POST' }),
|
||||
bass: (id, level) => req(`/api/control/${id}/bass`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify({ level }),
|
||||
}),
|
||||
power: (id) => req(`/api/device-power/${id}`, { method: 'POST' }),
|
||||
recents: (id) => req(`/api/device-recents/${id}`),
|
||||
zone: (id) => req(`/api/zone/${id}`),
|
||||
zoneAdd: (masterId, slaveId) => req(`/api/zone/${masterId}/add/${slaveId}`, { method: 'POST' }),
|
||||
zoneRemove: (masterId, slaveId) => req(`/api/zone/${masterId}/remove/${slaveId}`, { method: 'POST' }),
|
||||
zoneDissolve: (id) => req(`/api/zone/${id}/dissolve`, { method: 'POST' }),
|
||||
zoneLeave: (id) => req(`/api/zone/${id}/leave`, { method: 'POST' }),
|
||||
play: (id, item) => req(`/api/device-play/${id}`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(item),
|
||||
}),
|
||||
tuneInBrowse: (path) => req(path ? `/api/tunein/navigate/${path}` : '/api/tunein/navigate'),
|
||||
tuneInSearch: (q) => req(`/api/tunein/search?q=${encodeURIComponent(q)}`),
|
||||
control: (id, action, presetId) => req(`/api/control/${id}/${action}?id=${presetId}`),
|
||||
selectSource: (id, source, account) => req(`/api/control/${id}/source?name=${encodeURIComponent(source)}&account=${encodeURIComponent(account || '')}`),
|
||||
tuneInPlay: (deviceId, item) => req(`/api/tunein/play/${deviceId}`, {
|
||||
method: 'POST',
|
||||
headers: JSON_HEADERS,
|
||||
body: JSON.stringify(item),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { h, render } from 'preact';
|
||||
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { DeviceList } from './components/DeviceList.js';
|
||||
import { NowPlaying } from './components/NowPlaying.js';
|
||||
import { Controls } from './components/Controls.js';
|
||||
import { Presets } from './components/Presets.js';
|
||||
import { Sources } from './components/Sources.js';
|
||||
import { Zone } from './components/Zone.js';
|
||||
import { Recents } from './components/Recents.js';
|
||||
import { TuneInBrowser } from './components/TuneInBrowser.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function DeviceDetail({ deviceId, devices, onBack }) {
|
||||
const device = devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
return html`
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onClick=${onBack}>← Back</button>
|
||||
</div>
|
||||
<p>Device not found.</p>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="device-detail">
|
||||
<div class="page-header">
|
||||
<button class="back-btn" onClick=${onBack}>← Back</button>
|
||||
<h2>${device.info?.Name || deviceId}</h2>
|
||||
<button class="btn-icon" onClick=${() => api.power(deviceId)} title="Power">⏻</button>
|
||||
</div>
|
||||
<${NowPlaying} nowPlaying=${device.status?.nowPlaying} />
|
||||
<${Controls} deviceId=${deviceId} status=${device.status} />
|
||||
<${Presets} deviceId=${deviceId} status=${device.status} />
|
||||
<${Sources} deviceId=${deviceId} status=${device.status} />
|
||||
<${Zone} deviceId=${deviceId} devices=${devices} />
|
||||
<${Recents} deviceId=${deviceId} />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [devices, setDevices] = useState({});
|
||||
const [page, setPage] = useState('devices');
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [toast, setToast] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${location.host}/ws`);
|
||||
let reconnectTimer;
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'devices') {
|
||||
setDevices(msg.data || {});
|
||||
} else if (msg.type === 'discovery_status') {
|
||||
if (msg.data?.status === 'completed') {
|
||||
showToast(`Found ${msg.data.deviceCount} device(s)`);
|
||||
}
|
||||
} else if (msg.type === 'status_update' && msg.deviceId) {
|
||||
setDevices(prev => ({
|
||||
...prev,
|
||||
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
reconnectTimer = setTimeout(() => location.reload(), 5000);
|
||||
};
|
||||
|
||||
return () => {
|
||||
clearTimeout(reconnectTimer);
|
||||
ws.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function showToast(msg) {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}
|
||||
|
||||
const navigate = useCallback((p, id = null) => {
|
||||
setPage(p);
|
||||
setSelectedId(id);
|
||||
}, []);
|
||||
|
||||
async function discover() {
|
||||
showToast('Discovering devices…');
|
||||
await api.discover();
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="app">
|
||||
<nav class="navbar">
|
||||
<a class="brand" href="#" onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
|
||||
SoundTouch
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="#" class="${page === 'devices' || page === 'device' ? 'active' : ''}"
|
||||
onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
|
||||
Devices
|
||||
</a>
|
||||
<a href="#" class="${page === 'tunein' ? 'active' : ''}"
|
||||
onClick=${(e) => { e.preventDefault(); navigate('tunein'); }}>
|
||||
<img src="/static/img/tunein-mono.svg" alt="TuneIn" class="nav-tunein-icon" />
|
||||
</a>
|
||||
<button class="btn-icon" onClick=${discover} title="Discover">⟳</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content">
|
||||
${page === 'devices' && html`
|
||||
<${DeviceList}
|
||||
devices=${devices}
|
||||
onSelect=${(id) => navigate('device', id)}
|
||||
onDiscover=${discover}
|
||||
/>
|
||||
`}
|
||||
${page === 'device' && html`
|
||||
<${DeviceDetail}
|
||||
deviceId=${selectedId}
|
||||
devices=${devices}
|
||||
onBack=${() => navigate('devices')}
|
||||
/>
|
||||
`}
|
||||
${page === 'tunein' && html`
|
||||
<${TuneInBrowser} devices=${devices} />
|
||||
`}
|
||||
</main>
|
||||
|
||||
${toast && html`<div class="toast">${toast}</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render(html`<${App} />`, document.getElementById('app'));
|
||||
@@ -0,0 +1,80 @@
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function DeviceCard({ id, device, onSelect }) {
|
||||
const { info, status } = device;
|
||||
const np = status?.nowPlaying;
|
||||
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
|
||||
const isStandby = !np || np.Source === 'STANDBY';
|
||||
|
||||
return html`
|
||||
<div class="device-card" onClick=${() => onSelect(id)}>
|
||||
<div class="device-header">
|
||||
<span class="device-name">${info?.Name || id}</span>
|
||||
<span class="device-indicator ${status?.isConnected ? 'online' : 'offline'}"></span>
|
||||
</div>
|
||||
<div class="device-type">${info?.Type || ''}</div>
|
||||
${!isStandby && html`
|
||||
<div class="now-playing-mini">
|
||||
<span class="play-status">${isPlaying ? '▶' : '⏸'}</span>
|
||||
<span class="track-mini">${np.Track || np.StationName || np.Source}</span>
|
||||
${np.Artist && html`<span class="artist-mini"> — ${np.Artist}</span>`}
|
||||
</div>
|
||||
`}
|
||||
${isStandby && html`<div class="standby-label">Standby</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function DeviceList({ devices, onSelect, onDiscover }) {
|
||||
const entries = Object.entries(devices);
|
||||
|
||||
return html`
|
||||
<div class="page-header">
|
||||
<h2>Devices</h2>
|
||||
<button class="btn-secondary" onClick=${onDiscover}>Discover</button>
|
||||
</div>
|
||||
${entries.length === 0
|
||||
? html`
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">◉</div>
|
||||
<p>No devices found on your network.</p>
|
||||
<button class="btn-primary" onClick=${onDiscover}>Start Discovery</button>
|
||||
</div>`
|
||||
: html`
|
||||
<div class="device-grid">
|
||||
${entries.map(([id, device]) => html`
|
||||
<${DeviceCard} key=${id} id=${id} device=${device} onSelect=${onSelect} />
|
||||
`)}
|
||||
</div>`
|
||||
}
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
function fmt(secs) {
|
||||
if (!secs || secs <= 0) return '0:00';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function NowPlaying({ nowPlaying }) {
|
||||
const [position, setPosition] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const pos = nowPlaying?.Time?.Position ?? 0;
|
||||
setPosition(pos);
|
||||
if (nowPlaying?.PlayStatus !== 'PLAY_STATE') return;
|
||||
const id = setInterval(() => setPosition(p => p + 1), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [nowPlaying?.Time?.Position, nowPlaying?.PlayStatus]);
|
||||
|
||||
if (!nowPlaying || nowPlaying.Source === 'STANDBY') {
|
||||
return html`<div class="now-playing standby">Standby</div>`;
|
||||
}
|
||||
|
||||
const title = nowPlaying.Track || nowPlaying.StationName || nowPlaying.Source;
|
||||
const artURL = nowPlaying.Art?.URL;
|
||||
const isBuffering = nowPlaying.PlayStatus === 'BUFFERING_STATE';
|
||||
const total = nowPlaying.Time?.Total ?? 0;
|
||||
const pct = total > 0 ? Math.min(100, (position / total) * 100) : 0;
|
||||
|
||||
return html`
|
||||
<div class="now-playing">
|
||||
${artURL && html`<img class="album-art" src=${artURL} alt="" />`}
|
||||
<div class="track-info">
|
||||
<div class="track-title">${title}</div>
|
||||
${nowPlaying.Artist && html`<div class="track-artist">${nowPlaying.Artist}</div>`}
|
||||
${nowPlaying.Album && html`<div class="track-album">${nowPlaying.Album}</div>`}
|
||||
<div class="track-meta">
|
||||
<span class="track-source">${nowPlaying.Source}</span>
|
||||
${isBuffering && html`<span class="buffering-badge">Buffering…</span>`}
|
||||
</div>
|
||||
${total > 0 && html`
|
||||
<div class="progress-row">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
<span class="progress-time">${fmt(position)} / ${fmt(total)}</span>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
TUNEIN: 'TuneIn', SPOTIFY: 'Spotify', AMAZON: 'Amazon',
|
||||
PANDORA: 'Pandora', IHEARTRADIO: 'iHeart', DEEZER: 'Deezer',
|
||||
LOCAL_INTERNET_RADIO: 'Internet Radio',
|
||||
};
|
||||
|
||||
function sourceLabel(source) {
|
||||
return SOURCE_LABELS[source] || source;
|
||||
}
|
||||
|
||||
function PresetSlot({ preset, deviceId, active }) {
|
||||
const item = preset?.ContentItem;
|
||||
const isEmpty = !item;
|
||||
const art = item?.ContainerArt;
|
||||
const name = item?.ItemName || `Preset ${preset?.ID ?? ''}`;
|
||||
|
||||
function select() {
|
||||
if (!isEmpty) api.control(deviceId, 'preset', preset.ID);
|
||||
}
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="preset-slot ${isEmpty ? 'empty' : ''} ${active ? 'active' : ''}"
|
||||
onClick=${select}
|
||||
disabled=${isEmpty}
|
||||
title=${isEmpty ? 'Empty' : name}
|
||||
>
|
||||
${art
|
||||
? html`<img class="preset-art" src=${art} alt="" />`
|
||||
: html`<span class="preset-source-label">${isEmpty ? '—' : sourceLabel(item.Source)}</span>`
|
||||
}
|
||||
<span class="preset-name">${isEmpty ? 'Empty' : name}</span>
|
||||
<span class="preset-num">${preset?.ID ?? ''}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function Presets({ deviceId, status }) {
|
||||
const presets = status?.presets?.Preset ?? [];
|
||||
const currentSource = status?.nowPlaying?.Source;
|
||||
const currentLocation = status?.nowPlaying?.ContentItem?.Location;
|
||||
|
||||
// Build a map for quick lookup, then render slots 1-6
|
||||
const byId = Object.fromEntries(presets.map(p => [p.ID, p]));
|
||||
const slots = [1, 2, 3, 4, 5, 6].map(id => byId[id] ?? { ID: id, ContentItem: null });
|
||||
|
||||
function isActive(preset) {
|
||||
const item = preset.ContentItem;
|
||||
return item && item.Source === currentSource && item.Location === currentLocation;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="presets-section">
|
||||
<h3 class="section-title">Presets</h3>
|
||||
<div class="preset-grid">
|
||||
${slots.map(preset => html`
|
||||
<${PresetSlot}
|
||||
key=${preset.ID}
|
||||
preset=${preset}
|
||||
deviceId=${deviceId}
|
||||
active=${isActive(preset)}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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 SOURCE_ICONS = {
|
||||
TUNEIN: '📻', SPOTIFY: '🎵', AMAZON: '🎶', PANDORA: '🎸',
|
||||
DEEZER: '🎵', IHEART: '📻', BLUETOOTH: '📶', AUX: '🔌',
|
||||
LOCAL_MUSIC: '💽', STORED_MUSIC: '💽',
|
||||
};
|
||||
|
||||
export function Recents({ deviceId }) {
|
||||
const [items, setItems] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceId) return;
|
||||
api.recents(deviceId).then(resp => {
|
||||
setItems(resp.data?.Items ?? []);
|
||||
}).catch(() => {
|
||||
setItems([]);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [deviceId]);
|
||||
|
||||
if (loading) return html`
|
||||
<div class="recents-section">
|
||||
<div class="section-title">Recents</div>
|
||||
<div class="loading-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!items || items.length === 0) return null;
|
||||
|
||||
function play(item) {
|
||||
const ci = item.ContentItem;
|
||||
if (!ci?.Location) return;
|
||||
api.play(deviceId, {
|
||||
source: ci.Source,
|
||||
type: ci.Type,
|
||||
location: ci.Location,
|
||||
sourceAccount: ci.SourceAccount,
|
||||
itemName: ci.ItemName,
|
||||
containerArt: ci.ContainerArt,
|
||||
isPresetable: ci.IsPresetable,
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="recents-section">
|
||||
<div class="section-title">Recents</div>
|
||||
<div class="recents-list">
|
||||
${items.map(item => {
|
||||
const ci = item.ContentItem;
|
||||
if (!ci) return null;
|
||||
const icon = SOURCE_ICONS[ci.Source] ?? '♪';
|
||||
return html`
|
||||
<button class="recent-item" key=${item.ID || item.UTCTime} onClick=${() => play(item)}>
|
||||
${ci.ContainerArt
|
||||
? html`<img class="recent-art" src=${ci.ContainerArt} alt="" />`
|
||||
: html`<div class="recent-art recent-art-empty">${icon}</div>`
|
||||
}
|
||||
<div class="recent-info">
|
||||
<span class="recent-name">${ci.ItemName || ci.Source}</span>
|
||||
<span class="recent-source">${ci.Source}</span>
|
||||
</div>
|
||||
<span class="recent-play">▶</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { h } from 'preact';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
const SOURCE_ICONS = {
|
||||
TUNEIN: '📻', SPOTIFY: '🎵', AMAZON: '🛒', PANDORA: '🎶',
|
||||
BLUETOOTH: '📶', AUX: '🔌', OPTICAL: '💡', HDMI: '📺',
|
||||
IHEARTRADIO: '❤️', DEEZER: '🎼', LOCAL_INTERNET_RADIO: '📡',
|
||||
AIRPLAY: '📡', PRODUCT: '🔊',
|
||||
};
|
||||
|
||||
export function Sources({ deviceId, status }) {
|
||||
const items = status?.sources?.SourceItem ?? [];
|
||||
const currentSource = status?.nowPlaying?.Source;
|
||||
const currentAccount = status?.nowPlaying?.SourceAccount;
|
||||
|
||||
const ready = items.filter(s => s.Status === 'READY');
|
||||
if (ready.length === 0) return null;
|
||||
|
||||
function select(src) {
|
||||
api.selectSource(deviceId, src.Source, src.SourceAccount ?? '');
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="sources-section">
|
||||
<h3 class="section-title">Sources</h3>
|
||||
<div class="source-list">
|
||||
${ready.map(src => {
|
||||
const isActive = src.Source === currentSource &&
|
||||
(!src.SourceAccount || src.SourceAccount === currentAccount);
|
||||
return html`
|
||||
<button
|
||||
key=${src.Source + (src.SourceAccount || '')}
|
||||
class="source-btn ${isActive ? 'active' : ''} ${src.IsLocal ? 'local' : ''}"
|
||||
onClick=${() => select(src)}
|
||||
title=${src.Source}
|
||||
>
|
||||
<span class="source-icon">${SOURCE_ICONS[src.Source] || '🔊'}</span>
|
||||
<span class="source-name">${src.DisplayName || src.Source}</span>
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { h } from 'preact';
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import htm from 'htm';
|
||||
import { api } from '../api.js';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
// BmxNavResponse has shape { bmx_sections: [{ name, items: [{ name, imageUrl, subtitle, _links }] }] }
|
||||
// _links.bmx_navigate.href = "/v1/navigate/{encodedPath}" — strip prefix for API call
|
||||
// _links.bmx_playback.href = station/track URL, type = "stationurl"|"tracklisturl"
|
||||
|
||||
function navPath(item) {
|
||||
const href = item._links?.bmx_navigate?.href;
|
||||
return href ? href.replace(/^\/v1\/navigate\//, '') : null;
|
||||
}
|
||||
|
||||
function playbackInfo(item) {
|
||||
const link = item._links?.bmx_playback;
|
||||
return link ? { location: link.href, type: link.type || 'stationurl' } : null;
|
||||
}
|
||||
|
||||
function flattenSections(data) {
|
||||
if (!data?.bmx_sections) return [];
|
||||
return data.bmx_sections.flatMap(section =>
|
||||
(section.items || []).map(item => ({ ...item, _sectionName: section.name }))
|
||||
);
|
||||
}
|
||||
|
||||
export function TuneInBrowser({ devices }) {
|
||||
const [items, setItems] = useState([]);
|
||||
const [navStack, setNavStack] = useState([{ label: 'TuneIn', path: null }]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pendingPlay, setPendingPlay] = useState(null);
|
||||
|
||||
useEffect(() => { browse(null); }, []);
|
||||
|
||||
async function browse(path) {
|
||||
setLoading(true);
|
||||
const resp = await api.tuneInBrowse(path);
|
||||
setLoading(false);
|
||||
if (resp.success) setItems(flattenSections(resp.data));
|
||||
}
|
||||
|
||||
async function search(q) {
|
||||
if (!q.trim()) return;
|
||||
setLoading(true);
|
||||
const resp = await api.tuneInSearch(q);
|
||||
setLoading(false);
|
||||
if (resp.success) {
|
||||
setNavStack([{ label: 'TuneIn', path: null }, { label: `"${q}"`, path: null }]);
|
||||
setItems(flattenSections(resp.data));
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(item) {
|
||||
const path = navPath(item);
|
||||
const play = playbackInfo(item);
|
||||
|
||||
if (path) {
|
||||
setNavStack(s => [...s, { label: item.name, path }]);
|
||||
browse(path);
|
||||
} else if (play) {
|
||||
setPendingPlay({ ...play, name: item.name, image: item.imageUrl });
|
||||
}
|
||||
}
|
||||
|
||||
function navTo(index) {
|
||||
const stack = navStack.slice(0, index + 1);
|
||||
setNavStack(stack);
|
||||
browse(stack[stack.length - 1].path);
|
||||
}
|
||||
|
||||
async function playOn(deviceId) {
|
||||
await api.tuneInPlay(deviceId, { location: pendingPlay.location, type: pendingPlay.type, name: pendingPlay.name });
|
||||
setPendingPlay(null);
|
||||
}
|
||||
|
||||
const deviceEntries = Object.entries(devices);
|
||||
|
||||
return html`
|
||||
<div class="tunein-browser">
|
||||
<div class="tunein-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
class="tunein-search-input"
|
||||
placeholder="Search stations, podcasts…"
|
||||
value=${searchQuery}
|
||||
onInput=${(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown=${(e) => e.key === 'Enter' && search(searchQuery)}
|
||||
/>
|
||||
<button class="btn-primary" onClick=${() => search(searchQuery)}>Search</button>
|
||||
<button class="btn-secondary" onClick=${() => {
|
||||
setNavStack([{ label: 'TuneIn', path: null }]);
|
||||
setSearchQuery('');
|
||||
browse(null);
|
||||
}}>Browse</button>
|
||||
</div>
|
||||
|
||||
${navStack.length > 1 && html`
|
||||
<nav class="breadcrumb">
|
||||
${navStack.map((entry, i) => html`
|
||||
${i > 0 && html`<span class="breadcrumb-sep">›</span>`}
|
||||
${i < navStack.length - 1
|
||||
? html`<a class="breadcrumb-link" onClick=${() => navTo(i)}>${entry.label}</a>`
|
||||
: html`<span class="breadcrumb-current">${entry.label}</span>`
|
||||
}
|
||||
`)}
|
||||
</nav>
|
||||
`}
|
||||
|
||||
${loading && html`<div class="loading-bar"></div>`}
|
||||
|
||||
<ul class="tunein-list">
|
||||
${items.map((item, i) => {
|
||||
const isNav = !!navPath(item);
|
||||
return html`
|
||||
<li key=${item._links?.self?.href || i} class="tunein-item" onClick=${() => navigate(item)}>
|
||||
${item.imageUrl && html`<img class="tunein-thumb" src=${item.imageUrl} alt="" />`}
|
||||
<div class="tunein-item-info">
|
||||
<span class="tunein-item-name">${item.name}</span>
|
||||
${item.subtitle && html`<span class="tunein-item-desc">${item.subtitle}</span>`}
|
||||
</div>
|
||||
<span class="tunein-item-arrow">${isNav ? '›' : '▶'}</span>
|
||||
</li>
|
||||
`;
|
||||
})}
|
||||
</ul>
|
||||
|
||||
${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>`}
|
||||
${deviceEntries.map(([id, d]) => html`
|
||||
<button class="picker-device-btn" onClick=${() => playOn(id)}>
|
||||
${d.info?.name || id}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<button class="btn-secondary picker-cancel" onClick=${() => setPendingPlay(null)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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 Zone({ deviceId, devices }) {
|
||||
const [zone, setZone] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
|
||||
function refresh() {
|
||||
api.zone(deviceId).then(resp => {
|
||||
if (resp.success) setZone(resp.data);
|
||||
}).finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => { refresh(); }, [deviceId]);
|
||||
|
||||
async function addDevice(slaveId) {
|
||||
setShowPicker(false);
|
||||
await api.zoneAdd(deviceId, slaveId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function removeDevice(slaveId) {
|
||||
await api.zoneRemove(deviceId, slaveId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function dissolve() {
|
||||
await api.zoneDissolve(deviceId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function leave() {
|
||||
await api.zoneLeave(deviceId);
|
||||
refresh();
|
||||
}
|
||||
|
||||
if (loading) return html`
|
||||
<div class="zone-section">
|
||||
<div class="section-title">Zone</div>
|
||||
<div class="loading-bar"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (!zone) return null;
|
||||
|
||||
// Devices not already in the zone are available to add
|
||||
const zoneIps = new Set([zone.masterIp, ...(zone.members || []).map(m => m.ip)].filter(Boolean));
|
||||
const available = Object.entries(devices || {}).filter(([ip]) => !zoneIps.has(ip));
|
||||
|
||||
const deviceName = (ip) => devices[ip]?.info?.Name ?? ip;
|
||||
|
||||
return html`
|
||||
<div class="zone-section">
|
||||
<div class="section-title">Zone</div>
|
||||
|
||||
${zone.isStandalone && html`
|
||||
<div class="zone-row">
|
||||
<span class="zone-status-label">Standalone</span>
|
||||
${available.length > 0 && html`
|
||||
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Group with…</button>
|
||||
`}
|
||||
</div>
|
||||
`}
|
||||
|
||||
${zone.isMaster && html`
|
||||
<div class="zone-members">
|
||||
<div class="zone-member zone-master-row">
|
||||
<span class="zone-badge master">Master</span>
|
||||
<span class="zone-member-name">${deviceName(deviceId)}</span>
|
||||
</div>
|
||||
${(zone.members || []).map(m => html`
|
||||
<div class="zone-member" key=${m.ip}>
|
||||
<span class="zone-badge slave">Member</span>
|
||||
<span class="zone-member-name">${m.name || m.ip}</span>
|
||||
<button class="btn-icon zone-remove" title="Remove from zone"
|
||||
onClick=${() => removeDevice(m.ip)}>✕</button>
|
||||
</div>
|
||||
`)}
|
||||
<div class="zone-actions">
|
||||
${available.length > 0 && html`
|
||||
<button class="btn-secondary zone-btn" onClick=${() => setShowPicker(true)}>+ Add speaker</button>
|
||||
`}
|
||||
<button class="btn-secondary zone-btn" onClick=${dissolve}>Dissolve zone</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${zone.isSlave && html`
|
||||
<div class="zone-row">
|
||||
<span class="zone-badge slave">Member</span>
|
||||
<span class="zone-member-name">Zone: ${zone.masterName || zone.masterIp}</span>
|
||||
<button class="btn-secondary zone-btn" onClick=${leave}>Leave zone</button>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${showPicker && html`
|
||||
<div class="overlay" onClick=${() => setShowPicker(false)}>
|
||||
<div class="device-picker" onClick=${e => e.stopPropagation()}>
|
||||
<div class="picker-title">Add to zone</div>
|
||||
<div class="picker-devices">
|
||||
${available.map(([ip, d]) => html`
|
||||
<button class="picker-device-btn" key=${ip} onClick=${() => addDevice(ip)}>
|
||||
${d.info?.Name ?? ip}
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
<button class="btn-secondary picker-cancel" onClick=${() => setShowPicker(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
// Package handlers contains WebSocket handlers for real-time communication.
|
||||
package handlers
|
||||
package soundtouchweb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -7,13 +6,13 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/soundtouchweb/webtypes"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// HandleWebSocket handles WebSocket connections for real-time updates
|
||||
// HandleWebSocket handles browser WebSocket connections for real-time updates.
|
||||
func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := app.Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
@@ -22,19 +21,16 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
defer func() {
|
||||
// Unregister client
|
||||
app.WSMutex.Lock()
|
||||
delete(app.WSClients, conn)
|
||||
app.WSMutex.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
// Register client
|
||||
app.WSMutex.Lock()
|
||||
app.WSClients[conn] = true
|
||||
app.WSMutex.Unlock()
|
||||
|
||||
// Send initial device list
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
@@ -44,30 +40,20 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
Type: "devices",
|
||||
Data: devices,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{Type: "devices", Data: devices}); err != nil {
|
||||
log.Printf("Failed to send initial data: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep connection alive and send updates
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -79,24 +65,19 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Main loop for sending periodic updates
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send periodic status updates
|
||||
for id, device := range app.Devices {
|
||||
if device.Status.IsConnected {
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "status_update",
|
||||
DeviceID: id,
|
||||
Data: device.Status,
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send status update: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -105,36 +86,31 @@ func (app *WebApp) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// HandleAPIDiscover triggers device discovery
|
||||
// HandleAPIDiscover acknowledges a discovery request (actual discovery is triggered by Mount).
|
||||
func (app *WebApp) HandleAPIDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
app.sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Discovery will be triggered by the main app
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
response := webtypes.APIResponse{
|
||||
if err := json.NewEncoder(w).Encode(webtypes.APIResponse{
|
||||
Success: true,
|
||||
Data: map[string]string{"message": "Discovery started"},
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a device
|
||||
// ConnectDeviceWebSocket establishes a WebSocket connection to a SoundTouch device.
|
||||
func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.DeviceConnection) {
|
||||
// Skip WebSocket connection if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wsClient := conn.Client.NewWebSocketClient(nil)
|
||||
|
||||
// Setup event handlers
|
||||
wsClient.OnNowPlaying(func(event *models.NowPlayingUpdatedEvent) {
|
||||
conn.Status.NowPlaying = &event.NowPlaying
|
||||
conn.Status.LastActivity = time.Now()
|
||||
@@ -155,7 +131,6 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
conn.Status.LastActivity = time.Now()
|
||||
})
|
||||
|
||||
// Connect WebSocket
|
||||
if err := wsClient.Connect(); err != nil {
|
||||
log.Printf("Failed to connect WebSocket for device %s: %v", deviceID, err)
|
||||
return
|
||||
@@ -166,7 +141,6 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
|
||||
log.Printf("WebSocket connected for device %s", deviceID)
|
||||
|
||||
// Wait for disconnection
|
||||
wsClient.Wait()
|
||||
|
||||
conn.Status.IsConnected = false
|
||||
@@ -174,55 +148,46 @@ func (app *WebApp) ConnectDeviceWebSocket(deviceID string, conn *webtypes.Device
|
||||
log.Printf("WebSocket disconnected for device %s", deviceID)
|
||||
}
|
||||
|
||||
// UpdateDeviceStatus fetches current status from device
|
||||
// UpdateDeviceStatus fetches current status from a device.
|
||||
func (app *WebApp) UpdateDeviceStatus(_ string, conn *webtypes.DeviceConnection) {
|
||||
// Skip status update if client is not available (e.g., in tests)
|
||||
if conn.Client == nil {
|
||||
return
|
||||
}
|
||||
|
||||
statusUpdated := false
|
||||
|
||||
// Get current now playing
|
||||
if nowPlaying, err := conn.Client.GetNowPlaying(); err == nil {
|
||||
conn.Status.NowPlaying = nowPlaying
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get current volume
|
||||
if volume, err := conn.Client.GetVolume(); err == nil {
|
||||
conn.Status.Volume = volume
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get presets
|
||||
if presets, err := conn.Client.GetPresets(); err == nil {
|
||||
conn.Status.Presets = presets
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Update last activity if any status was updated
|
||||
if statusUpdated {
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
// Get sources
|
||||
|
||||
if sources, err := conn.Client.GetSources(); err == nil {
|
||||
conn.Status.Sources = sources
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Get bass (if available)
|
||||
if bass, err := conn.Client.GetBass(); err == nil {
|
||||
conn.Status.Bass = bass
|
||||
statusUpdated = true
|
||||
}
|
||||
|
||||
// Mark as connected if we successfully got at least one status
|
||||
conn.Status.IsConnected = statusUpdated
|
||||
conn.Status.LastActivity = time.Now()
|
||||
}
|
||||
|
||||
// HandleDeviceWebSocket handles individual device WebSocket connections for real-time device-specific updates
|
||||
// HandleDeviceWebSocket handles per-device WebSocket connections for real-time device-specific updates.
|
||||
func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
deviceID := chi.URLParam(r, "id")
|
||||
if deviceID == "" {
|
||||
@@ -245,31 +210,21 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
log.Printf("Device WebSocket connected for %s", deviceID)
|
||||
|
||||
// Send initial device status
|
||||
initialMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(initialMessage); err != nil {
|
||||
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send initial device status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set up ping handler to detect client disconnects
|
||||
conn.SetPongHandler(func(string) error {
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
// Set initial read deadline
|
||||
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
|
||||
// Handle incoming messages in a separate goroutine
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -281,36 +236,26 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}()
|
||||
|
||||
// Send periodic device status updates
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Send ping to check if client is still connected
|
||||
if err := conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||
log.Printf("Failed to send ping to device WebSocket %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send device status update
|
||||
statusMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "device_status",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(statusMessage); err != nil {
|
||||
Data: map[string]interface{}{"info": device.DeviceInfo, "status": device.Status},
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send device status update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
|
||||
// If device has active WebSocket connection to SoundTouch device,
|
||||
// also send any real-time updates from that connection
|
||||
if device.WebSocket != nil && device.Status.IsConnected {
|
||||
realtimeMessage := webtypes.WebSocketMessage{
|
||||
if err := conn.WriteJSON(webtypes.WebSocketMessage{
|
||||
Type: "device_realtime",
|
||||
DeviceID: deviceID,
|
||||
Data: map[string]interface{}{
|
||||
@@ -318,12 +263,57 @@ func (app *WebApp) HandleDeviceWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
"volume": device.Status.Volume,
|
||||
"timestamp": time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
if err := conn.WriteJSON(realtimeMessage); err != nil {
|
||||
}); err != nil {
|
||||
log.Printf("Failed to send realtime update for %s: %v", deviceID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BroadcastDeviceList sends the updated device list to all connected browser WebSocket clients.
|
||||
func (app *WebApp) BroadcastDeviceList() {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
devices := make(map[string]interface{})
|
||||
for id, device := range app.Devices {
|
||||
devices[id] = map[string]interface{}{
|
||||
"info": device.DeviceInfo,
|
||||
"status": device.Status,
|
||||
"lastSeen": device.LastSeen,
|
||||
}
|
||||
}
|
||||
|
||||
app.broadcast(webtypes.WebSocketMessage{Type: "devices", Data: devices})
|
||||
}
|
||||
|
||||
// BroadcastDiscoveryStatus sends discovery progress to all connected browser WebSocket clients.
|
||||
func (app *WebApp) BroadcastDiscoveryStatus(status string, deviceCount int) {
|
||||
app.WSMutex.RLock()
|
||||
defer app.WSMutex.RUnlock()
|
||||
|
||||
app.broadcast(webtypes.WebSocketMessage{
|
||||
Type: "discovery_status",
|
||||
Data: map[string]interface{}{"status": status, "deviceCount": deviceCount},
|
||||
})
|
||||
}
|
||||
|
||||
// broadcast sends a message to all registered WS clients, removing failed ones.
|
||||
// Caller must hold at least a read lock on WSMutex.
|
||||
func (app *WebApp) broadcast(msg webtypes.WebSocketMessage) {
|
||||
var failed []*websocket.Conn
|
||||
|
||||
for client := range app.WSClients {
|
||||
if err := client.WriteJSON(msg); err != nil {
|
||||
log.Printf("Failed to broadcast to WebSocket client: %v", err)
|
||||
|
||||
failed = append(failed, client)
|
||||
}
|
||||
}
|
||||
|
||||
for _, client := range failed {
|
||||
delete(app.WSClients, client)
|
||||
client.Close()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user