Dashboard Studio 1.5.0: conditional widgets, appliance card, multi-player media

- Visibility conditions on every widget (is / is not / above / below,
  all or any); rows that only held hidden widgets close up in view mode
- Appliance card for washer, dryer and dishwasher: status or power
  based, time left, ready time, progress and program; hides when off
- Media card controls several players with a switcher and album art
- Dutch translations and docs for the new features

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 22:24:22 +01:00
parent e39a3d93f7
commit 9e7e7beaa0
8 changed files with 317 additions and 20 deletions
+110 -5
View File
@@ -175,7 +175,7 @@
const lights = first("light", 3);
if (lights[0]) put("light", 4, 1, 4, 2, { entity: lights[0] });
const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging/.test(e)).slice(0, 2);
const sensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && !/battery|range|odometer|charging|afvalwijzer|washer|dryer|dishwasher|remaining|progress/.test(e)).slice(0, 2);
sensors.forEach((e, i) => put("value", i * 3, 3, 3, 2, { entity: e }));
const tiles = [...lights.slice(1, 2), ...first("switch", 1)];
tiles.forEach((e, i) => put("entity", 6 + i * 3, 3, 3, 2, { entity: e }));
@@ -197,6 +197,22 @@
if (sw.length) put("list", carLock ? 6 : 8, y, carLock ? 6 : 4, 3, { title: tr("Quick controls"), entities: sw });
const graphSensors = ids.filter((e) => domainOf(e) === "sensor" && numeric(e) && ha.states[e].attributes.device_class === "temperature").slice(0, 2);
if (graphSensors.length) put("graph", 0, carLock ? y + 3 : y, carLock ? 12 : 8, 3, { entities: graphSensors, title: tr("Temperature, last 24 hours") });
const players = first("media_player", 4);
const appliances = [["washer", /(washer|washing_machine|wasmachine)_(status|state|operation_state|program_state)$/, "Washing machine"],
["dryer", /(dryer|droger|wasdroger)_(status|state|operation_state|program_state)$/, "Dryer"],
["dishwasher", /(dishwasher|vaatwasser|vaatwasmachine)_(status|state|operation_state|program_state)$/, "Dishwasher"]]
.map(([kind, re, name]) => ({ kind, name, status: ids.find((e) => domainOf(e) === "sensor" && re.test(e)) })).filter((a) => a.status);
if (players.length || appliances.length) {
let y2 = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h)), x2 = 0;
if (players.length) { put("media", 0, y2, 4, 3, { entities: players }); x2 = 4; }
for (const a of appliances) {
const base = a.status.replace(/_(status|state|operation_state|program_state)$/, "");
const pick = (...suffixes) => suffixes.map((sfx) => `${base}_${sfx}`).find((e) => ha.states[e]) || "";
if (x2 + 4 > 12) { x2 = 0; y2 += 3; }
put("appliance", x2, y2, 4, 2, { name: tr(a.name), kind: a.kind, status: a.status, remaining: pick("remaining_time", "time_remaining", "remaining", "end_time", "finish_time"), progress: pick("progress", "program_progress"), program: pick("program", "selected_program", "active_program"), power: pick("power") });
x2 += 4;
}
}
if (ids.some((e) => e.startsWith("sensor.afvalwijzer_") && "is_collection_date_today" in ha.states[e].attributes)) {
const bottom = Math.max(0, ...d.tabs[0].widgets.map((w) => w.y + w.h));
put("waste", 0, bottom, 4, 4, {});
@@ -302,13 +318,40 @@
}
// Compact widths: two columns in reading order. Small widgets take one column, the rest the full width.
// ------------------------------------------------------------ visibility
// A widget can carry conditions ("show only when the washer is running"); widgets may
// also hide themselves (autoHide). Editing always shows everything.
function ruleMatches(r) {
const st = String(ha.states[r.entity]?.state ?? "unavailable").toLowerCase();
const v = String(r.value ?? "").trim().toLowerCase();
switch (r.op) {
case "is not": return st !== v;
case "above": return Number(st) > Number(v);
case "below": return Number(st) < Number(v);
default: return st === v;
}
}
function isVisible(w) {
if (Widgets[w.type]?.autoHide?.(w.cfg, ctxFor(w))) return false;
const rules = (w.visibility?.rules || []).filter((r) => r.entity);
if (!rules.length) return true;
return w.visibility.mode === "any" ? rules.some(ruleMatches) : rules.every(ruleMatches);
}
const ruleEntities = (w) => (w.visibility?.rules || []).map((r) => r.entity).filter(Boolean);
let lastVisible = {};
function positions() {
const map = {};
const shown = editMode ? tab().widgets : tab().widgets.filter(isVisible);
if (!metrics.compact) {
for (const w of tab().widgets) map[w.id] = w;
if (editMode) { for (const w of shown) map[w.id] = w; return map; }
// Rows that only hidden widgets used close up, so nothing leaves a hole.
const rows = (list) => { const set = new Set(); for (const w of list) for (let r = w.y; r < w.y + w.h; r++) set.add(r); return set; };
const vis = rows(shown), gone = [...rows(tab().widgets.filter((w) => !shown.includes(w)))].filter((r) => !vis.has(r));
for (const w of shown) map[w.id] = { x: w.x, y: w.y - gone.filter((r) => r < w.y).length, w: w.w, h: w.h };
return map;
}
const sorted = [...tab().widgets].sort((a, b) => a.y - b.y || a.x - b.x);
const sorted = [...shown].sort((a, b) => a.y - b.y || a.x - b.x);
const small = (w) => w.w <= Math.floor(current.grid.cols / 4) && w.type !== "heading";
let y = 0, halfOpen = null;
for (const w of sorted) {
@@ -395,7 +438,10 @@
computeMetrics();
const pos = positions();
gridEl.innerHTML = "";
lastVisible = {};
for (const w of tab().widgets) {
lastVisible[w.id] = isVisible(w);
if (!pos[w.id]) continue;
const el = document.createElement("div");
el.className = "w";
el.dataset.id = w.id;
@@ -430,7 +476,10 @@
}
let html;
try { html = def.render(w.cfg, ctx); } catch (e) { console.error(e); html = `<div class="empty">${tr("This widget couldn’t be drawn.")}</div>`; }
const hiddenNow = editMode && !isVisible(w);
el.classList.toggle("cond-hidden", hiddenNow);
el.innerHTML = `<div class="w-body ${def.flush ? "flush" : ""}">${html}</div>` +
(hiddenNow ? `<div class="w-hidden">${Icons.svg("eye-off")}${tr("Hidden now")}</div>` : "") +
(editMode ? `<div class="w-tools"><button data-tool="dup" aria-label="${tr("Duplicate")}" title="${tr("Duplicate")}">${Icons.svg("copy")}</button><button data-tool="del" class="del" aria-label="${tr("Delete")}" title="${tr("Delete")}">${Icons.svg("trash")}</button></div><div class="w-resize" aria-hidden="true"></div>` : "");
if (editMode) el.querySelectorAll(".w-body button, .w-body input, .w-body iframe").forEach((n) => n.setAttribute("tabindex", "-1"));
if (def.after) Promise.resolve(def.after(el, w.cfg, ctx)).catch(console.error);
@@ -447,8 +496,9 @@
function onStateChange(entityId) {
if (!current) return;
if (entityId == null) { for (const c of Object.values(caches)) c.key = null; renderGrid(); if (editMode) renderInspector(); return; }
for (const w of tab().widgets) {
if (!entitiesOf(w).includes(entityId)) continue;
const affected = tab().widgets.filter((w) => entitiesOf(w).includes(entityId) || ruleEntities(w).includes(entityId));
if (affected.some((w) => isVisible(w) !== lastVisible[w.id])) { renderGrid(); return; }
for (const w of affected) {
const el = gridEl.querySelector(`.w[data-id="${w.id}"]`);
if (interacting && el?.contains(document.activeElement)) continue; // don't yank a slider mid-drag
const focused = el?.contains(document.activeElement) ? document.activeElement.dataset.act : null;
@@ -565,6 +615,7 @@
<div class="row">${[["x", "Column"], ["y", "Row"], ["w", "Width"], ["h", "Height"]].map(([k, l]) => `<div class="field"><label for="pos-${k}">${tr(l)}</label><input class="input" id="pos-${k}" type="number" min="${k === "w" || k === "h" ? 1 : 0}" data-path="pos" data-k="${k}" data-num="1" value="${w[k]}"></div>`).join("")}</div></div>
${current.tabs.length > 1 ? fieldHTML({ k: "tab", t: "select", l: "Tab", o: current.tabs.map((t) => t.id), labels: Object.fromEntries(current.tabs.map((t) => [t.id, t.name])) }, tab().id, "move") : ""}
<div class="section"><div class="section-title">${tr("Style")}</div>${STYLE_FIELDS.map((f) => fieldHTML(f, w.style?.[f.k], "style")).join("")}</div>
${visibilityHTML(w)}
<div class="insp-actions"><button class="btn" data-tool="dup">${Icons.svg("copy")}${tr("Duplicate")}</button><button class="btn danger" data-tool="del">${Icons.svg("trash")}${tr("Delete")}</button></div>`;
fillAlbums(el);
return;
@@ -633,6 +684,32 @@
}
}
function visibilityHTML(w) {
const vis = w.visibility || { mode: "all", rules: [] };
const common = ["on", "off", "running", "idle", "playing", "paused", "home", "not_home", "unavailable"];
const rows = vis.rules.map((r, i) => {
const s = ha.states[r.entity];
const opts = [...new Set([s?.state, ...common].filter(Boolean))].map((o) => `<option value="${esc(o)}">`).join("");
return `<div class="rule">
<input class="input" type="text" list="dl-all" data-path="rule" data-idx="${i}" data-k="entity" value="${esc(r.entity || "")}" placeholder="${tr("Entity")}" aria-label="${tr("Entity")}" autocomplete="off" spellcheck="false">
<div class="rule-row">
<select class="input" data-path="rule" data-idx="${i}" data-k="op" aria-label="${tr("Condition")}">${["is", "is not", "above", "below"].map((o) => `<option value="${o}" ${o === (r.op || "is") ? "selected" : ""}>${esc(optLabel(o))}</option>`).join("")}</select>
<input class="input" type="text" list="dl-rule-${i}" data-path="rule" data-idx="${i}" data-k="value" value="${esc(r.value ?? "")}" placeholder="${tr("Value")}" aria-label="${tr("Value")}" autocomplete="off">
<datalist id="dl-rule-${i}">${opts}</datalist>
<button class="btn icon" data-rule-del="${i}" aria-label="${tr("Remove condition")}">${Icons.svg("close")}</button>
</div>
${s ? `<div class="ent-hint">${tr("Now")}: ${esc(s.state)}</div>` : ""}
</div>`;
}).join("");
const status = vis.rules.some((r) => r.entity) || Widgets[w.type]?.autoHide
? `<p class="small vis-status">${isVisible(w) ? Icons.svg("eye") + tr("Visible right now") : Icons.svg("eye-off") + tr("Hidden right now")}</p>` : "";
return `<div class="section"><div class="section-title">${tr("Visibility")}</div>
<p class="muted small" style="margin:0 2px 10px">${tr("Show this widget only when the conditions below are met. Leave empty to always show it.")}</p>
${vis.rules.length > 1 ? fieldHTML({ k: "mode", t: "seg", l: "Show when", o: ["all", "any"] }, vis.mode || "all", "vis") : ""}
${rows}${status}
<button class="btn" data-rule-add style="width:100%">${Icons.svg("plus")}${tr("Add condition")}</button></div>`;
}
function applyField(t, v, isChange) {
const path = t.dataset.path, k = t.dataset.k, idx = t.dataset.idx;
if (path === "tab") {
@@ -642,6 +719,16 @@
if (isChange && k === "icon") renderInspector();
return;
}
if (path === "vis" || path === "rule") {
const w = tab().widgets.find((x) => x.id === selectedId);
mutate(() => {
w.visibility ||= { mode: "all", rules: [] };
if (path === "vis") w.visibility[k] = v;
else w.visibility.rules[Number(t.dataset.idx)][k] = v;
}, `${w.id}|${path}|${t.dataset.idx}|${k}`);
if (isChange || t.tagName === "BUTTON") renderInspector();
return;
}
if (path === "nav") {
mutate(() => { current.nav[k] = v; });
return;
@@ -867,6 +954,12 @@
const t = Math.round(((s?.attributes.temperature ?? 20) + Number(a.dataset.v)) * 10) / 10;
ha.callService("climate", "set_temperature", { temperature: t }, eid);
} else if (act === "hvac") ha.callService("climate", "set_hvac_mode", { hvac_mode: a.dataset.v }, eid);
else if (act === "mp-select") {
const w = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id);
caches[w.id].sel = a.dataset.v;
renderWidget(w);
gridEl.querySelector(`.w[data-id="${w.id}"] [data-act="mp-select"][data-v="${a.dataset.v}"]`)?.focus();
}
else if (act === "svc") { const [d, s] = a.dataset.v.split("."); ha.callService(d, s, {}, eid); }
else if (act === "button") {
const c = tab().widgets.find((x) => x.id === a.closest(".w").dataset.id).cfg;
@@ -1179,6 +1272,18 @@
mutate(() => w.cfg[del.dataset.k].splice(Number(del.dataset.entDel), 1), null, { rerender: false });
renderWidget(w); renderInspector(); return;
}
const ruleAdd = e.target.closest("[data-rule-add]"), ruleDel = e.target.closest("[data-rule-del]");
if (ruleAdd || ruleDel) {
const w = tab().widgets.find((x) => x.id === selectedId);
mutate(() => {
w.visibility ||= { mode: "all", rules: [] };
if (ruleAdd) w.visibility.rules.push({ entity: "", op: "is", value: "" });
else w.visibility.rules.splice(Number(ruleDel.dataset.ruleDel), 1);
});
renderInspector();
if (ruleAdd) $(`#inspector [data-path="rule"][data-idx="${w.visibility.rules.length - 1}"][data-k="entity"]`)?.focus();
return;
}
const op = e.target.closest("[data-tabop]");
if (op) { tabOp(op.dataset.tabop, op.dataset.tabId); return; }
const k = e.target.closest("[data-kiosk]");