137 lines
8.4 KiB
JavaScript
137 lines
8.4 KiB
JavaScript
let zones = [], source = 'favorites';
|
||
let zoneRequest = 0, zoneController = null, musicRequest = 0, musicController = null;
|
||
const pendingEq = new Set();
|
||
const $ = selector => document.querySelector(selector);
|
||
const el = (tag, cls, text) => { const node = document.createElement(tag); if (cls) node.className = cls; if (text !== undefined) node.textContent = text; return node; };
|
||
|
||
async function api(url, options = {}) {
|
||
const ownController = options.signal ? null : new AbortController();
|
||
const signal = options.signal || ownController.signal;
|
||
const timer = ownController ? setTimeout(() => ownController.abort(), 12000) : null;
|
||
try {
|
||
const response = await fetch(url, {...options, signal, headers: {'Content-Type': 'application/json', ...(options.headers || {})}});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(data.message || `HTTP ${response.status}`);
|
||
return data;
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') throw new Error('Die Anfrage hat zu lange gedauert.');
|
||
throw error;
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
}
|
||
function notice(message = '') { $('#notice').textContent = message; }
|
||
async function act(url, method = 'POST', data) {
|
||
try { notice(); await api(url, {method, body: data === undefined ? undefined : JSON.stringify(data)}); await load(false); }
|
||
catch (error) { notice(error.message); }
|
||
}
|
||
async function setEq(uid, control, value, input) {
|
||
const key = `${uid}:${control}`;
|
||
if (pendingEq.has(key)) return;
|
||
pendingEq.add(key); input.disabled = true;
|
||
try {
|
||
notice();
|
||
await api(`/api/zones/${encodeURIComponent(uid)}/eq`, {method:'PUT', body:JSON.stringify({control, value})});
|
||
pendingEq.delete(key);
|
||
await load(false);
|
||
} catch (error) {
|
||
pendingEq.delete(key);
|
||
await load(false);
|
||
notice(error.message);
|
||
}
|
||
}
|
||
function option(value, text) { const item = el('option', null, text); item.value = value; return item; }
|
||
function render() {
|
||
const root = $('#zones'); root.replaceChildren();
|
||
for (const zone of zones) {
|
||
const card = el('article', 'zone');
|
||
const name = el('h2', null, zone.name);
|
||
const trackText = zone.state === 'PLAYING' && zone.track.title
|
||
? `${zone.track.title}${zone.track.artist ? ' · ' + zone.track.artist : ''}`
|
||
: '';
|
||
const track = el('p', 'track', zone.error || trackText);
|
||
const controls = el('div', 'controls');
|
||
for (const [icon, label, action] of [['▶','Play','play'], ['■','Stop','stop']]) {
|
||
const button = el('button', null, `${icon} ${label}`);
|
||
button.disabled = Boolean(zone.error);
|
||
button.onclick = () => act(`/api/zones/${encodeURIComponent(zone.uid)}/transport`, 'POST', {action}); controls.append(button);
|
||
}
|
||
const volume = el('label', null, `Lautstärke: ${zone.volume ?? '–'}`), slider = el('input');
|
||
slider.type = 'range'; slider.min = 0; slider.max = 100; slider.value = zone.volume ?? 0; slider.disabled = zone.volume === null;
|
||
slider.oninput = () => volume.firstChild.textContent = `Lautstärke: ${slider.value}`;
|
||
slider.onchange = () => act(`/api/zones/${encodeURIComponent(zone.uid)}/volume`, 'PUT', {volume:Number(slider.value)}); volume.append(slider);
|
||
const eq = el('div', 'eq-controls');
|
||
for (const [control, label, value] of [['bass', 'Bass', zone.bass], ['treble', 'Höhen', zone.treble]]) {
|
||
const field = el('label', null, `${label}: ${value ?? '–'}`), range = el('input');
|
||
range.type = 'range'; range.min = -10; range.max = 10; range.value = value ?? 0; range.disabled = value === null || pendingEq.has(`${zone.uid}:${control}`);
|
||
range.oninput = () => field.firstChild.textContent = `${label}: ${Number(range.value) > 0 ? '+' : ''}${range.value}`;
|
||
range.onchange = () => setEq(zone.uid, control, Number(range.value), range);
|
||
field.append(range); eq.append(field);
|
||
}
|
||
const loudness = el('button', `loudness${zone.loudness ? ' active' : ''}`, `Loudness: ${zone.loudness ? 'Ein' : 'Aus'}`);
|
||
loudness.dataset.value = String(zone.loudness);
|
||
loudness.disabled = zone.loudness === null || pendingEq.has(`${zone.uid}:loudness`);
|
||
loudness.onclick = () => setEq(zone.uid, 'loudness', loudness.dataset.value !== 'true', loudness);
|
||
const leave = el('button', 'danger', 'Aus Gruppe lösen'); leave.hidden = zone.memberUids.length < 2;
|
||
leave.onclick = () => act(`/api/groups/${encodeURIComponent(zone.uid)}`, 'DELETE');
|
||
const zoneActions = el('div', 'zone-actions'); zoneActions.append(loudness, leave);
|
||
card.append(name, track, controls, volume, eq, zoneActions); root.append(card);
|
||
}
|
||
renderSelects(); loadMusic();
|
||
}
|
||
|
||
function renderSelects() {
|
||
const oldMusic = $('#music-zone').value, oldCoordinator = $('#coordinator').value;
|
||
const selectedMembers = new Set([...document.querySelectorAll('#members input:checked')].map(input => input.value));
|
||
for (const id of ['#music-zone', '#coordinator']) $(id).replaceChildren(...zones.map(zone => option(zone.uid, zone.name)));
|
||
if (zones.some(zone => zone.uid === oldMusic)) $('#music-zone').value = oldMusic;
|
||
if (zones.some(zone => zone.uid === oldCoordinator)) $('#coordinator').value = oldCoordinator;
|
||
const members = $('#members');
|
||
members.replaceChildren(...zones.filter(zone => zone.uid !== $('#coordinator').value).map(zone => {
|
||
const label = el('label'), checkbox = el('input'); checkbox.type = 'checkbox'; checkbox.value = zone.uid;
|
||
checkbox.checked = selectedMembers.has(zone.uid); label.append(checkbox, document.createTextNode(zone.name)); return label;
|
||
}));
|
||
}
|
||
|
||
async function load(refresh = true) {
|
||
const request = ++zoneRequest;
|
||
if (zoneController) zoneController.abort();
|
||
zoneController = new AbortController();
|
||
try {
|
||
notice(); const data = await api(`/api/zones?refresh=${refresh ? 1 : 0}`, {signal: zoneController.signal});
|
||
if (request !== zoneRequest) return;
|
||
zones = data.zones; render();
|
||
if (!zones.length) notice('Keine Sonos-Räume gefunden. Prüfe Netzwerk und host network.');
|
||
} catch (error) {
|
||
if (request !== zoneRequest) return;
|
||
notice(error.message); $('#zones').replaceChildren(el('p', 'muted', 'Keine Daten verfügbar.'));
|
||
}
|
||
}
|
||
|
||
async function loadMusic() {
|
||
const request = ++musicRequest;
|
||
if (musicController) musicController.abort();
|
||
musicController = new AbortController();
|
||
const root = $('#music-content'), uid = $('#music-zone').value, requestedSource = source;
|
||
if (!uid) { root.replaceChildren(el('p', 'muted', 'Kein Raum verfügbar.')); return; }
|
||
if (source === 'stream') {
|
||
const row = el('div', 'list-row'), input = el('input'); input.type = 'url'; input.placeholder = 'https://radio.example/stream.mp3'; input.style.flex = '1';
|
||
const button = el('button', null, 'Stream abspielen'); button.onclick = () => act(`/api/zones/${encodeURIComponent(uid)}/play/stream`, 'POST', {url:input.value});
|
||
row.append(input, button); root.replaceChildren(row); return;
|
||
}
|
||
root.replaceChildren(el('p', 'muted', 'Wird geladen …'));
|
||
try {
|
||
const data = await api(`/api/zones/${encodeURIComponent(uid)}/${requestedSource}`, {signal:musicController.signal});
|
||
if (request !== musicRequest || uid !== $('#music-zone').value || requestedSource !== source) return;
|
||
const items = data[requestedSource]; root.replaceChildren();
|
||
items.forEach(item => { const row = el('div', 'list-row'), text = el('span', null, item.title + (item.creator ? ' · ' + item.creator : '')), button = el('button', null, 'Abspielen');
|
||
button.onclick = () => act(`/api/zones/${encodeURIComponent(uid)}/play/${requestedSource === 'favorites' ? 'favorite' : 'queue'}`, 'POST', {index:item.index}); row.append(text, button); root.append(row); });
|
||
if (!items.length) root.append(el('p', 'muted', requestedSource === 'favorites' ? 'Keine Favoriten vorhanden.' : 'Warteschlange ist leer.'));
|
||
} catch (error) { if (request === musicRequest) root.replaceChildren(el('p', 'muted', error.message)); }
|
||
}
|
||
|
||
$('#refresh').onclick = () => load(true); $('#coordinator').onchange = renderSelects; $('#music-zone').onchange = loadMusic;
|
||
$('#group').onclick = () => act('/api/groups', 'POST', {coordinatorUid:$('#coordinator').value, memberUids:[...document.querySelectorAll('#members input:checked')].map(input => input.value)});
|
||
document.querySelectorAll('.tabs button').forEach(button => button.onclick = () => { source = button.dataset.source; document.querySelectorAll('.tabs button').forEach(item => item.classList.toggle('active', item === button)); loadMusic(); });
|
||
load(); setInterval(() => load(false), 10000);
|