Files
Sonos-Web/app/static/app.js
T

106 lines
7.1 KiB
JavaScript
Raw 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 $ = 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);