mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
1099 lines
40 KiB
JavaScript
1099 lines
40 KiB
JavaScript
// SoundTouch Web UI - Application JavaScript
|
|
// Single Page Application functionality with WebSocket real-time updates
|
|
|
|
// Global variables
|
|
let ws = null;
|
|
let reconnectInterval = null;
|
|
let reconnectAttempts = 0;
|
|
let maxReconnectAttempts = 5;
|
|
let devices = {};
|
|
let currentDeviceId = null;
|
|
let tuneInNavStack = [];
|
|
let tuneInPendingPlay = null;
|
|
|
|
// Page navigation
|
|
function showPage(pageId) {
|
|
document
|
|
.querySelectorAll(".page")
|
|
.forEach((page) => page.classList.remove("active"));
|
|
document.getElementById(pageId + "-page").classList.add("active");
|
|
|
|
if (pageId === "devices") {
|
|
currentDeviceId = null;
|
|
loadDevices();
|
|
} else if (pageId === "tunein" && tuneInNavStack.length === 0) {
|
|
tuneInBrowse();
|
|
}
|
|
}
|
|
|
|
// ── TuneIn Browse ──────────────────────────────────────────────────────────────
|
|
|
|
function tuneInBrowse() {
|
|
tuneInNavStack = [{ fetchUrl: "/api/tunein/navigate", label: "TuneIn" }];
|
|
tuneInRenderBreadcrumb();
|
|
tuneInFetchAndRender("/api/tunein/navigate");
|
|
}
|
|
|
|
function tuneInSearch(query) {
|
|
if (!query || !query.trim()) return;
|
|
const q = query.trim();
|
|
document.getElementById("tunein-search-input").value = q;
|
|
const url = "/api/tunein/search?q=" + encodeURIComponent(q);
|
|
tuneInNavStack = [
|
|
{ fetchUrl: "/api/tunein/navigate", label: "TuneIn" },
|
|
{ fetchUrl: url, label: "Search: " + q },
|
|
];
|
|
tuneInRenderBreadcrumb();
|
|
tuneInFetchAndRender(url);
|
|
}
|
|
|
|
function tuneInNavigate(navPath, label) {
|
|
const url = "/api/tunein/navigate/" + navPath;
|
|
tuneInNavStack.push({ fetchUrl: url, label: label || "Browse" });
|
|
tuneInRenderBreadcrumb();
|
|
tuneInFetchAndRender(url);
|
|
}
|
|
|
|
function tuneInNavTo(index) {
|
|
tuneInNavStack = tuneInNavStack.slice(0, index + 1);
|
|
tuneInRenderBreadcrumb();
|
|
tuneInFetchAndRender(tuneInNavStack[tuneInNavStack.length - 1].fetchUrl);
|
|
}
|
|
|
|
function tuneInRenderBreadcrumb() {
|
|
const nav = document.getElementById("tunein-breadcrumb");
|
|
if (tuneInNavStack.length <= 1) {
|
|
nav.style.display = "none";
|
|
return;
|
|
}
|
|
nav.style.display = "";
|
|
const items = tuneInNavStack
|
|
.map((entry, i) => {
|
|
if (i === tuneInNavStack.length - 1) {
|
|
return `<li class="breadcrumb-item active" aria-current="page">${escapeHtml(entry.label)}</li>`;
|
|
}
|
|
return `<li class="breadcrumb-item"><a href="#" onclick="tuneInNavTo(${i}); return false;">${escapeHtml(entry.label)}</a></li>`;
|
|
})
|
|
.join("");
|
|
nav.innerHTML = `<ol class="breadcrumb mb-0">${items}</ol>`;
|
|
}
|
|
|
|
function tuneInFetchAndRender(url) {
|
|
const el = document.getElementById("tunein-results");
|
|
el.innerHTML = '<div class="loading-spinner mx-auto mt-4"></div>';
|
|
fetch(url)
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
if (data.success) {
|
|
renderTuneInResponse(data.data);
|
|
} else {
|
|
el.innerHTML = `<div class="alert alert-danger mt-3">${escapeHtml(data.error || "Failed to load TuneIn content")}</div>`;
|
|
}
|
|
})
|
|
.catch(() => {
|
|
el.innerHTML =
|
|
'<div class="alert alert-danger mt-3">Failed to load TuneIn content. Check your connection.</div>';
|
|
});
|
|
}
|
|
|
|
function renderTuneInResponse(data) {
|
|
const el = document.getElementById("tunein-results");
|
|
if (!data || !data.bmx_sections || data.bmx_sections.length === 0) {
|
|
el.innerHTML =
|
|
'<div class="text-center text-muted py-5"><i class="bi bi-music-note display-4"></i><p class="mt-2">No results found</p></div>';
|
|
return;
|
|
}
|
|
el.innerHTML = data.bmx_sections.map(renderTuneInSection).join("");
|
|
}
|
|
|
|
function renderTuneInSection(section) {
|
|
const layout = section.layout || "list";
|
|
const items = section.items || [];
|
|
if (items.length === 0) return "";
|
|
|
|
const titleHtml = section.name
|
|
? `<h5 class="tunein-section-title">${escapeHtml(section.name)}</h5>`
|
|
: "";
|
|
|
|
let itemsHtml;
|
|
if (layout === "ribbon") {
|
|
itemsHtml = `<div class="tunein-ribbon">${items.map((item) => renderTuneInItem(item, "ribbon")).join("")}</div>`;
|
|
} else if (layout === "hero") {
|
|
itemsHtml = `<div class="tunein-hero">${items.map((item) => renderTuneInItem(item, "hero")).join("")}</div>`;
|
|
} else if (layout === "responsiveGrid") {
|
|
itemsHtml = `<div class="tunein-grid">${items.map((item) => renderTuneInItem(item, "grid")).join("")}</div>`;
|
|
} else {
|
|
itemsHtml = `<div class="tunein-list">${items.map((item) => renderTuneInItem(item, "list")).join("")}</div>`;
|
|
}
|
|
|
|
return `<div class="tunein-section">${titleHtml}${itemsHtml}</div>`;
|
|
}
|
|
|
|
function tuneInNavPath(item) {
|
|
const href = item._links?.bmx_navigate?.href;
|
|
return href ? href.replace(/^\/v1\/navigate\/?/, "") : null;
|
|
}
|
|
|
|
function renderTuneInItem(item, layout) {
|
|
const navPath = tuneInNavPath(item);
|
|
const isNavigable = !!navPath;
|
|
const playHref = item._links?.bmx_playback?.href;
|
|
const playType = item._links?.bmx_playback?.type || "stationurl";
|
|
const isPlayable = !!playHref;
|
|
const name = item.name || "";
|
|
const subtitle = item.subtitle || "";
|
|
const imageUrl = item.imageUrl || "";
|
|
|
|
const navAttrs = isNavigable
|
|
? `data-nav-path="${escapeHtml(navPath)}" data-nav-label="${escapeHtml(name)}" role="button" tabindex="0"`
|
|
: "";
|
|
const navClass = isNavigable ? " tunein-nav-item" : "";
|
|
|
|
const playBtn = isPlayable
|
|
? `<button class="tunein-play-btn" data-play-location="${escapeHtml(playHref)}" data-play-name="${escapeHtml(name)}" data-play-type="${escapeHtml(playType)}" data-play-art="${escapeHtml(imageUrl)}" title="Play ${escapeHtml(name)}" aria-label="Play ${escapeHtml(name)}"><i class="bi bi-play-fill"></i></button>`
|
|
: "";
|
|
|
|
const imgHtml = imageUrl
|
|
? `<img src="${escapeHtml(imageUrl)}" alt="" class="tunein-item-image" loading="lazy" onerror="this.style.display='none'">`
|
|
: `<div class="tunein-item-image tunein-item-placeholder"><i class="bi bi-music-note-beamed"></i></div>`;
|
|
|
|
if (layout === "ribbon") {
|
|
return `<div class="tunein-ribbon-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-item-label">${escapeHtml(name)}</div>${playBtn}</div>`;
|
|
}
|
|
|
|
if (layout === "hero") {
|
|
return `<div class="tunein-hero-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-hero-overlay"><div class="tunein-hero-name">${escapeHtml(name)}</div>${subtitle ? `<div class="tunein-hero-subtitle">${escapeHtml(subtitle)}</div>` : ""}</div>${playBtn ? `<div class="tunein-hero-play">${playBtn}</div>` : ""}</div>`;
|
|
}
|
|
|
|
if (layout === "grid") {
|
|
return `<div class="tunein-grid-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-item-label">${escapeHtml(name)}</div>${subtitle ? `<div class="tunein-item-subtitle">${escapeHtml(subtitle)}</div>` : ""}${playBtn ? `<div class="mt-1 text-center">${playBtn}</div>` : ""}</div>`;
|
|
}
|
|
|
|
// list / shortList / default
|
|
return `<div class="tunein-list-item${navClass}" ${navAttrs}>${imgHtml}<div class="tunein-item-info"><div class="tunein-item-name">${escapeHtml(name)}</div>${subtitle ? `<div class="tunein-item-subtitle">${escapeHtml(subtitle)}</div>` : ""}</div>${isNavigable ? '<i class="bi bi-chevron-right tunein-item-chevron ms-auto"></i>' : ""}${playBtn}</div>`;
|
|
}
|
|
|
|
function tuneInPlayClick(location, name, type, art) {
|
|
const deviceIds = Object.keys(devices);
|
|
if (deviceIds.length === 0) {
|
|
showToast("No Devices", "No SoundTouch devices found. Try discovering devices first.", "warning");
|
|
return;
|
|
}
|
|
if (deviceIds.length === 1) {
|
|
tuneInPlay(deviceIds[0], location, name, type, art);
|
|
} else {
|
|
tuneInShowDevicePicker(location, name, type, art);
|
|
}
|
|
}
|
|
|
|
function tuneInPlay(deviceId, location, name, type, art) {
|
|
fetch(`/api/tunein/play/${deviceId}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ location, name, type, containerArt: art }),
|
|
})
|
|
.then((r) => r.json())
|
|
.then((data) => {
|
|
if (data.success) {
|
|
showToast("Now Playing", data.data?.message || name, "success");
|
|
} else {
|
|
showToast("Playback Failed", data.error || "Could not play station", "error");
|
|
}
|
|
})
|
|
.catch(() => showToast("Playback Failed", "Could not reach device", "error"));
|
|
}
|
|
|
|
function tuneInShowDevicePicker(location, name, type, art) {
|
|
tuneInPendingPlay = { location, name, type, art };
|
|
const list = document.getElementById("devicePickerList");
|
|
list.innerHTML = Object.entries(devices)
|
|
.map(
|
|
([id, dev]) =>
|
|
`<button class="btn btn-outline-secondary w-100 text-start mb-1" data-device-id="${escapeHtml(id)}" onclick="tuneInPlayOnDevice('${escapeHtml(id)}')"><i class="bi bi-speaker me-2"></i>${escapeHtml(dev.info?.Name || id)}</button>`,
|
|
)
|
|
.join("");
|
|
new bootstrap.Modal(document.getElementById("devicePickerModal")).show();
|
|
}
|
|
|
|
function tuneInPlayOnDevice(deviceId) {
|
|
if (!tuneInPendingPlay) return;
|
|
const { location, name, type, art } = tuneInPendingPlay;
|
|
tuneInPendingPlay = null;
|
|
bootstrap.Modal.getInstance(document.getElementById("devicePickerModal")).hide();
|
|
tuneInPlay(deviceId, location, name, type, art);
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s)
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
// WebSocket connection management
|
|
function connectWebSocket() {
|
|
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsUrl = `${protocol}//${location.host}/ws`;
|
|
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = function () {
|
|
console.log("WebSocket connected");
|
|
reconnectAttempts = 0;
|
|
clearInterval(reconnectInterval);
|
|
hideConnectionError();
|
|
};
|
|
|
|
ws.onmessage = function (event) {
|
|
const data = JSON.parse(event.data);
|
|
handleWebSocketMessage(data);
|
|
};
|
|
|
|
ws.onclose = function () {
|
|
console.log("WebSocket disconnected");
|
|
if (reconnectAttempts < maxReconnectAttempts) {
|
|
showConnectionError();
|
|
scheduleReconnect();
|
|
} else {
|
|
showMaxReconnectError();
|
|
}
|
|
};
|
|
|
|
ws.onerror = function (error) {
|
|
console.error("WebSocket error:", error);
|
|
};
|
|
}
|
|
|
|
function scheduleReconnect() {
|
|
reconnectAttempts++;
|
|
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
|
|
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), 30000);
|
|
console.log(
|
|
`Reconnect attempt ${reconnectAttempts}/${maxReconnectAttempts} in ${delay}ms`,
|
|
);
|
|
|
|
clearInterval(reconnectInterval);
|
|
reconnectInterval = setTimeout(connectWebSocket, delay);
|
|
}
|
|
|
|
function handleWebSocketMessage(data) {
|
|
switch (data.type) {
|
|
case "devices":
|
|
devices = data.data;
|
|
renderDeviceList();
|
|
break;
|
|
case "status_update":
|
|
if (Object.hasOwn(devices, data.deviceId)) {
|
|
devices[data.deviceId].status = data.data;
|
|
if (currentDeviceId === data.deviceId) {
|
|
updateDeviceStatus(data.data);
|
|
} else {
|
|
renderDeviceList();
|
|
}
|
|
}
|
|
break;
|
|
case "discovery_status":
|
|
handleDiscoveryStatus(data.data);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Device management functions
|
|
function loadDevices() {
|
|
document.getElementById("devices-loading").style.display = "block";
|
|
document.getElementById("devices-list").style.display = "none";
|
|
document.getElementById("no-devices").style.display = "none";
|
|
|
|
fetch("/api/devices")
|
|
.then((response) => response.json())
|
|
.then((data) => {
|
|
if (data.success) {
|
|
devices = data.data;
|
|
// Fetch power status for each device after getting device list
|
|
fetchPowerStatesForAllDevices();
|
|
renderDeviceList();
|
|
} else {
|
|
showToast(
|
|
"Error",
|
|
data.error || "Failed to load devices",
|
|
"error",
|
|
);
|
|
showNoDevices();
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Error loading devices:", error);
|
|
showToast("Error", "Failed to load devices", "error");
|
|
showNoDevices();
|
|
});
|
|
}
|
|
|
|
function fetchPowerStatesForAllDevices() {
|
|
Object.keys(devices).forEach((deviceId) => {
|
|
fetchDevicePowerStatus(deviceId);
|
|
});
|
|
}
|
|
|
|
function fetchDevicePowerStatus(deviceId) {
|
|
fetch(`/api/device-power-status/${deviceId}`)
|
|
.then((response) => response.json())
|
|
.then((data) => {
|
|
if (data.success && devices[deviceId]) {
|
|
// Update device status with power information
|
|
if (!devices[deviceId].status) {
|
|
devices[deviceId].status = {};
|
|
}
|
|
if (!devices[deviceId].status.nowPlaying) {
|
|
devices[deviceId].status.nowPlaying = {};
|
|
}
|
|
devices[deviceId].status.nowPlaying.source = data.data.source;
|
|
devices[deviceId].status.isConnected = true;
|
|
|
|
// Re-render device list with updated power status
|
|
renderDeviceList();
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.log(`Failed to get power status for ${deviceId}:`, error);
|
|
});
|
|
}
|
|
|
|
function renderDeviceList() {
|
|
const devicesContainer = document.getElementById("devices-list");
|
|
const loadingSpinner = document.getElementById("devices-loading");
|
|
const noDevicesMsg = document.getElementById("no-devices");
|
|
|
|
loadingSpinner.style.display = "none";
|
|
|
|
if (!devices || Object.keys(devices).length === 0) {
|
|
showNoDevices();
|
|
return;
|
|
}
|
|
|
|
noDevicesMsg.style.display = "none";
|
|
devicesContainer.style.display = "block";
|
|
|
|
let html = "";
|
|
for (const [deviceId, device] of Object.entries(devices)) {
|
|
const isConnected = device.status?.isConnected || false;
|
|
const nowPlaying = device.status?.nowPlaying;
|
|
const isPoweredOn =
|
|
isConnected && nowPlaying && nowPlaying.source !== "STANDBY";
|
|
|
|
const statusClass = isConnected
|
|
? isPoweredOn
|
|
? "connected"
|
|
: "standby"
|
|
: "disconnected";
|
|
const statusText = isConnected
|
|
? isPoweredOn
|
|
? "On"
|
|
: "Standby"
|
|
: "Disconnected";
|
|
const powerIcon = isPoweredOn
|
|
? "bi-power text-success"
|
|
: "bi-power text-muted";
|
|
|
|
const lastSeen = device.lastSeen
|
|
? formatTimeAgo(new Date(device.lastSeen))
|
|
: "Never";
|
|
|
|
html += `
|
|
<div class="col-md-6 col-lg-4 mb-4">
|
|
<div class="card device-card ${statusClass}">
|
|
<div class="card-body">
|
|
<h5 class="card-title d-flex align-items-center justify-content-between">
|
|
<span>
|
|
<span class="status-indicator status-${isConnected ? "connected" : "disconnected"}"></span>
|
|
${escapeHtml(device.info?.Name || "Unknown Device")}
|
|
</span>
|
|
<i class="bi ${powerIcon}" title="Power Status"></i>
|
|
</h5>
|
|
<p class="card-text">
|
|
<strong>Type:</strong> ${escapeHtml(device.info?.Type || "Unknown")}<br>
|
|
<strong>Status:</strong> ${statusText}<br>
|
|
<strong>Last Seen:</strong> ${lastSeen}
|
|
</p>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-primary flex-grow-1" onclick="showDevice('${deviceId}')">
|
|
<i class="bi bi-gear"></i>
|
|
Control
|
|
</button>
|
|
<button class="btn ${isPoweredOn ? "btn-outline-danger" : "btn-success"} power-btn" onclick="toggleDevicePowerFromCard('${deviceId}', this)" title="${isPoweredOn ? "Power Off" : "Power On"}">
|
|
<i class="bi bi-power"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
devicesContainer.innerHTML = html;
|
|
}
|
|
|
|
function showNoDevices() {
|
|
document.getElementById("devices-loading").style.display = "none";
|
|
document.getElementById("devices-list").style.display = "none";
|
|
document.getElementById("no-devices").style.display = "block";
|
|
}
|
|
|
|
function showDevice(deviceId) {
|
|
currentDeviceId = deviceId;
|
|
const device = devices[deviceId];
|
|
|
|
if (!device) {
|
|
showToast("Error", "Device not found", "error");
|
|
return;
|
|
}
|
|
|
|
renderDeviceControl(deviceId, device);
|
|
showPage("device");
|
|
}
|
|
|
|
function renderDeviceControl(deviceId, device) {
|
|
const container = document.getElementById("device-content");
|
|
const info = device.info || {};
|
|
const status = device.status || {};
|
|
const nowPlaying = status.nowPlaying || {};
|
|
const volume = status.volume || {};
|
|
const bass = status.bass || {};
|
|
|
|
const isPoweredOn =
|
|
status.isConnected && nowPlaying && nowPlaying.Source !== "STANDBY";
|
|
const powerButtonClass = isPoweredOn ? "btn-outline-danger" : "btn-success";
|
|
const powerButtonTitle = isPoweredOn ? "Power Off" : "Power On";
|
|
|
|
const html = `
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h2 class="mb-1">${escapeHtml(info.Name || "Unknown Device")}</h2>
|
|
<p class="text-muted mb-0">${escapeHtml(info.Type || "Unknown Type")}</p>
|
|
</div>
|
|
<button class="btn ${powerButtonClass} btn-sm device-header-power" onclick="toggleDevicePower('${deviceId}')" title="${powerButtonTitle}">
|
|
<i class="bi bi-power"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Now Playing Section -->
|
|
<div class="card now-playing mb-4">
|
|
<div class="card-body">
|
|
<div class="row align-items-center">
|
|
<div class="col-auto">
|
|
<div class="artwork bg-secondary d-flex align-items-center justify-content-center">
|
|
<i class="bi bi-music-note-beamed text-white fs-2"></i>
|
|
</div>
|
|
</div>
|
|
<div class="col">
|
|
<h6 class="mb-1" id="track-title">${escapeHtml(nowPlaying.Track || nowPlaying.track || "No track playing")}</h6>
|
|
<p class="mb-1" id="track-artist">${escapeHtml(nowPlaying.Artist || nowPlaying.artist || "Unknown artist")}</p>
|
|
<small id="track-album">${escapeHtml(nowPlaying.Album || nowPlaying.album || "Unknown album")}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Transport Controls -->
|
|
<div class="control-panel">
|
|
<h5>Transport Controls</h5>
|
|
<div class="d-flex justify-content-center gap-3 mb-3">
|
|
<button class="btn btn-outline-primary" onclick="controlDevice('${deviceId}', 'previous')">
|
|
<i class="bi bi-skip-backward"></i>
|
|
</button>
|
|
<button class="btn btn-primary btn-lg" onclick="controlDevice('${deviceId}', 'play')">
|
|
<i class="bi bi-play"></i>
|
|
</button>
|
|
<button class="btn btn-outline-primary" onclick="controlDevice('${deviceId}', 'pause')">
|
|
<i class="bi bi-pause"></i>
|
|
</button>
|
|
<button class="btn btn-outline-primary" onclick="controlDevice('${deviceId}', 'stop')">
|
|
<i class="bi bi-stop"></i>
|
|
</button>
|
|
<button class="btn btn-outline-primary" onclick="controlDevice('${deviceId}', 'next')">
|
|
<i class="bi bi-skip-forward"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Volume Control -->
|
|
<div class="control-panel">
|
|
<h5>Volume Control</h5>
|
|
<div class="row align-items-center">
|
|
<div class="col">
|
|
<input type="range" class="form-range volume-slider"
|
|
min="0" max="100" value="${volume.ActualVolume || volume.TargetVolume || 50}"
|
|
id="volume-slider-${deviceId.replace(/\./g, "-")}"
|
|
oninput="updateVolume('${deviceId}', this.value)">
|
|
</div>
|
|
<div class="col-auto">
|
|
<button class="btn btn-outline-secondary" onclick="controlDevice('${deviceId}', 'mute')">
|
|
<i class="bi bi-volume-mute"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="text-center mt-2">
|
|
<span id="volume-display">${volume.ActualVolume || volume.TargetVolume || 50}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bass Control -->
|
|
<div class="control-panel">
|
|
<h5>Bass Control</h5>
|
|
<div class="row align-items-center">
|
|
<div class="col">
|
|
<input type="range" class="form-range bass-slider"
|
|
min="-9" max="9" value="${bass.ActualBass || bass.TargetBass || 0}"
|
|
id="bass-slider-${deviceId.replace(/\./g, "-")}"
|
|
oninput="updateBass('${deviceId}', this.value)">
|
|
</div>
|
|
</div>
|
|
<div class="text-center mt-2">
|
|
<span id="bass-display">${bass.ActualBass || bass.TargetBass || 0}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Presets -->
|
|
<div class="control-panel">
|
|
<h5>Presets</h5>
|
|
<div class="preset-grid" id="presets-grid">
|
|
${[1, 2, 3, 4, 5, 6]
|
|
.map(
|
|
(i) => `
|
|
<button class="preset-btn" onclick="controlDevice('${deviceId}', 'preset', null, '?id=${i}')">
|
|
<i class="bi bi-bookmark"></i>
|
|
<span>Preset ${i}</span>
|
|
</button>
|
|
`,
|
|
)
|
|
.join("")}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sources -->
|
|
<div class="control-panel">
|
|
<h5>Sources</h5>
|
|
<div class="source-grid">
|
|
<button class="source-btn" onclick="controlDevice('${deviceId}', 'source', null, '?name=SPOTIFY')">
|
|
<i class="bi bi-spotify"></i>
|
|
<span>Spotify</span>
|
|
</button>
|
|
<button class="source-btn" onclick="controlDevice('${deviceId}', 'source', null, '?name=BLUETOOTH')">
|
|
<i class="bi bi-bluetooth"></i>
|
|
<span>Bluetooth</span>
|
|
</button>
|
|
<button class="source-btn" onclick="controlDevice('${deviceId}', 'source', null, '?name=AUX')">
|
|
<i class="bi bi-input-cursor"></i>
|
|
<span>AUX</span>
|
|
</button>
|
|
<button class="source-btn" onclick="controlDevice('${deviceId}', 'source', null, '?name=INTERNET_RADIO')">
|
|
<i class="bi bi-radio"></i>
|
|
<span>Radio</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function updateDeviceStatus(status) {
|
|
// Update volume display if we have volume info
|
|
if (status.volume !== undefined) {
|
|
const volumeSlider = document.getElementById("volume-slider");
|
|
const volumeDisplay = document.getElementById("volume-display");
|
|
if (volumeSlider && volumeDisplay) {
|
|
volumeSlider.value = status.volume;
|
|
volumeDisplay.textContent = status.volume + "%";
|
|
}
|
|
}
|
|
|
|
// Update bass display if we have bass info
|
|
if (status.bass !== undefined) {
|
|
const bassSlider = document.getElementById("bass-slider");
|
|
const bassDisplay = document.getElementById("bass-display");
|
|
if (bassSlider && bassDisplay) {
|
|
bassSlider.value = status.bass;
|
|
bassDisplay.textContent = status.bass;
|
|
}
|
|
}
|
|
|
|
// Update now playing info
|
|
if (status.nowPlaying) {
|
|
const np = status.nowPlaying;
|
|
const titleEl = document.getElementById("track-title");
|
|
const artistEl = document.getElementById("track-artist");
|
|
const albumEl = document.getElementById("track-album");
|
|
|
|
if (titleEl) titleEl.textContent = np.track || "No track playing";
|
|
if (artistEl) artistEl.textContent = np.artist || "Unknown artist";
|
|
if (albumEl) albumEl.textContent = np.album || "Unknown album";
|
|
}
|
|
}
|
|
|
|
// Device control functions
|
|
function updateVolume(deviceId, level) {
|
|
document.getElementById("volume-display").textContent = level + "%";
|
|
// Debounce the actual API call
|
|
clearTimeout(window.volumeTimeout);
|
|
window.volumeTimeout = setTimeout(() => {
|
|
setDeviceVolume(deviceId, parseInt(level));
|
|
}, 300);
|
|
}
|
|
|
|
function updateBass(deviceId, level) {
|
|
document.getElementById("bass-display").textContent = level;
|
|
// Debounce the actual API call
|
|
clearTimeout(window.bassTimeout);
|
|
window.bassTimeout = setTimeout(() => {
|
|
controlDevice(deviceId, "bass", { level: parseInt(level) });
|
|
}, 300);
|
|
}
|
|
|
|
// Enhanced device control functions
|
|
function toggleDevicePower(deviceId) {
|
|
const url = `/api/device-power/${deviceId}`;
|
|
|
|
// Show loading state on button
|
|
const powerBtn = event.target.closest("button");
|
|
const originalContent = powerBtn.innerHTML;
|
|
powerBtn.disabled = true;
|
|
powerBtn.classList.add("power-loading");
|
|
powerBtn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
|
|
|
fetch(url, { method: "POST" })
|
|
.then((response) => response.json())
|
|
.then((result) => {
|
|
if (result.success) {
|
|
showToast("Success", result.data.message, "success");
|
|
// Trigger device power status refresh after power command
|
|
setTimeout(() => {
|
|
fetchDevicePowerStatus(deviceId);
|
|
}, 2000);
|
|
} else {
|
|
showToast(
|
|
"Error",
|
|
result.error || "Power toggle failed",
|
|
"error",
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Power toggle error:", error);
|
|
showToast("Error", "Failed to toggle power", "error");
|
|
})
|
|
.finally(() => {
|
|
// Restore button state
|
|
powerBtn.disabled = false;
|
|
powerBtn.classList.remove("power-loading");
|
|
powerBtn.innerHTML = originalContent;
|
|
});
|
|
}
|
|
|
|
function toggleDevicePowerFromCard(deviceId, buttonElement) {
|
|
const url = `/api/device-power/${deviceId}`;
|
|
|
|
// Show loading state on button
|
|
const originalContent = buttonElement.innerHTML;
|
|
buttonElement.disabled = true;
|
|
buttonElement.classList.add("power-loading");
|
|
buttonElement.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
|
|
|
fetch(url, { method: "POST" })
|
|
.then((response) => response.json())
|
|
.then((result) => {
|
|
if (result.success) {
|
|
showToast("Success", result.data.message, "success");
|
|
// Trigger device power status refresh after power command
|
|
setTimeout(() => {
|
|
fetchDevicePowerStatus(deviceId);
|
|
}, 2000);
|
|
} else {
|
|
showToast(
|
|
"Error",
|
|
result.error || "Power toggle failed",
|
|
"error",
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Power toggle error:", error);
|
|
showToast("Error", "Failed to toggle power", "error");
|
|
})
|
|
.finally(() => {
|
|
// Restore button state
|
|
buttonElement.disabled = false;
|
|
buttonElement.classList.remove("power-loading");
|
|
buttonElement.innerHTML = originalContent;
|
|
});
|
|
}
|
|
|
|
function sendDeviceKey(deviceId, key) {
|
|
const url = `/api/device-key/${deviceId}/${key}`;
|
|
|
|
fetch(url, { method: "POST" })
|
|
.then((response) => response.json())
|
|
.then((result) => {
|
|
if (result.success) {
|
|
showToast("Success", result.data.message, "success");
|
|
} else {
|
|
showToast(
|
|
"Error",
|
|
result.error || "Key command failed",
|
|
"error",
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Key command error:", error);
|
|
showToast("Error", "Failed to send key command", "error");
|
|
});
|
|
}
|
|
|
|
function setDeviceVolume(deviceId, level) {
|
|
const url = `/api/device-volume/${deviceId}/${level}`;
|
|
|
|
fetch(url, { method: "POST" })
|
|
.then((response) => response.json())
|
|
.then((result) => {
|
|
if (result.success) {
|
|
showToast("Success", result.data.message, "success");
|
|
} else {
|
|
showToast(
|
|
"Error",
|
|
result.error || "Volume command failed",
|
|
"error",
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Volume command error:", error);
|
|
showToast("Error", "Failed to set volume", "error");
|
|
});
|
|
}
|
|
|
|
function controlDevice(deviceId, action, data = null, queryParams = "") {
|
|
let url = `/api/control/${deviceId}/${action}${queryParams}`;
|
|
let options = {
|
|
method: action === "volume" || action === "bass" ? "POST" : "GET",
|
|
};
|
|
|
|
if (data) {
|
|
options.headers = {
|
|
"Content-Type": "application/json",
|
|
};
|
|
options.body = JSON.stringify(data);
|
|
}
|
|
|
|
fetch(url, options)
|
|
.then((response) => response.json())
|
|
.then((result) => {
|
|
if (result.success) {
|
|
showToast("Success", result.data.message, "success");
|
|
} else {
|
|
showToast(
|
|
"Error",
|
|
result.error || "Control command failed",
|
|
"error",
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Control error:", error);
|
|
showToast("Error", "Failed to send control command", "error");
|
|
});
|
|
}
|
|
|
|
function discoverDevices() {
|
|
fetch("/api/discover", {
|
|
method: "POST",
|
|
})
|
|
.then((response) => response.json())
|
|
.then((data) => {
|
|
if (data.success) {
|
|
showToast(
|
|
"Discovery Started",
|
|
"Searching for SoundTouch devices...",
|
|
"info",
|
|
);
|
|
// Real-time updates will come via WebSocket, no need for setTimeout
|
|
} else {
|
|
showToast(
|
|
"Discovery Failed",
|
|
data.error || "Failed to start discovery",
|
|
"error",
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Discovery error:", error);
|
|
showToast(
|
|
"Discovery Error",
|
|
"Failed to start device discovery",
|
|
"error",
|
|
);
|
|
});
|
|
}
|
|
|
|
function handleDiscoveryStatus(data) {
|
|
const status = data.status;
|
|
const deviceCount = data.deviceCount;
|
|
|
|
switch (status) {
|
|
case "starting":
|
|
showToast(
|
|
"Discovery Starting",
|
|
"Searching for SoundTouch devices...",
|
|
"info",
|
|
);
|
|
break;
|
|
case "completed":
|
|
const message =
|
|
deviceCount === 0
|
|
? "No devices found"
|
|
: `Discovery completed - found ${deviceCount} device${deviceCount === 1 ? "" : "s"}`;
|
|
showToast(
|
|
"Discovery Completed",
|
|
message,
|
|
deviceCount === 0 ? "warning" : "success",
|
|
);
|
|
break;
|
|
case "failed":
|
|
showToast(
|
|
"Discovery Failed",
|
|
"Failed to discover devices",
|
|
"error",
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Utility functions
|
|
function formatTimeAgo(date) {
|
|
const now = new Date();
|
|
const diff = now - date;
|
|
const minutes = Math.floor(diff / 60000);
|
|
const hours = Math.floor(diff / 3600000);
|
|
const days = Math.floor(diff / 86400000);
|
|
|
|
if (days > 0) return `${days}d ago`;
|
|
if (hours > 0) return `${hours}h ago`;
|
|
if (minutes > 0) return `${minutes}m ago`;
|
|
return "Just now";
|
|
}
|
|
|
|
function showToast(title, message, type = "info") {
|
|
const toastContainer = document.querySelector(".toast-container");
|
|
const toastId = "toast-" + Date.now();
|
|
|
|
const toastHTML = `
|
|
<div class="toast" id="${toastId}" role="alert" aria-live="assertive" aria-atomic="true">
|
|
<div class="toast-header">
|
|
<i class="bi bi-${getToastIcon(type)} me-2"></i>
|
|
<strong class="me-auto">${escapeHtml(title)}</strong>
|
|
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
|
</div>
|
|
<div class="toast-body">${escapeHtml(message)}</div>
|
|
</div>
|
|
`;
|
|
|
|
toastContainer.insertAdjacentHTML("beforeend", toastHTML);
|
|
const toast = new bootstrap.Toast(document.getElementById(toastId));
|
|
toast.show();
|
|
|
|
document
|
|
.getElementById(toastId)
|
|
.addEventListener("hidden.bs.toast", function () {
|
|
this.remove();
|
|
});
|
|
}
|
|
|
|
function getToastIcon(type) {
|
|
switch (type) {
|
|
case "success":
|
|
return "check-circle-fill text-success";
|
|
case "error":
|
|
return "exclamation-triangle-fill text-danger";
|
|
case "warning":
|
|
return "exclamation-triangle-fill text-warning";
|
|
default:
|
|
return "info-circle-fill text-info";
|
|
}
|
|
}
|
|
|
|
function showConnectionError() {
|
|
// Check if connection error is already shown to avoid spam
|
|
const existingError = document.getElementById("connection-error");
|
|
if (existingError) {
|
|
// Update existing error message with attempt count
|
|
const messageDiv = existingError.querySelector("div");
|
|
messageDiv.textContent = `Connection lost. Reconnecting... (${reconnectAttempts}/${maxReconnectAttempts})`;
|
|
return;
|
|
}
|
|
|
|
// Create a persistent notification instead of toast
|
|
const errorDiv = document.createElement("div");
|
|
errorDiv.id = "connection-error";
|
|
errorDiv.className =
|
|
"alert alert-warning d-flex align-items-center position-fixed top-0 start-50 translate-middle-x mt-3";
|
|
errorDiv.style.zIndex = "9999";
|
|
errorDiv.innerHTML = `
|
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
|
<div>Connection lost. Reconnecting... (${reconnectAttempts}/${maxReconnectAttempts})</div>
|
|
<button type="button" class="btn-close ms-auto" onclick="this.parentElement.remove()"></button>
|
|
`;
|
|
|
|
document.body.appendChild(errorDiv);
|
|
}
|
|
|
|
function showMaxReconnectError() {
|
|
const existingError = document.getElementById("connection-error");
|
|
if (existingError) {
|
|
existingError.remove();
|
|
}
|
|
|
|
const errorDiv = document.createElement("div");
|
|
errorDiv.id = "connection-error";
|
|
errorDiv.className =
|
|
"alert alert-danger d-flex align-items-center position-fixed top-0 start-50 translate-middle-x mt-3";
|
|
errorDiv.style.zIndex = "9999";
|
|
errorDiv.innerHTML = `
|
|
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
|
<div>Connection failed. Please refresh the page to retry.</div>
|
|
<button type="button" class="btn btn-outline-light btn-sm ms-3" onclick="window.location.reload()">Refresh</button>
|
|
<button type="button" class="btn-close ms-2" onclick="this.parentElement.remove()"></button>
|
|
`;
|
|
|
|
document.body.appendChild(errorDiv);
|
|
}
|
|
|
|
function hideConnectionError() {
|
|
const existingError = document.getElementById("connection-error");
|
|
if (existingError) {
|
|
existingError.remove();
|
|
}
|
|
}
|
|
|
|
// Theme management functions
|
|
function initializeTheme() {
|
|
// Check for saved theme preference or default to 'auto'
|
|
const savedTheme = localStorage.getItem("theme") || "auto";
|
|
applyTheme(savedTheme);
|
|
updateThemeIcon(savedTheme);
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const themeToggle = document.querySelector(".theme-toggle");
|
|
|
|
// Add animation class
|
|
themeToggle.classList.add("switching");
|
|
|
|
const currentTheme = document.documentElement.getAttribute("data-theme");
|
|
const systemPrefersDark = window.matchMedia(
|
|
"(prefers-color-scheme: dark)",
|
|
).matches;
|
|
|
|
let newTheme;
|
|
if (currentTheme === null || currentTheme === "auto") {
|
|
// If auto or no theme set, switch to opposite of system preference
|
|
newTheme = systemPrefersDark ? "light" : "dark";
|
|
} else if (currentTheme === "light") {
|
|
newTheme = "dark";
|
|
} else {
|
|
newTheme = "light";
|
|
}
|
|
|
|
// Apply theme changes after a short delay for animation
|
|
setTimeout(() => {
|
|
applyTheme(newTheme);
|
|
updateThemeIcon(newTheme);
|
|
localStorage.setItem("theme", newTheme);
|
|
|
|
// Remove animation class after animation completes
|
|
setTimeout(() => {
|
|
themeToggle.classList.remove("switching");
|
|
}, 100);
|
|
}, 200);
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
if (theme === "auto") {
|
|
document.documentElement.removeAttribute("data-theme");
|
|
} else {
|
|
document.documentElement.setAttribute("data-theme", theme);
|
|
}
|
|
}
|
|
|
|
function updateThemeIcon(theme) {
|
|
const themeIcon = document.getElementById("theme-icon");
|
|
const systemPrefersDark = window.matchMedia(
|
|
"(prefers-color-scheme: dark)",
|
|
).matches;
|
|
|
|
if (theme === "dark" || (theme === "auto" && systemPrefersDark)) {
|
|
themeIcon.className = "bi bi-sun";
|
|
} else {
|
|
themeIcon.className = "bi bi-moon";
|
|
}
|
|
}
|
|
|
|
// Listen for system theme changes
|
|
window
|
|
.matchMedia("(prefers-color-scheme: dark)")
|
|
.addEventListener("change", (e) => {
|
|
const currentTheme = localStorage.getItem("theme") || "auto";
|
|
if (currentTheme === "auto") {
|
|
updateThemeIcon("auto");
|
|
}
|
|
});
|
|
|
|
// Initialize application
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
initializeTheme();
|
|
connectWebSocket();
|
|
loadDevices();
|
|
|
|
// TuneIn: keyboard search
|
|
document
|
|
.getElementById("tunein-search-input")
|
|
.addEventListener("keydown", function (e) {
|
|
if (e.key === "Enter") tuneInSearch(this.value);
|
|
});
|
|
|
|
// TuneIn: event delegation — play buttons take priority over navigation
|
|
document
|
|
.getElementById("tunein-results")
|
|
.addEventListener("click", function (e) {
|
|
const playBtn = e.target.closest(".tunein-play-btn");
|
|
if (playBtn) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
tuneInPlayClick(
|
|
playBtn.dataset.playLocation,
|
|
playBtn.dataset.playName,
|
|
playBtn.dataset.playType || "stationurl",
|
|
playBtn.dataset.playArt || "",
|
|
);
|
|
return;
|
|
}
|
|
const item = e.target.closest("[data-nav-path]");
|
|
if (item) {
|
|
e.preventDefault();
|
|
tuneInNavigate(item.dataset.navPath, item.dataset.navLabel || "");
|
|
}
|
|
});
|
|
|
|
// TuneIn: keyboard activation for navigable items
|
|
document
|
|
.getElementById("tunein-results")
|
|
.addEventListener("keydown", function (e) {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
const item = e.target.closest("[data-nav-path]");
|
|
if (item) {
|
|
e.preventDefault();
|
|
tuneInNavigate(item.dataset.navPath, item.dataset.navLabel || "");
|
|
}
|
|
}
|
|
});
|
|
});
|