Files
Tobias Gesellchen d2ba3d757d feat(player): render announcement banners in soundtouch-player too
The design's three-area target model (chooser/app/admin) and the backend
(HandleListAnnouncements' target=app filtering) already supported this, but
nothing in soundtouch-player's frontend called it — chunk 5 only wired the
admin UI. New Announcements Preact component (static/js/components/), mounted
in App() above the main content so it's visible across every page, styled
with the app's existing CSS variables (dark-mode-aware, unlike the admin
UI's hardcoded inline colors). Currently renders nothing, since no
announcement in the list targets "app" yet (only the admin-gate notice,
targeting "admin") — this is just closing the parity gap so a future
app-targeted announcement has somewhere to show up.

Verified end-to-end against a running instance: the component is served
under /app/static/js/components/, app.js references it, and
/api/announcements?target=app responds correctly (empty today).

Refs #419
2026-08-08 23:49:57 +02:00

287 lines
13 KiB
JavaScript

import { h, render } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
import htm from 'htm';
import { DeviceList } from './components/DeviceList.js';
import { NowPlaying } from './components/NowPlaying.js';
import { Controls } from './components/Controls.js';
import { Presets } from './components/Presets.js';
import { Sources } from './components/Sources.js';
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 { Announcements } from './components/Announcements.js';
import { api } from './api.js';
const html = htm.bind(h);
function DeviceDetail({ deviceId, devices, onBack }) {
const device = devices[deviceId];
if (!device) {
return html`
<div class="page-header">
<button class="back-btn" onClick=${onBack}>← Back</button>
</div>
<p>Device not found.</p>
`;
}
return html`
<div class="device-detail">
<div class="page-header">
<button class="back-btn" onClick=${onBack}>← Back</button>
<button class="btn-icon" onClick=${() => api.power(deviceId)} title="Power">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M12 2v8" />
<path d="M18.36 6.64a9 9 0 1 1-12.73 0" />
</svg>
</button>
</div>
<${NowPlaying} nowPlaying=${device.status?.nowPlaying} deviceId=${deviceId} presets=${device.status?.presets} />
<${Controls} deviceId=${deviceId} status=${device.status} />
<${Presets} deviceId=${deviceId} status=${device.status} />
<${Sources} deviceId=${deviceId} status=${device.status} />
<${Zone} deviceId=${deviceId} devices=${devices} />
<${Recents} deviceId=${deviceId} />
</div>
`;
}
function App() {
const [devices, setDevices] = useState({});
const [page, setPage] = useState('devices');
const [selectedId, setSelectedId] = useState(null);
const [toast, setToast] = useState(null);
const [version, setVersion] = useState(null);
const [isDiscovering, setIsDiscovering] = useState(false);
const getPageTitle = () => {
if (page === 'devices') return 'Devices';
if (page === 'device') {
const device = devices[selectedId];
const name = device?.info?.name || selectedId || 'Device Detail';
const ip = device?.info?.ip_address;
if (ip) {
return html`
<div class="title-with-subtitle">
<span class="main-title">${name}</span>
<span class="sub-title">${ip}</span>
</div>
`;
}
return name;
}
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';
};
useEffect(() => {
fetch('/api/control/version')
.then(res => res.json())
.then(resp => {
if (resp.success) {
setVersion(resp.data);
}
})
.catch(err => console.error('Failed to fetch version:', err));
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/api/control/ws`);
let reconnectTimer;
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'devices') {
setDevices(msg.data || {});
} else if (msg.type === 'discovery_status') {
console.log('[DEBUG_LOG] discovery_status:', msg.data);
if (msg.data?.isDiscovering !== undefined) {
setIsDiscovering(msg.data.isDiscovering);
} else if (msg.data?.status === 'starting') {
setIsDiscovering(true);
} else if (msg.data?.status === 'completed') {
setIsDiscovering(false);
}
if (msg.data?.status === 'completed') {
showToast(`Found ${msg.data.deviceCount} device(s)`);
}
} else if (msg.type === 'status_update' && msg.deviceId) {
setDevices(prev => {
if (!prev[msg.deviceId]) return prev;
return {
...prev,
[msg.deviceId]: { ...prev[msg.deviceId], status: msg.data },
};
});
}
};
ws.onclose = () => {
reconnectTimer = setTimeout(() => location.reload(), 5000);
};
return () => {
clearTimeout(reconnectTimer);
ws.close();
};
}, []);
function showToast(msg) {
setToast(null);
setTimeout(() => setToast(msg), 10);
setTimeout(() => setToast(null), 3000);
}
const navigate = useCallback((p, id = null) => {
setPage(p);
setSelectedId(id);
}, []);
async function discover() {
showToast('Discovering devices…');
await api.discover();
}
async function removeDevice(id) {
const name = devices[id]?.info?.name || id;
if (!confirm(`Remove "${name}"?\n\nThis clears it from AfterTouch. A device still online may reappear after the next discovery scan.`)) {
return;
}
// Optimistically drop it; the server's devices broadcast reconciles.
setDevices(prev => {
const next = { ...prev };
delete next[id];
return next;
});
try {
const resp = await api.removeDevice(id);
showToast(resp?.success ? `Removed "${name}"` : (resp?.error || 'Failed to remove device'));
} catch (err) {
showToast('Failed to remove device');
}
}
return html`
<div class="app">
<nav class="navbar">
<a class="brand" href="/?chooser" title="AfterTouch home">
<img src="/app/static/img/logo.svg" alt="AfterTouch" class="nav-logo" />
<div class="brand-text">
<span class="brand-name">AfterTouch</span>
<span class="brand-subtitle">Bose SoundTouch Toolkit</span>
</div>
</a>
<div class="page-title">${getPageTitle()}</div>
<div class="nav-links">
<a href="#" class="${page === 'devices' || page === 'device' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('devices'); }}
title="Devices"
>
<img src="/app/static/img/speaker-mono.svg" alt="Devices" class="nav-device-icon" />
</a>
<a href="#" class="${page === 'tunein' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('tunein'); }}
title="TuneIn"
>
<img src="/app/static/img/tunein-mono.svg" alt="TuneIn" class="nav-tunein-icon" />
</a>
<a href="#" class="${page === 'radiobrowser' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('radiobrowser'); }}
title="RadioBrowser"
>
<img src="/app/static/img/radiobrowser-mono.svg" alt="RadioBrowser" class="nav-rb-icon" />
</a>
<a href="#" class="${page === 'playurl' ? 'active' : ''}"
onClick=${(e) => { e.preventDefault(); navigate('playurl'); }}
title="Play URL"
>
<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"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
</svg>
</a>
<span class="nav-separator">|</span>
<button class="btn-icon" onClick=${discover} title="Discover">
<img src="/app/static/img/knob-mono.svg" alt="Discover" class="nav-discover-icon ${isDiscovering ? 'buzzing' : ''}" />
</button>
<a href="https://gesellix.github.io/Bose-SoundTouch/" target="_blank" rel="noopener" title="Documentation">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
</a>
</div>
</nav>
<${Announcements} />
<main class="main-content">
${page === 'devices' ? html`
<${DeviceList}
key="device-list"
devices=${devices}
isDiscovering=${isDiscovering}
onSelect=${(id) => navigate('device', id)}
onDiscover=${discover}
onRemove=${removeDevice}
/>
` : page === 'device' ? html`
<${DeviceDetail}
key="device-detail"
deviceId=${selectedId}
devices=${devices}
onBack=${() => navigate('devices')}
/>
` : page === 'tunein' ? html`
<${TuneInBrowser} key="tunein-browser" devices=${devices} />
` : page === 'radiobrowser' ? html`
<${RadioBrowser} key="radiobrowser-browser" devices=${devices} />
` : page === 'playurl' ? html`
<${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>
${version ? html`
<footer id="footer" key="footer">
<span>
AfterTouch <a href="${version.release_url || version.repo_url}" target="_blank">${version.version}</a>
${version.commit && version.commit !== 'unknown' ? html`
${' ('}<a href="${version.commit_url}" target="_blank">${version.commit.substring(0, 7)}</a>${')'}
` : null}
${version.date && version.date !== 'unknown' ? html` • ${version.date}` : null}
</span>
</footer>
` : null}
${toast ? html`<div class="toast" key="toast">${toast}</div>` : null}
</div>
`;
}
render(html`<${App} />`, document.getElementById('app'));