106 lines
7.1 KiB
JavaScript
106 lines
7.1 KiB
JavaScript
let zones = [], source = 'favorites';
|
||
let zoneRequest = 0, zoneController = null, musicRequest = 0, musicController = null;
|
||
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); }
|
||
}
|
||
function option(value, text) { const item = el('option', null, text); item.value = value; return item; }
|
||
function stateName(state) { return ({PLAYING:'WIEDERGABE', PAUSED_PLAYBACK:'PAUSIERT', STOPPED:'GESTOPPT', TRANSITIONING:'WECHSEL', UNAVAILABLE:'NICHT ERREICHBAR'})[state] || state; }
|
||
|
||
function render() {
|
||
const root = $('#zones'); root.replaceChildren();
|
||
for (const zone of zones) {
|
||
const card = el('article', 'zone'), head = el('div', 'zone-head'), names = el('div');
|
||
names.append(el('h2', null, zone.name), el('small', 'muted', zone.ip));
|
||
head.append(names, el('span', 'state', stateName(zone.state)));
|
||
const track = el('p', 'track', zone.error || (zone.track.title ? `${zone.track.title}${zone.track.artist ? ' · ' + zone.track.artist : ''}` : 'Kein aktueller Titel'));
|
||
const controls = el('div', 'controls');
|
||
for (const [icon, label, action] of [['▶','Abspielen','play'], ['Ⅱ','Pause','pause'], ['■','Stopp','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 leave = el('button', 'danger', 'Aus Gruppe lösen'); leave.hidden = zone.memberUids.length < 2;
|
||
leave.onclick = () => act(`/api/groups/${encodeURIComponent(zone.uid)}`, 'DELETE'); card.append(head, track, controls, volume, leave); 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);
|