Files
Bose-SoundTouch/pkg/service/soundtouchweb/static/js/app.js
T
Tobias Gesellchen b95bdae751 feat: Add RadioBrowser integration alongside TuneIn support
- Refactors BMX service to support multiple radio providers
- Adds RadioBrowser.com API integration with search and browse
- Splits TuneIn logic into separate module for better organization
- Adds new web UI components for radio station discovery
- Includes new SVG icons for RadioBrowser branding
2026-05-18 22:34:26 +02:00

220 lines
8.8 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 { 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">⏻</button>
</div>
<${NowPlaying} nowPlaying=${device.status?.nowPlaying} />
<${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';
return 'AfterTouch';
};
useEffect(() => {
fetch('/api/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}/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();
}
return html`
<div class="app">
<nav class="navbar">
<a class="brand" href="#" onClick=${(e) => { e.preventDefault(); navigate('devices'); }}>
<img src="/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="/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="/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="/static/img/radiobrowser-mono.svg" alt="RadioBrowser" class="nav-rb-icon" />
</a>
<span class="nav-separator">|</span>
<button class="btn-icon" onClick=${discover} title="Discover">
<img src="/static/img/knob-mono.svg" alt="Discover" class="nav-discover-icon ${isDiscovering ? 'buzzing' : ''}" />
</button>
</div>
</nav>
<main class="main-content">
${page === 'devices' ? html`
<${DeviceList}
key="device-list"
devices=${devices}
isDiscovering=${isDiscovering}
onSelect=${(id) => navigate('device', id)}
onDiscover=${discover}
/>
` : 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} />
` : 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'));