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:
+110
-5
@@ -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]");
|
||||
|
||||
@@ -221,6 +221,24 @@
|
||||
"Textile": "Textiel", "Bulky waste": "Grofvuil", "Hazardous waste": "KCA", "Nappies": "Luiers", "Glass": "Glas",
|
||||
"Pruning waste": "Snoeiafval", "Garden waste": "Tuinafval", "Christmas trees": "Kerstbomen", "BEST bag": "BEST-tas",
|
||||
"Hazardous waste van": "Milieubus",
|
||||
// visibility
|
||||
"Hidden now": "Nu verborgen", "Visibility": "Zichtbaarheid",
|
||||
"Show this widget only when the conditions below are met. Leave empty to always show it.": "Toon deze widget alleen als aan de voorwaarden hieronder is voldaan. Laat leeg om hem altijd te tonen.",
|
||||
"Show when": "Toon als", "all": "Alle voorwaarden", "any": "Eén voorwaarde", "Add condition": "Voorwaarde toevoegen",
|
||||
"Remove condition": "Verwijder voorwaarde", "Condition": "Voorwaarde", "Value": "Waarde",
|
||||
"is": "Is", "is not": "Is niet", "above": "Hoger dan", "below": "Lager dan",
|
||||
"Visible right now": "Nu zichtbaar", "Hidden right now": "Nu verborgen",
|
||||
// media
|
||||
"Media players": "Mediaspelers", "Show album art": "Toon albumhoes", "Hide players that are off from the switcher": "Verberg uitgeschakelde spelers in de keuzelijst",
|
||||
// appliance
|
||||
"Appliance": "Apparaat", "Washing machine": "Wasmachine", "Dryer": "Droger", "Dishwasher": "Vaatwasser",
|
||||
"washer": "Wasmachine", "dryer": "Droger", "dishwasher": "Vaatwasser", "other": "Anders",
|
||||
"Status": "Status", "Power (W), if there is no status": "Vermogen (W), als er geen status is", "Running above (W)": "Aan boven (W)",
|
||||
"Time remaining or end time": "Resterende tijd of eindtijd", "Progress (%)": "Voortgang (%)", "Program": "Programma",
|
||||
"Only show while running or finished": "Alleen tonen als hij draait of klaar is",
|
||||
"Choose the appliance’s status, power or remaining-time entity in the inspector.": "Kies in de inspector de status-, vermogens- of resterende-tijdentiteit van het apparaat.",
|
||||
"In progress": "Bezig", "Finished": "Klaar", "Paused": "Gepauzeerd", "Ready to unload": "Klaar om uit te ruimen",
|
||||
"{0} h {1} min": "{0} u {1} min", "{0} min": "{0} min", "left · ready at {0}": "te gaan · klaar om {0}", "Progress": "Voortgang",
|
||||
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
|
||||
};
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@
|
||||
shield: "M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z",
|
||||
bin: "M4 6h16M6 6l1.2 12.2a1 1 0 0 0 1 .8h7.6a1 1 0 0 0 1-.8L18 6M9 6V3.5h6V6M8.5 21.5a1 1 0 1 0 0-.01M15.5 21.5a1 1 0 1 0 0-.01M10 10v5M14 10v5",
|
||||
tabs: "M3 5h7v14H3zM13 6h8M13 10h8M13 14h5",
|
||||
washer: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM4 7.5h16M7 5.3h.01M10 5.3h.01M12 18.5a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zM9.6 14.6c1-.8 1.9-.8 2.9 0s1.9.8 2.9 0",
|
||||
dryer: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM4 7.5h16M7 5.3h.01M10 5.3h.01M12 18.5a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zM10.5 12c.8.8.8 1.6 0 2.4M13.5 12c.8.8.8 1.6 0 2.4",
|
||||
dishwasher: "M5 3h14a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM4 8h16M8 5.5h.01M11 5.5h.01M8 12v5M12 12v5M16 12v5",
|
||||
eye: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
|
||||
"eye-off": "M3 3l18 18M10.6 5.1A10.6 10.6 0 0 1 12 5c6.5 0 10 7 10 7a17.7 17.7 0 0 1-3.2 4.1M6.6 6.6A17.6 17.6 0 0 0 2 12s3.5 7 10 7a9.7 9.7 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2",
|
||||
calendar: "M4 6a1 1 0 0 1 1-1h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zM4 10h16M8 3v4M16 3v4M8 14h.01M12 14h.01M16 14h.01M8 17h.01M12 17h.01",
|
||||
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
|
||||
chart: "M3 3v18h18M7 15l4-4 3 3 6-6",
|
||||
|
||||
@@ -432,6 +432,41 @@ body.kiosk #tabnav { top: 6px; max-height: calc(100vh - 20px); }
|
||||
.waste-day.soon { color: var(--d-accent); }
|
||||
@container (max-width: 300px) { .waste-row .st { display: none; } }
|
||||
|
||||
/* visibility */
|
||||
.w.cond-hidden { opacity: 0.5; }
|
||||
.w-hidden { position: absolute; left: 8px; bottom: 8px; z-index: 3; display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 99px; font-size: 12px; font-weight: 600; background: var(--surface); color: var(--text); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); }
|
||||
.rule { padding: 8px; border-radius: 10px; background: var(--fill); margin-bottom: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.rule .input { background: var(--surface); }
|
||||
.rule-row { display: flex; gap: 6px; }
|
||||
.rule-row select { flex: 1.1; }
|
||||
.rule-row input { flex: 1; min-width: 0; }
|
||||
.rule-row .btn { flex: none; }
|
||||
.vis-status { display: flex; align-items: center; gap: 6px; margin: 0 2px 10px; color: var(--text-2); }
|
||||
|
||||
/* media players */
|
||||
.mp { gap: 8px; }
|
||||
.mp-switch { display: flex; gap: 6px; overflow-x: auto; flex: none; scrollbar-width: none; }
|
||||
.mp-switch .chip { flex: none; gap: 6px; }
|
||||
.mp-switch .chip[aria-selected="true"] { background: var(--d-text); color: var(--d-tile); font-weight: 600; }
|
||||
.mp-now { display: flex; align-items: center; gap: 12px; min-height: 0; flex: 1; }
|
||||
.mp-art { width: clamp(48px, 32cqh, 96px); aspect-ratio: 1; border-radius: 10px; object-fit: cover; flex: none; background: var(--d-fill); }
|
||||
.mp-art.ph { display: grid; place-items: center; font-size: 28px; color: var(--d-text-2); }
|
||||
.mp-meta { display: flex; flex-direction: column; min-width: 0; }
|
||||
.eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 11px; }
|
||||
.eq i { width: 3px; background: currentColor; border-radius: 1px; animation: eq 0.9s ease-in-out infinite; }
|
||||
.eq i:nth-child(1) { height: 50%; } .eq i:nth-child(2) { height: 100%; animation-delay: -0.3s; } .eq i:nth-child(3) { height: 70%; animation-delay: -0.6s; }
|
||||
@keyframes eq { 0%, 100% { transform: scaleY(0.4); transform-origin: bottom; } 50% { transform: scaleY(1); transform-origin: bottom; } }
|
||||
@container (max-height: 170px) { .mp-art { display: none; } }
|
||||
|
||||
/* appliance */
|
||||
.appl { display: flex; flex-direction: column; height: 100%; gap: 8px; justify-content: space-between; }
|
||||
.appl-status { margin-left: auto; font-size: 13px; font-weight: 600; padding: 3px 10px; border-radius: 99px; background: var(--d-fill); white-space: nowrap; }
|
||||
.appl.phase-running .appl-status { background: var(--d-accent); color: #fff; }
|
||||
.appl.phase-done .appl-status { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
|
||||
.appl-big { font-size: 22px; font-weight: 700; }
|
||||
.appl-big .wt { font-size: 14px; font-weight: 400; }
|
||||
.appl .car-fill { background: var(--d-accent); }
|
||||
|
||||
/* photos */
|
||||
.photos { position: absolute; inset: 0; background: #000; overflow: hidden; }
|
||||
.ph-layer { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: var(--fit, cover); opacity: 0; transition: opacity 1.2s ease; }
|
||||
|
||||
+110
-13
@@ -384,23 +384,120 @@
|
||||
render: (c) => `<button class="btn-widget wbtn" data-act="button"><span class="badge">${icon(null, c.icon || "sparkles")}</span><span class="name">${esc(c.label)}</span></button>`,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- media (one or more players)
|
||||
const mediaIds = (c) => (c.entities?.length ? c.entities : c.entity ? [c.entity] : []);
|
||||
const ACTIVE = ["playing", "paused", "buffering", "on", "idle"];
|
||||
|
||||
/** Which player the card shows: the one picked, else whatever is playing, else the first that's on. */
|
||||
function mediaSelected(ids, ha, cache) {
|
||||
if (cache.sel && ids.includes(cache.sel)) return cache.sel;
|
||||
return ids.find((e) => ha.states[e]?.state === "playing") || ids.find((e) => ha.states[e]?.state === "paused") || ids.find((e) => ACTIVE.includes(ha.states[e]?.state)) || ids[0];
|
||||
}
|
||||
|
||||
W.media = {
|
||||
label: "Media player", icon: "music", size: [4, 2],
|
||||
fields: [{ k: "entity", t: "entity", l: "Media player", domains: ["media_player"] }, { k: "name", t: "text", l: "Name", ph: "Entity name" }],
|
||||
render(c, { ha }) {
|
||||
const s = ha.states[c.entity];
|
||||
if (!s) return missing(c.entity, "a media player");
|
||||
const a = s.attributes, e = esc(c.entity), playing = s.state === "playing";
|
||||
return `<div class="media"><div class="wt">${esc(friendly(s, c.name))}</div>
|
||||
<div><div class="title">${esc(a.media_title || st(s.state))}</div><div class="wt">${esc(a.media_artist || "")}</div></div>
|
||||
<div class="ctrls">
|
||||
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_previous_track" aria-label="${t("Previous track")}">${Icons.svg("prev")}</button>
|
||||
label: "Media players", icon: "music", size: [4, 3],
|
||||
fields: [
|
||||
{ k: "entities", t: "entities", l: "Media players", domains: ["media_player"] },
|
||||
{ k: "art", t: "check", l: "Show album art", d: true },
|
||||
{ k: "hideOff", t: "check", l: "Hide players that are off from the switcher", d: false },
|
||||
],
|
||||
entities: mediaIds,
|
||||
render(c, { ha, cache }) {
|
||||
let ids = mediaIds(c).filter((e) => ha.states[e]);
|
||||
if (!ids.length) return missing(mediaIds(c)[0], "a media player");
|
||||
const sel = mediaSelected(ids, ha, cache);
|
||||
if (c.hideOff) ids = ids.filter((e) => e === sel || ha.states[e].state !== "off");
|
||||
const s = ha.states[sel], a = s.attributes, e = esc(sel);
|
||||
const playing = s.state === "playing", off = ["off", "standby", "unavailable"].includes(s.state);
|
||||
const bars = `<span class="eq" aria-hidden="true"><i></i><i></i><i></i></span>`;
|
||||
const switcher = ids.length > 1 ? `<div class="mp-switch" role="tablist" aria-label="${t("Media players")}">${ids.map((id) => {
|
||||
const p = ha.states[id], on = p.state === "playing";
|
||||
return `<button class="chip wbtn ${on ? "is-playing" : ""}" role="tab" aria-selected="${id === sel}" data-act="mp-select" data-v="${esc(id)}" aria-label="${esc(friendly(p))}, ${esc(st(p.state))}">${on ? bars : ""}${esc(friendly(p))}</button>`;
|
||||
}).join("")}</div>` : "";
|
||||
const pic = c.art !== false && a.entity_picture && !off ? `<img class="mp-art" src="${esc(a.entity_picture)}" alt="">` : `<span class="mp-art ph" aria-hidden="true">${icon(s)}</span>`;
|
||||
const title = off ? st(s.state) : a.media_title || a.app_name || a.source || st(s.state);
|
||||
const sub = off ? "" : [a.media_artist, a.media_album_name].filter(Boolean).join(" · ") || (a.media_title ? "" : st(s.state));
|
||||
const controls = off
|
||||
? `<button class="car-act wbtn" data-act="svc" data-e="${e}" data-v="media_player.turn_on">${Icons.svg("power")}<span>${t("Turn on")}</span></button>`
|
||||
: `<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_previous_track" aria-label="${t("Previous track")}">${Icons.svg("prev")}</button>
|
||||
<button class="round wbtn primary" data-act="svc" data-e="${e}" data-v="media_player.media_play_pause" aria-label="${playing ? t("Pause") : t("Play")}">${Icons.svg(playing ? "pause" : "play")}</button>
|
||||
<button class="round wbtn" data-act="svc" data-e="${e}" data-v="media_player.media_next_track" aria-label="${t("Next track")}">${Icons.svg("next")}</button>
|
||||
${a.volume_level != null ? `<input class="slider thin" type="range" min="0" max="100" value="${Math.round(a.volume_level * 100)}" style="--pct:${Math.round(a.volume_level * 100)}%" data-act="volume" data-e="${e}" aria-label="${t("Volume")}">` : ""}
|
||||
</div></div>`;
|
||||
${a.volume_level != null ? `<input class="slider thin" type="range" min="0" max="100" value="${Math.round(a.volume_level * 100)}" style="--pct:${Math.round(a.volume_level * 100)}%" data-act="volume" data-e="${e}" aria-label="${esc(t("Volume"))} ${esc(friendly(s))}">` : ""}`;
|
||||
return `<div class="media mp">${switcher}
|
||||
<div class="mp-now">${pic}<div class="mp-meta">
|
||||
${ids.length === 1 ? `<div class="wt">${esc(friendly(s))}</div>` : ""}
|
||||
<div class="title">${esc(title)}</div>${sub ? `<div class="wt">${esc(sub)}</div>` : ""}</div></div>
|
||||
<div class="ctrls">${controls}</div></div>`;
|
||||
},
|
||||
lit: (c, { ha }) => (ha.states[c.entity]?.state === "playing" ? "on" : false),
|
||||
lit: (c, { ha, cache }) => (ha.states[mediaSelected(mediaIds(c), ha, cache || {})]?.state === "playing" ? "on" : false),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- appliance (washer, dryer, dishwasher)
|
||||
// Works with any integration: a status entity, and/or a power sensor above a threshold,
|
||||
// plus optional remaining time (minutes, hours or an end timestamp), progress and program.
|
||||
const RUNNING_RE = /run|wash|dry|rins|spin|heat|clean|activ|program|bezig|draait|centrifug|spoel|droog|^on$|^true$/;
|
||||
const DONE_RE = /finish|end|done|complete|ready|klaar|gereed|beëindigd/;
|
||||
const PAUSE_RE = /pause|gepauzeerd|delayed|uitgesteld/;
|
||||
|
||||
function appliancePhase(c, ha) {
|
||||
const status = String(ha.states[c.status]?.state || "").toLowerCase();
|
||||
if (status && !["unavailable", "unknown"].includes(status)) {
|
||||
if (DONE_RE.test(status)) return "done";
|
||||
if (PAUSE_RE.test(status)) return "paused";
|
||||
if (RUNNING_RE.test(status)) return "running";
|
||||
}
|
||||
const power = Number(ha.states[c.power]?.state);
|
||||
if (c.power && isFinite(power) && power > (Number(c.threshold) || 5)) return "running";
|
||||
const left = remainingMinutes(c, ha);
|
||||
if (!c.status && !c.power && left > 0) return "running";
|
||||
return "off";
|
||||
}
|
||||
|
||||
function remainingMinutes(c, ha) {
|
||||
const s = ha.states[c.remaining];
|
||||
if (!s) return null;
|
||||
if (/^\d{4}-\d{2}-\d{2}T/.test(s.state)) return Math.max(0, Math.round((new Date(s.state) - Date.now()) / 60000));
|
||||
const n = Number(s.state);
|
||||
if (!isFinite(n)) return null;
|
||||
const unit = String(s.attributes.unit_of_measurement || "min").toLowerCase();
|
||||
return Math.round(unit.startsWith("h") ? n * 60 : unit === "s" ? n / 60 : n);
|
||||
}
|
||||
const fmtDuration = (m) => (m >= 60 ? t("{0} h {1} min", Math.floor(m / 60), m % 60) : t("{0} min", m));
|
||||
|
||||
W.appliance = {
|
||||
label: "Appliance", icon: "washer", size: [4, 2],
|
||||
fields: [
|
||||
{ k: "name", t: "text", l: "Name", d: "Washing machine" },
|
||||
{ k: "kind", t: "seg", l: "Appliance", o: ["washer", "dryer", "dishwasher", "other"], d: "washer" },
|
||||
{ k: "status", t: "entity", l: "Status", domains: ["sensor", "binary_sensor", "switch", "select", "input_select", "input_boolean"] },
|
||||
{ k: "power", t: "entity", l: "Power (W), if there is no status", domains: ["sensor"] },
|
||||
{ k: "threshold", t: "number", l: "Running above (W)", d: 5 },
|
||||
{ k: "remaining", t: "entity", l: "Time remaining or end time", domains: ["sensor"] },
|
||||
{ k: "progress", t: "entity", l: "Progress (%)", domains: ["sensor", "number"] },
|
||||
{ k: "program", t: "entity", l: "Program", domains: ["sensor", "select", "input_select"] },
|
||||
{ k: "hideWhenOff", t: "check", l: "Only show while running or finished", d: true },
|
||||
],
|
||||
entities: (c) => [c.status, c.power, c.remaining, c.progress, c.program].filter(Boolean),
|
||||
autoHide: (c, { ha }) => !!c.hideWhenOff && !!(c.status || c.power || c.remaining) && appliancePhase(c, ha) === "off",
|
||||
render(c, { ha }) {
|
||||
if (!c.status && !c.power && !c.remaining) return `<div class="empty">${t("Choose the appliance’s status, power or remaining-time entity in the inspector.")}</div>`;
|
||||
const phase = appliancePhase(c, ha);
|
||||
const label = { running: t("In progress"), done: t("Finished"), paused: t("Paused"), off: t("Off") }[phase];
|
||||
const left = phase === "running" || phase === "paused" ? remainingMinutes(c, ha) : null;
|
||||
const prog = Number(ha.states[c.progress]?.state);
|
||||
const program = ha.states[c.program]?.state;
|
||||
const ends = left != null ? new Date(Date.now() + left * 60000).toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) : "";
|
||||
const ico = { washer: "washer", dryer: "dryer", dishwasher: "dishwasher", other: "plug" }[c.kind] || "washer";
|
||||
const showProgram = program && !["unknown", "unavailable", "none", "off", ""].includes(String(program).toLowerCase());
|
||||
return `<div class="appl phase-${phase}">
|
||||
<div class="tile-row"><span class="badge">${Icons.svg(ico)}</span>
|
||||
<span class="tile-text"><span class="name">${esc(c.name || t("Appliance"))}</span>${showProgram ? `<span class="state">${esc(st(program))}</span>` : ""}</span>
|
||||
<span class="appl-status">${esc(label)}</span></div>
|
||||
${phase === "done" ? `<div class="appl-big">${t("Ready to unload")}</div>` : left != null ? `<div class="appl-big"><span class="num">${esc(fmtDuration(left))}</span> <span class="wt">${t("left · ready at {0}", ends)}</span></div>` : ""}
|
||||
${isFinite(prog) && c.progress && phase !== "off" ? `<div class="car-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(prog)}" aria-label="${t("Progress")}"><span class="car-fill" style="width:${Math.max(0, Math.min(100, prog))}%"></span></div>` : ""}
|
||||
</div>`;
|
||||
},
|
||||
lit: (c, { ha }) => (["running", "done"].includes(appliancePhase(c, ha)) ? "on" : false),
|
||||
};
|
||||
|
||||
W.camera = {
|
||||
|
||||
Reference in New Issue
Block a user