diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index a782c28..7d26a3c 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -110,6 +110,8 @@ func (app *WebApp) Mount(r chi.Router) { 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-recents/{id}", app.HandleDeviceRecents) + r.Post("/api/device-play/{id}", app.HandleDevicePlay) r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket) r.Get("/", app.serveIndex) @@ -600,6 +602,94 @@ func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) } } +// HandleDeviceRecents returns recently played items for a device. +func (app *WebApp) HandleDeviceRecents(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 + } + + recents, err := device.Client.GetRecents() + 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: recents}); encErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +// HandleDevicePlay plays an arbitrary content item on a device. +func (app *WebApp) HandleDevicePlay(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 + } + + var req struct { + Source string `json:"source"` + Type string `json:"type"` + Location string `json:"location"` + SourceAccount string `json:"sourceAccount"` + ItemName string `json:"itemName"` + ContainerArt string `json:"containerArt"` + IsPresetable bool `json:"isPresetable"` + } + + 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 + } + + contentItem := &models.ContentItem{ + Source: req.Source, + Type: req.Type, + Location: req.Location, + SourceAccount: req.SourceAccount, + ItemName: req.ItemName, + ContainerArt: req.ContainerArt, + IsPresetable: req.IsPresetable, + } + + 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.ItemName}, + }); encErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + // HandlePlayTuneIn plays a TuneIn item on a specific device. func (app *WebApp) HandlePlayTuneIn(w http.ResponseWriter, r *http.Request) { deviceID := chi.URLParam(r, "id") diff --git a/pkg/service/soundtouchweb/static/css/app.css b/pkg/service/soundtouchweb/static/css/app.css index 7bf000a..9196f7e 100644 --- a/pkg/service/soundtouchweb/static/css/app.css +++ b/pkg/service/soundtouchweb/static/css/app.css @@ -296,6 +296,40 @@ img { display: block; max-width: 100%; } .source-icon { font-size: .9rem; line-height: 1; } .source-name { font-weight: 500; } +/* ── 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 { diff --git a/pkg/service/soundtouchweb/static/js/api.js b/pkg/service/soundtouchweb/static/js/api.js index 7caf130..7537187 100644 --- a/pkg/service/soundtouchweb/static/js/api.js +++ b/pkg/service/soundtouchweb/static/js/api.js @@ -17,6 +17,12 @@ export const api = { body: JSON.stringify({ level }), }), power: (id) => req(`/api/device-power/${id}`, { method: 'POST' }), + recents: (id) => req(`/api/device-recents/${id}`), + 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}`), diff --git a/pkg/service/soundtouchweb/static/js/app.js b/pkg/service/soundtouchweb/static/js/app.js index ff3b005..2274ea6 100644 --- a/pkg/service/soundtouchweb/static/js/app.js +++ b/pkg/service/soundtouchweb/static/js/app.js @@ -6,6 +6,7 @@ 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 { Recents } from './components/Recents.js'; import { TuneInBrowser } from './components/TuneInBrowser.js'; import { api } from './api.js'; @@ -34,6 +35,7 @@ function DeviceDetail({ deviceId, devices, onBack }) { <${Controls} deviceId=${deviceId} status=${device.status} /> <${Presets} deviceId=${deviceId} status=${device.status} /> <${Sources} deviceId=${deviceId} status=${device.status} /> + <${Recents} deviceId=${deviceId} /> `; } diff --git a/pkg/service/soundtouchweb/static/js/components/Recents.js b/pkg/service/soundtouchweb/static/js/components/Recents.js new file mode 100644 index 0000000..7f2952e --- /dev/null +++ b/pkg/service/soundtouchweb/static/js/components/Recents.js @@ -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` +
+
Recents
+
+
+ `; + + 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` +
+
Recents
+
+ ${items.map(item => { + const ci = item.ContentItem; + if (!ci) return null; + const icon = SOURCE_ICONS[ci.Source] ?? '♪'; + return html` + + `; + })} +
+
+ `; +} \ No newline at end of file