Files
Bose-SoundTouch/pkg/service/soundtouchweb/static/js/components/DeviceList.js
T
1270eed554 feat(player): add name/IP device sort toggle (#571) (#576)
Relates to #571.

## What

Adds a **Name / IP** sort toggle to the Player device list. The choice
is persisted in `localStorage` (`aftertouch_device_sort`), following the
same preference pattern as the service-URL field in `PlayURL.js`.

## Why

The device list was previously ordered only by IP: the service datastore
keys devices by IP address and Go marshals map keys lexicographically,
so the frontend received an already-IP-ordered object and rendered it
as-is. BirdyBA (#571) asked to be able to sort by name instead.

## Changes

- `DeviceList.js`: a `sortEntries()` helper plus a `useState`-backed
toggle seeded from `localStorage`. Name mode sorts by `device.info.name`
(falling back to the IP key when a device has no name yet); IP mode
sorts the IP key **numerically** (`.2` before `.10`), which also tidies
the old lexicographic ordering.
- `css/app.css`: additive `.device-sort` / `.sort-btn` styling, reusing
the existing accent / `.active` look. No existing rules touched.

No backend change: the device name and IP are already in the payload.

## Testing

- `node --check` on `DeviceList.js` passes.
- `make build-player` succeeds (the static tree is `//go:embed`ed into
the binary).
- Manual: open the Player, toggle Name / IP, confirm the order changes
and the choice survives a page reload.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:46:53 +02:00

99 lines
4.2 KiB
JavaScript

import { h } from 'preact';
import { useState } from 'preact/hooks';
import htm from 'htm';
const html = htm.bind(h);
const SORT_LS_KEY = 'aftertouch_device_sort';
function sortEntries(entries, mode) {
const copy = [...entries];
if (mode === 'name') {
// Sort by the speaker's display name, falling back to the map key (its IP)
// when a device has no name yet.
copy.sort(([idA, a], [idB, b]) =>
(a?.info?.name || idA).localeCompare(b?.info?.name || idB, undefined, { sensitivity: 'base' }));
} else {
// Default: by IP (the map key), ordered numerically so .2 precedes .10.
copy.sort(([idA], [idB]) =>
idA.localeCompare(idB, undefined, { numeric: true, sensitivity: 'base' }));
}
return copy;
}
function DeviceCard({ id, device, onSelect, onRemove }) {
const { info, status } = device;
const np = status?.nowPlaying;
const isPlaying = np?.PlayStatus === 'PLAY_STATE';
const isStandby = !np || np.Source === 'STANDBY';
return html`
<div class="device-card" onClick=${() => onSelect(id)}>
<div class="device-header">
<span class="device-name">${info?.name || id}</span>
<span class="device-header-right">
<span class="device-indicator ${status?.isConnected ? 'online' : 'offline'}"></span>
<button class="device-remove" title="Remove this device"
aria-label="Remove this device"
onClick=${(e) => { e.stopPropagation(); onRemove(id); }}>✕</button>
</span>
</div>
<div class="device-type">
${info?.type || ''}
${info?.ip_address ? html`<span class="device-ip">(${info.ip_address})</span>` : null}
</div>
${!isStandby ? html`
<div class="now-playing-mini">
<span class="play-status">${isPlaying ? '▶' : '⏸'}</span>
<span class="track-mini">${np.Track || np.StationName || np.Source}</span>
${np.Artist ? html`<span class="artist-mini"> — ${np.Artist}</span>` : null}
</div>
` : null}
${isStandby ? html`<div class="standby-label">Standby</div>` : null}
</div>
`;
}
export function DeviceList({ devices, isDiscovering, onSelect, onDiscover, onRemove }) {
const [sortMode, setSortMode] = useState(() => localStorage.getItem(SORT_LS_KEY) || 'ip');
function changeSort(mode) {
setSortMode(mode);
localStorage.setItem(SORT_LS_KEY, mode);
}
const entries = sortEntries(Object.entries(devices), sortMode);
return html`
<div class="device-list-container">
${entries.length === 0
? html`
<div class="empty-state" key="empty">
<div class="empty-icon ${isDiscovering ? 'radiating' : ''}">◉</div>
<p>${isDiscovering ? 'Searching for devices...' : 'No devices found on your network.'}</p>
<button class="btn-primary" onClick=${onDiscover} disabled=${isDiscovering}>
${isDiscovering ? 'Discovering...' : 'Start Discovery'}
</button>
</div>`
: html`
<div class="device-sort" key="sort">
<span class="device-sort-label">Sort by</span>
<button class="sort-btn ${sortMode === 'name' ? 'active' : ''}"
onClick=${() => changeSort('name')}>Name</button>
<button class="sort-btn ${sortMode === 'ip' ? 'active' : ''}"
onClick=${() => changeSort('ip')}>IP</button>
</div>
<div class="device-grid" key="grid">
${entries.map(([id, device]) => html`
<${DeviceCard} key=${id} id=${id} device=${device} onSelect=${onSelect} onRemove=${onRemove} />
`)}
</div>
<p class="device-list-note" key="note">
Removing a device clears it here. One that is still online may
reappear after the next discovery scan.
</p>`
}
</div>
`;
}