- ${imageUrl
- ? html`
-
- `
- : html`
-
- `}
+
+ ${imageUrl
+ ? html`
+
+ `
+ : html`
+
+ `}
+ ${badge
+ ? html`
+
+ `
+ : null}
+
= {
+ cooling: "var(--rgb-state-climate-cool-color)",
+ drying: "var(--rgb-state-climate-dry-color)",
+ heating: "var(--rgb-state-climate-heat-color)",
+ idle: "var(--rgb-state-climate-idle-color)",
+ off: "var(--rgb-state-climate-off-color)",
+};
+
+export const CLIMATE_HVAC_ACTION_ICONS: Record = {
+ cooling: mdiSnowflake,
+ drying: mdiWaterPercent,
+ heating: mdiFire,
+ idle: mdiClockOutline,
+ off: mdiPower,
+};
+
+export const computeClimateBadge: ComputeBadgeFunction = (stateObj) => {
+ const hvacAction = (stateObj as ClimateEntity).attributes.hvac_action;
+
+ if (!hvacAction || hvacAction === "off") {
+ return undefined;
+ }
+
+ return {
+ iconPath: CLIMATE_HVAC_ACTION_ICONS[hvacAction],
+ color: CLIMATE_HVAC_ACTION_COLORS[hvacAction],
+ };
+};
diff --git a/src/panels/lovelace/cards/tile/badges/tile-badge-person.ts b/src/panels/lovelace/cards/tile/badges/tile-badge-person.ts
new file mode 100644
index 0000000000..7eaa3ea340
--- /dev/null
+++ b/src/panels/lovelace/cards/tile/badges/tile-badge-person.ts
@@ -0,0 +1,44 @@
+import { mdiHelp, mdiHome, mdiHomeExportOutline } from "@mdi/js";
+import { HassEntity } from "home-assistant-js-websocket";
+import { UNAVAILABLE_STATES } from "../../../../../data/entity";
+import { HomeAssistant } from "../../../../../types";
+import { ComputeBadgeFunction } from "./tile-badge";
+
+function getZone(entity: HassEntity, hass: HomeAssistant) {
+ const state = entity.state;
+ if (state === "home" || state === "not_home") return undefined;
+
+ const zones = Object.values(hass.states).filter((stateObj) =>
+ stateObj.entity_id.startsWith("zone.")
+ );
+
+ return zones.find((z) => state === z.attributes.friendly_name);
+}
+
+function personBadgeIcon(entity: HassEntity) {
+ const state = entity.state;
+ if (UNAVAILABLE_STATES.includes(state)) {
+ return mdiHelp;
+ }
+ return state === "not_home" ? mdiHomeExportOutline : mdiHome;
+}
+
+function personBadgeColor(entity: HassEntity, inZone?: boolean) {
+ if (inZone) {
+ return "var(--rgb-state-person-zone-color)";
+ }
+ const state = entity.state;
+ return state === "not_home"
+ ? "var(--rgb-state-person-not-home-color)"
+ : "var(--rgb-state-person-home-color)";
+}
+
+export const computePersonBadge: ComputeBadgeFunction = (stateObj, hass) => {
+ const zone = getZone(stateObj, hass);
+
+ return {
+ iconPath: personBadgeIcon(stateObj),
+ icon: zone?.attributes.icon,
+ color: personBadgeColor(stateObj, Boolean(zone)),
+ };
+};
diff --git a/src/panels/lovelace/cards/tile/badges/tile-badge.ts b/src/panels/lovelace/cards/tile/badges/tile-badge.ts
new file mode 100644
index 0000000000..eebda6b5e4
--- /dev/null
+++ b/src/panels/lovelace/cards/tile/badges/tile-badge.ts
@@ -0,0 +1,29 @@
+import { HassEntity } from "home-assistant-js-websocket";
+import { computeDomain } from "../../../../../common/entity/compute_domain";
+import { HomeAssistant } from "../../../../../types";
+import { computeClimateBadge } from "./tile-badge-climate";
+import { computePersonBadge } from "./tile-badge-person";
+
+export type TileBadge = {
+ color: string;
+ icon?: string;
+ iconPath?: string;
+};
+
+export type ComputeBadgeFunction = (
+ stateObj: HassEntity,
+ hass: HomeAssistant
+) => TileBadge | undefined;
+
+export const computeTileBadge: ComputeBadgeFunction = (stateObj, hass) => {
+ const domain = computeDomain(stateObj.entity_id);
+ switch (domain) {
+ case "person":
+ case "device_tracker":
+ return computePersonBadge(stateObj, hass);
+ case "climate":
+ return computeClimateBadge(stateObj, hass);
+ default:
+ return undefined;
+ }
+};
diff --git a/src/panels/lovelace/components/hui-image.ts b/src/panels/lovelace/components/hui-image.ts
index fcf1654a0a..555bca93ad 100644
--- a/src/panels/lovelace/components/hui-image.ts
+++ b/src/panels/lovelace/components/hui-image.ts
@@ -16,6 +16,7 @@ import { CameraEntity, fetchThumbnailUrlWithCache } from "../../../data/camera";
import { UNAVAILABLE } from "../../../data/entity";
import { HomeAssistant } from "../../../types";
import "../../../components/ha-circular-progress";
+import type { HaCameraStream } from "../../../components/ha-camera-stream";
const UPDATE_INTERVAL = 10000;
const DEFAULT_FILTER = "grayscale(100%)";
@@ -65,9 +66,9 @@ export class HuiImage extends LitElement {
@state() private _loadedImageSrc?: string;
- private _intersectionObserver?: IntersectionObserver;
+ @state() private _lastImageHeight?: number;
- private _lastImageHeight?: number;
+ private _intersectionObserver?: IntersectionObserver;
private _cameraUpdater?: number;
@@ -192,6 +193,8 @@ export class HuiImage extends LitElement {
style=${styleMap({
paddingBottom: useRatio
? `${((100 * this._ratio!.h) / this._ratio!.w).toFixed(2)}%`
+ : !this._lastImageHeight
+ ? "56.25%"
: undefined,
backgroundImage:
useRatio && this._loadedImageSrc
@@ -203,7 +206,7 @@ export class HuiImage extends LitElement {
: undefined,
})}
class="container ${classMap({
- ratio: useRatio,
+ ratio: useRatio || !this._lastImageHeight,
})}"
>
${this.cameraImage && this.cameraView === "live"
@@ -212,6 +215,7 @@ export class HuiImage extends LitElement {
muted
.hass=${this.hass}
.stateObj=${cameraObj}
+ @load=${this._onVideoLoad}
>
`
: imageSrc === undefined
@@ -235,7 +239,7 @@ export class HuiImage extends LitElement {
id="brokenImage"
style=${styleMap({
height: !useRatio
- ? `${this._lastImageHeight || "100"}px`
+ ? `${this._lastImageHeight}px` || "100%"
: undefined,
})}
> `
@@ -245,7 +249,7 @@ export class HuiImage extends LitElement {
class="progress-container"
style=${styleMap({
height: !useRatio
- ? `${this._lastImageHeight || "100"}px`
+ ? `${this._lastImageHeight}px` || "100%"
: undefined,
})}
>
@@ -322,6 +326,13 @@ export class HuiImage extends LitElement {
this._lastImageHeight = imgEl.offsetHeight;
}
+ private async _onVideoLoad(ev: Event): Promise