diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index 76080a0..52a139e 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -753,6 +753,14 @@ func setupRouter(server *handlers.Server) *chi.Mux { r.Get("/proxy/*", server.HandleProxyRequest) + r.Get("/devices", server.HandleListDiscoveredDevices) + r.Get("/devices/{deviceId}/info", server.HandleGetStockholmDeviceInfo) + r.Post("/devices/{deviceId}/key/{key}", server.HandleDeviceKey) + r.Post("/devices/{deviceId}/volume/{level}", server.HandleDeviceVolume) + + // Stockholm Mini app + r.Handle("/stockholm-mini/*", http.StripPrefix("/stockholm-mini/", http.FileServer(http.Dir("pkg/service/handlers/web/stockholm-mini")))) + r.Route("/setup", func(r chi.Router) { r.Get("/devices", server.HandleListDiscoveredDevices) r.Post("/devices", server.HandleAddManualDevice) diff --git a/pkg/service/handlers/handlers_stockholm.go b/pkg/service/handlers/handlers_stockholm.go new file mode 100644 index 0000000..123f36f --- /dev/null +++ b/pkg/service/handlers/handlers_stockholm.go @@ -0,0 +1,124 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + + "github.com/gesellix/bose-soundtouch/pkg/client" + "github.com/gesellix/bose-soundtouch/pkg/service/setup" + "github.com/go-chi/chi/v5" +) + +// HandleGetStockholmDeviceInfo returns live information for a device. +func (s *Server) HandleGetStockholmDeviceInfo(w http.ResponseWriter, r *http.Request) { + deviceID := chi.URLParam(r, "deviceId") + if deviceID == "" { + http.Error(w, "Device ID is required", http.StatusBadRequest) + return + } + + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + info, err := s.sm.GetLiveDeviceInfo(deviceIP) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + // Include IP address in both snake_case and camelCase for frontend compatibility + type deviceInfoResponse struct { + *setup.DeviceInfoXML `json:",inline"` + IPAddress string `json:"ip_address"` + IPAddressCamel string `json:"ipAddress,omitempty"` + } + + resp := deviceInfoResponse{ + DeviceInfoXML: info, + IPAddress: deviceIP, + IPAddressCamel: deviceIP, + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// HandleDeviceKey sends a key command to a device. +func (s *Server) HandleDeviceKey(w http.ResponseWriter, r *http.Request) { + deviceID := chi.URLParam(r, "deviceId") + + key := chi.URLParam(r, "key") + if deviceID == "" || key == "" { + http.Error(w, "Device ID and Key are required", http.StatusBadRequest) + return + } + + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + c := client.NewClientFromHost(deviceIP) + + err = c.SendKey(key) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to send key %s to %s: %v", key, deviceIP, err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Key sent"}); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// HandleDeviceVolume sets the volume level for a device. +func (s *Server) HandleDeviceVolume(w http.ResponseWriter, r *http.Request) { + deviceID := chi.URLParam(r, "deviceId") + + levelStr := chi.URLParam(r, "level") + if deviceID == "" || levelStr == "" { + http.Error(w, "Device ID and Level are required", http.StatusBadRequest) + return + } + + deviceIP, err := s.lookupIP(deviceID) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + + level, err := strconv.Atoi(levelStr) + if err != nil { + http.Error(w, "Invalid volume level", http.StatusBadRequest) + return + } + + c := client.NewClientFromHost(deviceIP) + + err = c.SetVolume(level) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to set volume to %d on %s: %v", level, deviceIP, err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Volume set"}); err != nil { + log.Printf("Failed to encode JSON response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 2593242..f1e6d73 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -840,3 +840,23 @@ func (s *Server) resolveDeviceIDToIP(deviceID string) (string, error) { return "", fmt.Errorf("device not found: %s", deviceID) } + +// lookupIP resolves a deviceId to its last known device IP. +func (s *Server) lookupIP(deviceId string) (string, error) { + devices, err := s.ds.ListAllDevices() + if err != nil { + return "", err + } + + for i := range devices { + if devices[i].DeviceID == deviceId { + if devices[i].IPAddress == "" { + return "", fmt.Errorf("no IP known for deviceId %s", deviceId) + } + + return devices[i].IPAddress, nil + } + } + + return "", fmt.Errorf("deviceId %s not found", deviceId) +} diff --git a/pkg/service/handlers/web/stockholm-mini/app.js b/pkg/service/handlers/web/stockholm-mini/app.js new file mode 100644 index 0000000..e8d1197 --- /dev/null +++ b/pkg/service/handlers/web/stockholm-mini/app.js @@ -0,0 +1,456 @@ +async function fetchDevices() { + try { + const response = await fetch('/devices'); + const devices = await response.json(); + const container = document.getElementById('device-list'); + const seen = new Set(); + + if (devices.length === 0) { + container.innerHTML = '

No devices found. Ensure they are on the same network.

'; + return; + } + + devices.forEach(device => { + seen.add(device.device_id); + const existing = document.getElementById(`device-${device.device_id}`); + if (existing) { + // Update product code/IP if changed, but keep title if we already have a better name + const title = existing.querySelector('.device-title'); + if (title && (!title.textContent || title.textContent === 'Unknown Device' || title.textContent.startsWith('SoundTouch-'))) { + title.textContent = device.name || 'Unknown Device'; + } + const subtitle = existing.querySelector('.device-subtitle span'); + if (subtitle) { + const currentSubtitle = subtitle.textContent || ''; + const parts = currentSubtitle.split(' | '); + const currentType = parts.length > 1 ? parts[1].trim() : ''; + const newType = device.product_code || 'Unknown'; + + // Don't downgrade type if we already have a specific one + const isGeneric = !currentType || currentType === 'Unknown' || currentType === 'N/A'; + const displayType = isGeneric ? newType : currentType; + subtitle.textContent = `${device.ip_address} | ${displayType}`; + } + const details = existing.querySelector(`#details-${device.device_id}`); + if (details) { + const idField = details.querySelector('p:nth-child(1) code'); + if (idField) { + const currentId = idField.textContent; + // Don't overwrite with serial if we have a real deviceID (usually hex) + if (!currentId || currentId === 'N/A' || currentId === device.device_serial_number) { + idField.textContent = device.device_id || 'N/A'; + } + } + const firmwareField = details.querySelector('p:nth-child(2) code'); + if (firmwareField) { + const cur = firmwareField.textContent; + if (!cur || cur === 'N/A' || cur === '0.0.0') { + firmwareField.textContent = device.firmware_version || 'N/A'; + } + } + const serialField = details.querySelector('p:nth-child(3) code'); + if (serialField && (!serialField.textContent || serialField.textContent === 'N/A')) { + serialField.textContent = device.device_serial_number || 'N/A'; + } + } + // Ensure WS is open + openDeviceWebSocket(device.device_id); + return; + } + + const card = document.createElement('div'); + card.className = 'device-card'; + card.id = `device-${device.device_id}`; + card.innerHTML = ` +
+
+
+
+

${device.name || 'Unknown Device'}

+ +
+

+ ${device.ip_address} | ${device.product_code} +

+
+ +
+
+

ID: ${device.device_id}

+

Firmware: ${device.firmware_version || 'N/A'}

+

Serial: ${device.device_serial_number || 'N/A'}

+

Discovery: ${device.discovery_method || 'N/A'}

+
+
+
+

Loading playback status...

+
+
+ + + + +
+
+ Vol: + +
+ `; + container.appendChild(card); + updateNowPlaying(device.device_id); + updateVolume(device.device_id); + openDeviceWebSocket(device.device_id); + }); + + // Remove cards for devices that no longer exist + Array.from(container.children).forEach(child => { + const id = child.id?.replace('device-', ''); + if (id && !seen.has(id)) { + container.removeChild(child); + } + }); + } catch (error) { + console.error('Failed to fetch devices', error); + document.getElementById('device-list').innerHTML = '

Error loading devices.

'; + } +} + +async function updateNowPlaying(deviceId) { + try { + const response = await fetch(`/devices/${deviceId}/info`); + if (!response.ok) return; + const info = await response.json(); + + // Update device name and type if available (live info is more accurate than discovery) + const title = document.querySelector(`#device-${deviceId} .device-title`); + if (title && info.name) { + title.textContent = info.name; + } + const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`); + if (subtitle && info.type) { + subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`; + } + + // Update firmware version if available + const details = document.getElementById(`details-${deviceId}`); + if (details) { + if (info.deviceID) { + const idField = details.querySelector('p:nth-child(1) code'); + if (idField) idField.textContent = info.deviceID; + } + if (info.softwareVersion) { + const firmwareField = details.querySelector('p:nth-child(2) code'); + if (firmwareField) firmwareField.textContent = info.softwareVersion; + } + if (info.serialNumber) { + const serialField = details.querySelector('p:nth-child(3) code'); + if (serialField) serialField.textContent = info.serialNumber; + } + } + + const npContainer = document.getElementById(`np-${deviceId}`); + if (npContainer && info.nowPlaying) { + const np = info.nowPlaying; + const source = np.source || np.Source; + const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`); + if (powerIcon) { + if (source === 'STANDBY') { + powerIcon.classList.add('off'); + powerIcon.classList.remove('on'); + } else { + powerIcon.classList.remove('off'); + powerIcon.classList.add('on'); + } + } + if (source === 'STANDBY') { + npContainer.innerHTML = '

Standby

'; + } else { + const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track'; + const artist = np.artist || np.Artist || 'Unknown Artist'; + const album = np.album || np.Album || 'Unknown Album'; + const art = np.Art || np.art || {}; + const artStatus = art.ArtImageStatus || art.artImageStatus; + const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : ''; + + npContainer.innerHTML = ` + Artwork +
+ ${track}
+ ${artist} - ${album} +
+ `; + } + } + } catch (error) { + console.warn('Failed to fetch now playing for ' + deviceId, error); + } +} + +async function updateVolume(deviceId) { + try { + const response = await fetch(`/devices/${deviceId}/info`); + if (!response.ok) return; + const info = await response.json(); + const slider = document.getElementById(`vol-${deviceId}`); + if (slider && info.volume && typeof info.volume.actualvolume === 'number' && !adjusting[deviceId]) { + slider.value = String(info.volume.actualvolume); + } + } catch (error) { + console.warn('Failed to fetch volume for ' + deviceId, error); + } +} + +async function control(deviceId, key) { + let deviceName = deviceId; + const title = document.querySelector(`#device-${deviceId} .device-title`); + if (title && title.textContent) { + deviceName = title.textContent; + } + + try { + const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/key/${encodeURIComponent(key)}`, { + method: 'POST' + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `HTTP ${res.status}`); + } + } catch (error) { + console.error('Control failed', error); + alert(`Failed to send ${key} to ${deviceName}: ${error.message}`); + } +} + +async function setVolume(deviceId, level) { + let deviceName = deviceId; + const title = document.querySelector(`#device-${deviceId} .device-title`); + if (title && title.textContent) { + deviceName = title.textContent; + } + + try { + const res = await fetch(`/devices/${encodeURIComponent(deviceId)}/volume/${encodeURIComponent(level)}`, { + method: 'POST' + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `HTTP ${res.status}`); + } + } catch (error) { + console.error('Set volume failed', error); + alert(`Failed to set volume on ${deviceName} to ${level}: ${error.message}`); + } +} + +// Volume interaction helpers to avoid UI jumping while dragging +const adjusting = {}; +const volumeTimers = {}; + +function startAdjust(deviceId) { + adjusting[deviceId] = true; +} + +function endAdjust(deviceId) { + // Small delay to let the device send back its volume update + setTimeout(() => { adjusting[deviceId] = false; }, 300); +} + +function onVolumeInput(deviceId, el) { + startAdjust(deviceId); + const level = el.value; + // Debounce network calls per device + if (volumeTimers[deviceId]) { + clearTimeout(volumeTimers[deviceId]); + } + volumeTimers[deviceId] = setTimeout(() => { + setVolume(deviceId, level); + endAdjust(deviceId); + }, 150); +} + +let deviceSockets = {}; + +function openDeviceWebSocket(deviceId) { + const key = `${deviceId}`; + try { + const existing = deviceSockets[key]; + if (existing) { + // Reuse an already healthy connection instead of tearing it down every refresh + if (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING) { + return; + } + try { existing.close(); } catch (_) {} + } + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const wsUrl = `${proto}://${location.host}/devices/${encodeURIComponent(deviceId)}/ws`; + const ws = new WebSocket(wsUrl); + deviceSockets[key] = ws; + + ws.onopen = () => { + // console.log('WS connected for', deviceId); + }; + ws.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data); + const type = msg.type; + const payload = msg.payload || {}; + if (type === 'nowPlayingUpdated') { + const e = payload; + const np = e.NowPlaying || e.nowPlaying || {}; + const source = np.source || np.Source; + + // Also try to update name/type if they are present in the event (sometimes events carry device info) + const title = document.querySelector(`#device-${deviceId} .device-title`); + if (title && e.name) { + title.textContent = e.name; + } + const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`); + if (subtitle && e.type) { + subtitle.textContent = `${e.ipAddress || e.ip_address || 'N/A'} | ${e.type}`; + } + + const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`); + if (powerIcon) { + if (source === 'STANDBY') { + powerIcon.classList.add('off'); + powerIcon.classList.remove('on'); + } else { + powerIcon.classList.remove('off'); + powerIcon.classList.add('on'); + } + } + const npContainer = document.getElementById(`np-${deviceId}`); + if (npContainer) { + if (source === 'STANDBY') { + npContainer.innerHTML = '

Standby

'; + } else { + const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track'; + const artist = np.artist || np.Artist || 'Unknown Artist'; + const album = np.album || np.Album || 'Unknown Album'; + const art = np.Art || np.art || {}; + const artStatus = art.ArtImageStatus || art.artImageStatus; + const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : ''; + + npContainer.innerHTML = ` + Artwork +
+ ${track}
+ ${artist} - ${album} +
+ `; + } + } + } else if (type === 'volumeUpdated') { + const e = payload; + const vol = (e.Volume && (typeof e.Volume.actualvolume === 'number' ? e.Volume.actualvolume : (typeof e.Volume.actual === 'number' ? e.Volume.actual : e.Volume.target))) || + (e.volume && (typeof e.volume.actualvolume === 'number' ? e.volume.actualvolume : (typeof e.volume.actual === 'number' ? e.volume.actual : e.volume.target))); + const slider = document.getElementById(`vol-${deviceId}`); + if (slider && typeof vol === 'number' && !adjusting[deviceId]) { + slider.value = String(vol); + } + } else if (type === 'snapshotInfo') { + const info = payload || {}; + + // Update name and type from snapshot + const title = document.querySelector(`#device-${deviceId} .device-title`); + if (title && info.name) { + title.textContent = info.name; + } + const subtitle = document.querySelector(`#device-${deviceId} .device-subtitle span`); + if (subtitle && info.type) { + subtitle.textContent = `${info.ipAddress || info.ip_address || 'N/A'} | ${info.type}`; + } + + // Update firmware and ID from snapshot if available + const details = document.getElementById(`details-${deviceId}`); + if (details) { + if (info.deviceID) { + const idField = details.querySelector('p:nth-child(1) code'); + if (idField) idField.textContent = info.deviceID; + } + if (info.softwareVersion) { + const firmwareField = details.querySelector('p:nth-child(2) code'); + if (firmwareField) firmwareField.textContent = info.softwareVersion; + } + if (info.serialNumber) { + const serialField = details.querySelector('p:nth-child(3) code'); + if (serialField) serialField.textContent = info.serialNumber; + } + } + + if (info.nowPlaying) { + const np = info.nowPlaying; + const source = np.source || np.Source; + const powerIcon = document.querySelector(`#device-${deviceId} .power-icon`); + if (powerIcon) { + if (source === 'STANDBY') { + powerIcon.classList.add('off'); + powerIcon.classList.remove('on'); + } else { + powerIcon.classList.remove('off'); + powerIcon.classList.add('on'); + } + } + const npContainer = document.getElementById(`np-${deviceId}`); + if (npContainer) { + if (source === 'STANDBY') { + npContainer.innerHTML = '

Standby

'; + } else { + const track = np.track || np.Track || np.stationName || np.StationName || 'Unknown Track'; + const artist = np.artist || np.Artist || 'Unknown Artist'; + const album = np.album || np.Album || 'Unknown Album'; + const art = np.Art || np.art || {}; + const artStatus = art.ArtImageStatus || art.artImageStatus; + const artUrl = artStatus === 'IMAGE_PRESENT' ? (art.URL || art.url || '') : ''; + + npContainer.innerHTML = ` + Artwork +
+ ${track}
+ ${artist} - ${album} +
+ `; + } + } + } + const vol = info.actualVolume || (info.volume && (typeof info.volume.actualvolume === 'number' ? info.volume.actualvolume : (typeof info.volume.actual === 'number' ? info.volume.actual : null))); + const slider = document.getElementById(`vol-${deviceId}`); + if (slider && typeof vol === 'number' && !adjusting[deviceId]) slider.value = String(vol); + } + } catch (err) { + // console.warn('Bad WS message', err); + } + }; + ws.onerror = () => { + // console.warn('WS error for', ip); + }; + ws.onclose = () => { + // Try to reconnect after a delay + setTimeout(() => { + if (deviceSockets[key] === ws) { + delete deviceSockets[key]; + } + openDeviceWebSocket(deviceId); + }, 3000); + }; + } catch (e) { + // console.warn('Failed to open WS for', deviceId, e); + } +} + +function toggleDetails(deviceId) { + const el = document.getElementById(`details-${deviceId}`); + if (el) { + el.classList.toggle('visible'); + } +} + + +document.addEventListener('DOMContentLoaded', () => { + fetchDevices(); + fetchVersion(); + setInterval(fetchDevices, 30000); +}); diff --git a/pkg/service/handlers/web/stockholm-mini/bose.ttf b/pkg/service/handlers/web/stockholm-mini/bose.ttf new file mode 100644 index 0000000..efabc4c Binary files /dev/null and b/pkg/service/handlers/web/stockholm-mini/bose.ttf differ diff --git a/pkg/service/handlers/web/stockholm-mini/index.html b/pkg/service/handlers/web/stockholm-mini/index.html new file mode 100644 index 0000000..34d1018 --- /dev/null +++ b/pkg/service/handlers/web/stockholm-mini/index.html @@ -0,0 +1,25 @@ + + + + + Stockholm Mini - Reverse Engineered + + + + +
+

Stockholm Mini

+

A minimal reverse-engineered SoundTouch controller.

+

← Back to selection

+ +
+
+ + + + + + + diff --git a/pkg/service/handlers/web/stockholm-mini/style.css b/pkg/service/handlers/web/stockholm-mini/style.css new file mode 100644 index 0000000..67d0598 --- /dev/null +++ b/pkg/service/handlers/web/stockholm-mini/style.css @@ -0,0 +1,40 @@ +@font-face { + font-family: 'bose'; + src: url('bose.ttf') format('truetype'); + font-weight: normal; + font-style: normal; + font-display: swap; +} +body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #121212; color: #e0e0e0; margin: 0; padding: 20px; } +.container { max-width: 800px; margin: 0 auto; } +h1 { color: #fff; border-bottom: 1px solid #333; padding-bottom: 10px; } +.device-card { background: #1e1e1e; border-radius: 8px; padding: 20px; margin-bottom: 20px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); } +.device-info h2 { margin-top: 0; color: #00bcd4; margin-bottom: 0; } +.device-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.device-title-row { display: flex; align-items: center; gap: 10px; margin-bottom: 4px; } +.device-title { margin: 0; font-size: 1.5rem; line-height: 1.2; } +.device-subtitle { color: #888; font-size: 0.85rem; margin: 0; display: flex; align-items: center; } +.info-toggle { background: none; color: #555; padding: 0; width: 1.15rem; height: 1.15rem; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #444; border-radius: 50%; font-size: 0.7rem; font-style: italic; cursor: pointer; line-height: 1; transition: all 0.2s; flex-shrink: 0; } +.info-toggle:hover { color: #aaa; border-color: #666; background: #2a2a2a; } +.device-details { display: none; margin-top: 10px; font-size: 0.8rem; background: #252525; padding: 10px; border-radius: 4px; color: #aaa; border-left: 2px solid #00bcd4; } +.device-details.visible { display: block; } +.device-details p { margin: 4px 0; } +.device-details code { color: #ccc; } +.controls { display: flex; gap: 10px; margin-top: 20px; } +button { background: #333; color: #fff; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; transition: background 0.2s; } +button:hover { background: #444; } +button.primary { background: #00bcd4; color: #000; font-weight: bold; } +button.primary:hover { background: #00acc1; } +.power-icon { font-family: bose, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 1.25rem; line-height: 1; height: 2.25rem; width: 2.25rem; padding: 0; display: inline-flex; align-items: center; justify-content: center; background: #2a2a2a; border-radius: 50%; color: #00bcd4; border: 1px solid #00bcd4; } +.power-icon:hover { background: #3a3a3a; } +.power-icon.off { color: #666; border-color: #444; background: #1a1a1a; } +.power-icon.on { background: #00bcd4; color: #000; border-color: #00bcd4; } +.power-icon.on:hover { background: #00acc1; } +.status-badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.8em; background: #333; margin-left: 10px; } +.now-playing { margin-top: 20px; padding-top: 20px; border-top: 1px solid #333; display: flex; gap: 15px; align-items: center; min-height: 80px; } +.now-playing-info { flex-grow: 1; } +.album-art { width: 80px; height: 80px; border-radius: 4px; background: #2a2a2a; flex-shrink: 0; object-fit: cover; box-shadow: 0 2px 4px rgba(0,0,0,0.5); } +.album-art[src=""] { display: none; } +.volume-container { margin-top: 15px; display: flex; align-items: center; gap: 10px; } +input[type=range] { flex-grow: 1; } +#device-list:empty::after { content: "Searching for devices..."; color: #666; font-style: italic; }