mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
426036c911
commit
ab7c857630
@@ -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) }),
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
>
|
||||
<img src="/app/static/img/link-mono.svg" alt="Play URL" class="nav-url-icon" />
|
||||
</a>
|
||||
<a href="#" class="${page === 'library' ? 'active' : ''}"
|
||||
onClick=${(e) => { e.preventDefault(); navigate('library'); }}
|
||||
title="Library"
|
||||
style="font-size:.75rem;font-weight:600;letter-spacing:.02em"
|
||||
>Lib</a>
|
||||
<a href="#" class="${page === 'tts' ? 'active' : ''}"
|
||||
onClick=${(e) => { 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}
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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`
|
||||
<div class="tunein-browser">
|
||||
|
||||
${deviceEntries.length === 0 ? html`
|
||||
<p class="tunein-item-desc" style="padding:.75rem 0">
|
||||
No devices found. Discover devices first.
|
||||
</p>
|
||||
` : html`
|
||||
<div class="tunein-toolbar" style="flex-wrap:wrap;gap:.5rem;align-items:center">
|
||||
${deviceEntries.length > 1 ? html`
|
||||
<select
|
||||
value=${deviceId}
|
||||
onChange=${(e) => setDeviceId(e.target.value)}
|
||||
style="padding:.4rem .6rem;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface);color:var(--text);font:inherit;font-size:.875rem"
|
||||
>
|
||||
${deviceEntries.map(([id, d]) => html`
|
||||
<option key=${id} value=${id}>${d.info?.name || id}</option>
|
||||
`)}
|
||||
</select>
|
||||
` : html`
|
||||
<span style="font-size:.875rem;color:var(--text-dim)">
|
||||
${devices[deviceId]?.info?.name || deviceId}
|
||||
</span>
|
||||
`}
|
||||
<button
|
||||
class=${finding ? 'btn-primary' : 'btn-secondary'}
|
||||
onClick=${toggleFinding}
|
||||
>
|
||||
${finding ? 'Hide' : 'Find servers'}
|
||||
</button>
|
||||
<span style="font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim);padding:.2rem .4rem;border:1px solid var(--border);border-radius:4px">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
`}
|
||||
|
||||
${loading ? html`<div class="loading-bar"></div>` : null}
|
||||
|
||||
${finding ? html`
|
||||
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;margin-bottom:1rem">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem">
|
||||
<span style="font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-dim)">
|
||||
LAN media servers
|
||||
</span>
|
||||
<button class="btn-secondary" style="font-size:.8rem;padding:.3rem .6rem" onClick=${discover}>
|
||||
Rescan
|
||||
</button>
|
||||
</div>
|
||||
${discovered.length === 0 ? html`
|
||||
<p style="font-size:.875rem;color:var(--text-dim)">No servers found yet. Click Rescan to search.</p>
|
||||
` : html`
|
||||
<ul class="tunein-list">
|
||||
${discovered.map((srv, i) => {
|
||||
const already = registeredUdns.has(srv.udn);
|
||||
return html`
|
||||
<li key=${srv.udn || i} class="tunein-item">
|
||||
<div class="tunein-item-info">
|
||||
<span class="tunein-item-name">${srv.name}</span>
|
||||
${srv.manufacturer ? html`<span class="tunein-item-desc">${srv.manufacturer}${srv.model ? ` — ${srv.model}` : ''}</span>` : null}
|
||||
</div>
|
||||
${already
|
||||
? html`<span style="font-size:.8rem;color:var(--text-dim)">Added</span>`
|
||||
: html`<button class="btn-primary" style="font-size:.8rem;padding:.3rem .7rem;white-space:nowrap" onClick=${() => addServer(srv)}>Add</button>`
|
||||
}
|
||||
</li>
|
||||
`;
|
||||
})}
|
||||
</ul>
|
||||
`}
|
||||
</div>
|
||||
` : null}
|
||||
|
||||
${!server && servers.length === 0 && !loading ? html`
|
||||
<p style="font-size:.875rem;color:var(--text-dim);padding:.5rem 0">
|
||||
No media servers registered on this device. Use "Find servers" to add one.
|
||||
</p>
|
||||
` : null}
|
||||
|
||||
${servers.length > 0 && !server ? html`
|
||||
<div style="margin-bottom:1rem">
|
||||
<p class="section-title" style="margin-bottom:.5rem">Media servers</p>
|
||||
<ul class="tunein-list">
|
||||
${servers.map((srv, i) => html`
|
||||
<li key=${srv.udn || i} class="tunein-item" onClick=${() => openServer(srv)}>
|
||||
<div class="tunein-item-info">
|
||||
<span class="tunein-item-name">${srv.name}</span>
|
||||
${!srv.ready ? html`<span class="tunein-item-desc">(connecting…)</span>` : null}
|
||||
</div>
|
||||
<button
|
||||
class="btn-icon"
|
||||
title="Remove"
|
||||
style="color:var(--text-dim);font-size:.85rem;width:28px;height:28px"
|
||||
onClick=${(e) => { e.stopPropagation(); removeServer(srv); }}
|
||||
>✕</button>
|
||||
<span class="tunein-item-arrow">›</span>
|
||||
</li>
|
||||
`)}
|
||||
</ul>
|
||||
</div>
|
||||
` : null}
|
||||
|
||||
${server ? html`
|
||||
<div>
|
||||
${navStack.length > 0 ? html`
|
||||
<nav class="breadcrumb">
|
||||
${navStack.map((frame, i) => html`
|
||||
${i > 0 ? html`<span class="breadcrumb-sep">›</span>` : null}
|
||||
${i < navStack.length - 1
|
||||
? html`<a class="breadcrumb-link" onClick=${() => navTo(i)}>${frame.label}</a>`
|
||||
: html`<span class="breadcrumb-current">${frame.label}</span>`
|
||||
}
|
||||
`)}
|
||||
</nav>
|
||||
` : null}
|
||||
|
||||
${playingName ? html`
|
||||
<div style="font-size:.875rem;color:var(--text-dim);margin-bottom:.5rem">
|
||||
Playing: <strong>${playingName}</strong>
|
||||
</div>
|
||||
` : null}
|
||||
|
||||
${entries.length === 0 && !loading ? html`
|
||||
<p style="font-size:.875rem;color:var(--text-dim);padding:.5rem 0">No items found.</p>
|
||||
` : null}
|
||||
|
||||
<ul class="tunein-list">
|
||||
${entries.map((entry, i) => html`
|
||||
<li
|
||||
key=${entry.location || i}
|
||||
class="tunein-item"
|
||||
onClick=${() => entry.isDir ? browseEntry(entry) : null}
|
||||
style=${!entry.isDir ? 'cursor:default' : ''}
|
||||
>
|
||||
<div class="tunein-item-info">
|
||||
<span class="tunein-item-name">${entry.name}</span>
|
||||
${entry.type ? html`<span class="tunein-item-desc">${entry.type}</span>` : null}
|
||||
</div>
|
||||
${entry.playable && !entry.isDir ? html`
|
||||
<button
|
||||
class="tunein-play-btn"
|
||||
title="Play on ${devices[deviceId]?.info?.name || deviceId}"
|
||||
onClick=${(e) => { e.stopPropagation(); playEntry(entry); }}
|
||||
>▶</button>
|
||||
` : null}
|
||||
${entry.isDir ? html`<span class="tunein-item-arrow">›</span>` : null}
|
||||
</li>
|
||||
`)}
|
||||
</ul>
|
||||
</div>
|
||||
` : null}
|
||||
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user