From ab7c8576306a8fb5fafb471016bc75687b58b8a0 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Tue, 9 Jun 2026 22:09:05 +0200 Subject: [PATCH] feat(player): Library tab UI for DLNA browsing and playback Adds the soundtouch-player "Library" tab (Preact + htm), implementing the discover -> add server -> browse -> play flow over the device-scoped library API. Device is picked up front (browsing is speaker-native), then: find LAN servers (SSDP) and add one to the speaker, open a registered server, navigate folders via a breadcrumb, and play a track via native STORED_MUSIC. Mirrors the TuneIn/RadioBrowser components and reuses their CSS classes; marked BETA. api.js gains libraryDiscover/Servers/AddServer/ RemoveServer/Browse/Play; app.js gets the nav entry, title, and route. Validated end to end through the running player against real hardware (FRITZ!Box media server -> ST10): now_playing source=STORED_MUSIC status=PLAY_STATE. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/service/soundtouchweb/static/js/api.js | 15 + pkg/service/soundtouchweb/static/js/app.js | 9 + .../static/js/components/Library.js | 270 ++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 pkg/service/soundtouchweb/static/js/components/Library.js diff --git a/pkg/service/soundtouchweb/static/js/api.js b/pkg/service/soundtouchweb/static/js/api.js index e89d6b2..605c6ba 100644 --- a/pkg/service/soundtouchweb/static/js/api.js +++ b/pkg/service/soundtouchweb/static/js/api.js @@ -56,4 +56,19 @@ export const api = { headers: JSON_HEADERS, body: JSON.stringify({ text }), }), + libraryDiscover: (timeout) => req(`/api/control/providers/library/servers${timeout ? `?timeout=${timeout}` : ''}`), + libraryServers: (id) => req(`/api/control/devices/${id}/library/servers`), + libraryAddServer: (id, body) => req(`/api/control/devices/${id}/library/servers`, { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify(body) }), + libraryRemoveServer: (id, account) => req(`/api/control/devices/${id}/library/servers/${encodeURIComponent(account)}`, { method: 'DELETE' }), + libraryBrowse: (id, { account, location, type, start, count }) => { + const qs = [ + `account=${encodeURIComponent(account)}`, + location !== undefined && location !== '' ? `location=${encodeURIComponent(location)}` : null, + type ? `type=${encodeURIComponent(type)}` : null, + start !== undefined ? `start=${encodeURIComponent(start)}` : null, + count !== undefined ? `count=${encodeURIComponent(count)}` : null, + ].filter(Boolean).join('&'); + return req(`/api/control/devices/${id}/library/browse?${qs}`); + }, + libraryPlay: (id, body) => req(`/api/control/devices/${id}/library/play`, { method: 'POST', headers: JSON_HEADERS, body: JSON.stringify(body) }), }; diff --git a/pkg/service/soundtouchweb/static/js/app.js b/pkg/service/soundtouchweb/static/js/app.js index 973b4cf..e986277 100644 --- a/pkg/service/soundtouchweb/static/js/app.js +++ b/pkg/service/soundtouchweb/static/js/app.js @@ -10,6 +10,7 @@ import { Zone } from './components/Zone.js'; import { Recents } from './components/Recents.js'; import { TuneInBrowser } from './components/TuneInBrowser.js'; import { RadioBrowser } from './components/RadioBrowser.js'; +import { Library } from './components/Library.js'; import { PlayURL } from './components/PlayURL.js'; import { TTS } from './components/TTS.js'; import { api } from './api.js'; @@ -75,6 +76,7 @@ function App() { } if (page === 'tunein') return 'TuneIn'; if (page === 'radiobrowser') return 'RadioBrowser'; + if (page === 'library') return 'Library'; if (page === 'playurl') return 'Play URL'; if (page === 'tts') return 'TTS'; return 'AfterTouch'; @@ -203,6 +205,11 @@ function App() { > Play URL + { e.preventDefault(); navigate('library'); }} + title="Library" + style="font-size:.75rem;font-weight:600;letter-spacing:.02em" + >Lib { e.preventDefault(); navigate('tts'); }} title="TTS" @@ -251,6 +258,8 @@ function App() { <${PlayURL} key="play-url" devices=${devices} serverServiceUrl=${version?.service_url || ''} /> ` : page === 'tts' ? html` <${TTS} key="tts" devices=${devices} serverServiceUrl=${version?.service_url || ''} /> + ` : page === 'library' ? html` + <${Library} key="library" devices=${devices} /> ` : null} diff --git a/pkg/service/soundtouchweb/static/js/components/Library.js b/pkg/service/soundtouchweb/static/js/components/Library.js new file mode 100644 index 0000000..694de28 --- /dev/null +++ b/pkg/service/soundtouchweb/static/js/components/Library.js @@ -0,0 +1,270 @@ +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 Library({ devices }) { + const deviceEntries = Object.entries(devices); + const firstDeviceId = deviceEntries.length > 0 ? deviceEntries[0][0] : null; + + const [deviceId, setDeviceId] = useState(firstDeviceId); + const [servers, setServers] = useState([]); + const [discovered, setDiscovered] = useState([]); + const [server, setServer] = useState(null); // { udn, name, account } + const [navStack, setNavStack] = useState([]); // [{ label, location, type }] + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(false); + const [finding, setFinding] = useState(false); + const [playingName, setPlayingName] = useState(null); + + // Sync deviceId when devices prop first arrives or changes enough to + // invalidate the current selection. + useEffect(() => { + const entries = Object.entries(devices); + if (!deviceId && entries.length > 0) { + setDeviceId(entries[0][0]); + } + }, [devices]); + + // Reload registered servers whenever deviceId changes. + useEffect(() => { + if (!deviceId) return; + setServer(null); + setNavStack([]); + setEntries([]); + loadServers(deviceId); + }, [deviceId]); + + async function loadServers(id) { + setLoading(true); + const resp = await api.libraryServers(id); + setLoading(false); + if (resp.success) setServers(resp.data || []); + } + + async function discover() { + setLoading(true); + const resp = await api.libraryDiscover(6); + setLoading(false); + if (resp.success) setDiscovered(resp.data || []); + } + + async function addServer(srv) { + await api.libraryAddServer(deviceId, { udn: srv.udn, name: srv.name }); + await loadServers(deviceId); + setFinding(false); + } + + async function removeServer(srv) { + await api.libraryRemoveServer(deviceId, `${srv.udn}/0`); + if (server && server.udn === srv.udn) { + setServer(null); + setNavStack([]); + setEntries([]); + } + await loadServers(deviceId); + } + + async function openServer(srv) { + const account = `${srv.udn}/0`; + setServer({ udn: srv.udn, name: srv.name, account }); + const root = { label: srv.name, location: '', type: '' }; + setNavStack([root]); + await browseLevel(account, '', ''); + } + + async function browseLevel(account, location, type) { + setLoading(true); + const resp = await api.libraryBrowse(deviceId, { account, location, type }); + setLoading(false); + if (resp.success) setEntries(resp.data?.entries || []); + } + + async function browseEntry(entry) { + const newFrame = { label: entry.name, location: entry.location, type: entry.type }; + setNavStack(s => [...s, newFrame]); + await browseLevel(server.account, entry.location, entry.type); + } + + async function navTo(index) { + const stack = navStack.slice(0, index + 1); + setNavStack(stack); + const frame = stack[stack.length - 1]; + await browseLevel(server.account, frame.location, frame.type); + } + + async function playEntry(entry) { + await api.libraryPlay(deviceId, { + account: server.account, + location: entry.location, + type: 'track', + name: entry.name, + }); + setPlayingName(entry.name); + setTimeout(() => setPlayingName(null), 3000); + } + + function toggleFinding() { + const next = !finding; + setFinding(next); + if (next && discovered.length === 0) discover(); + } + + const registeredUdns = new Set(servers.map(s => s.udn)); + + return html` +
+ + ${deviceEntries.length === 0 ? html` +

+ No devices found. Discover devices first. +

+ ` : html` +
+ ${deviceEntries.length > 1 ? html` + + ` : html` + + ${devices[deviceId]?.info?.name || deviceId} + + `} + + + BETA + +
+ `} + + ${loading ? html`
` : null} + + ${finding ? html` +
+
+ + LAN media servers + + +
+ ${discovered.length === 0 ? html` +

No servers found yet. Click Rescan to search.

+ ` : html` +
    + ${discovered.map((srv, i) => { + const already = registeredUdns.has(srv.udn); + return html` +
  • +
    + ${srv.name} + ${srv.manufacturer ? html`${srv.manufacturer}${srv.model ? ` — ${srv.model}` : ''}` : null} +
    + ${already + ? html`Added` + : html`` + } +
  • + `; + })} +
+ `} +
+ ` : null} + + ${!server && servers.length === 0 && !loading ? html` +

+ No media servers registered on this device. Use "Find servers" to add one. +

+ ` : null} + + ${servers.length > 0 && !server ? html` +
+

Media servers

+
    + ${servers.map((srv, i) => html` +
  • openServer(srv)}> +
    + ${srv.name} + ${!srv.ready ? html`(connecting…)` : null} +
    + + +
  • + `)} +
+
+ ` : null} + + ${server ? html` +
+ ${navStack.length > 0 ? html` + + ` : null} + + ${playingName ? html` +
+ Playing: ${playingName} +
+ ` : null} + + ${entries.length === 0 && !loading ? html` +

No items found.

+ ` : null} + +
    + ${entries.map((entry, i) => html` +
  • entry.isDir ? browseEntry(entry) : null} + style=${!entry.isDir ? 'cursor:default' : ''} + > +
    + ${entry.name} + ${entry.type ? html`${entry.type}` : null} +
    + ${entry.playable && !entry.isDir ? html` + + ` : null} + ${entry.isDir ? html`` : null} +
  • + `)} +
+
+ ` : null} + +
+ `; +}