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 -13
View File
@@ -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 = {