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
-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