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>
This commit is contained in:
Tobias Gesellchen
2026-07-17 17:46:53 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ca4adb3e11
commit 1270eed554
2 changed files with 57 additions and 1 deletions
@@ -324,6 +324,30 @@ img { display: block; max-width: 100%; }
}
.back-btn:hover { background: var(--bg); }
/* ── Device sort toggle ──────────────────────────────────────────────────── */
.device-sort {
display: flex;
align-items: center;
gap: .4rem;
margin-bottom: 1rem;
}
.device-sort-label {
font-size: .85rem;
opacity: .65;
margin-right: .1rem;
}
.sort-btn {
padding: .25rem .7rem;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: inherit;
font-size: .85rem;
cursor: pointer;
}
.sort-btn:hover { background: var(--bg); }
.sort-btn.active { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
/* ── Device grid ─────────────────────────────────────────────────────────── */
.device-grid {
display: grid;
@@ -1,8 +1,26 @@
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;
@@ -37,7 +55,14 @@ function DeviceCard({ id, device, onSelect, onRemove }) {
}
export function DeviceList({ devices, isDiscovering, onSelect, onDiscover, onRemove }) {
const entries = Object.entries(devices);
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">
@@ -51,6 +76,13 @@ export function DeviceList({ devices, isDiscovering, onSelect, onDiscover, onRem
</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} />