Files
2026-09-11 22:28:26 +00:00

137 lines
8.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);