Files
Bose-SoundTouch/pkg/service/soundtouchweb/static/js/components/Library.js
T
Tobias GesellchenandClaude Sonnet 5 862ddbb87d fix(player): follow Library's selected device to its pair master
If the device selected in the Library tab disappeared from the
devices map (e.g. it just became a hidden stereo-pair member per
device_projection.go), the sync effect fell back to entries[0][0] --
whichever key happens to sort first in the map -- silently redirecting
the user's Library browsing session to an unrelated speaker.

Now checks first whether the vanished device reappears as a member of
some other device's stereoPair (the pair's master, which now
represents the same physical speaker for control purposes) and
follows it there. Only falls back to an arbitrary device when the
selection is gone for a genuinely unrelated reason (removed, discovery
gap), matching the prior behavior for that case.

No JS unit-test framework exists in this repo for component-level
logic (consistent with the rest of the client-side code), so this is
verified by manual trace rather than an automated regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 22:27:56 +02:00

291 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 && devices[deviceId]) return;
if (entries.length === 0) {
if (deviceId) setDeviceId(null);
return;
}
if (deviceId) {
// The selected device vanished from the list -- if that's because
// it just became a hidden stereo-pair member (see
// device_projection.go), follow it to its pair's master instead
// of silently jumping to an unrelated device.
const master = entries.find(([, d]) => d.stereoPair?.members?.some(m => m.ipAddress === deviceId));
if (master) {
setDeviceId(master[0]);
return;
}
}
setDeviceId(entries[0][0]);
}, [devices, deviceId]);
// 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) {
// Pass the entry's own type so a folder selects as a container ("dir")
// rather than a single track — that lets the speaker queue the folder so
// next/previous and auto-advance work, instead of stopping after one item.
await api.libraryPlay(deviceId, {
account: server.account,
location: entry.location,
type: entry.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="${entry.isDir ? 'Play folder' : '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>
`;
}