diff --git a/docs/configuration/view.md b/docs/configuration/view.md
index a33c5c71..645b7b10 100644
--- a/docs/configuration/view.md
+++ b/docs/configuration/view.md
@@ -11,13 +11,14 @@ view:
| ------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions` | | [Actions](actions/README.md) to use for all views, individual actions may be overriden by view-specific actions. |
| `camera_select` | `current` | The [view](view.md?id=supported-views) to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. |
-| `dark_mode` | `off` | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`. |
+| `dim` | `false` | Whether or not to 'dim' the brightness of the card (by 25%) if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time). |
| `default` | `live` | The view to show in the card by default. The default camera is the first one listed. See [Supported Views](view.md?id=supported-views) below. |
| `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See below. |
-| `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. `0` means no interactions are reported / acted upon. |
+| `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. |
| `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. |
| `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). |
| `reset_after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. |
+| `theme` | | How the card is themed. See below. |
| `triggers` | | How to react when a camera is [triggered](cameras/README.md?id=triggers). |
| `default_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. |
@@ -63,6 +64,39 @@ Configure the key-bindings for the builtin keyboard shortcuts. See [usage](../us
| alt | `false` | If `true` requires the `alt` key to be held. |
| meta | `false` | If `true` requires the `meta` key to be held. |
+## `theme` 🎨
+
+All configuration is under:
+
+```yaml
+view:
+ theme: [...]
+```
+
+Configure the theming/colors applied to the card.
+
+| Option | Default | Description |
+| ----------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `themes` | `[traditional]` | A list of themes that are applied sequentially. Valid themes are shown below. Usually only a single value is needed. An empty list is treated the same as the default. |
+| `overrides` | | A list of CSS keys that can be used to tweak the theming. |
+
+### `themes`
+
+| Theme | Description |
+| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| `dark` | Use a dark theme that is identical to the HA dark theme. |
+| `ha` | Uses HA-prescribed theming. Respects HA choice of dark or light colors. |
+| `light` | Use a light theme that is similar to the HA light theme (there are some differences if you do not use the standard choices of primary color). |
+| `traditional` | A theme based on the default Frigate card theme before full theming support was added. Respects HA color theme choices. |
+
+### `overrides`
+
+Allows overriding of any CSS value, can be used to tweak theming parameters.
+
+| Option | Description |
+| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
+| Any CSS key. Overriding the [Frigate Card](https://github.com/dermotduffy/frigate-hass-card/tree/main/src/scss/themes/base.scss) CSS variables allows changing individual theming paramters, e.g. `--frigate-card-menu-override-background` | Any CSS value, e.g. `red` or `rgba(10, 11, 12, 0.64)`. |
+
## `triggers`
The `triggers` block controls how the card reacts when a camera is triggered (note that _what_ triggers the camera is controlled by the [`triggers`](cameras/README.md?id=triggers) block within the config for a given camera). This can be used for a variety of purposes, such as allowing the card to automatically change to `live` for a camera that triggers.
@@ -136,7 +170,7 @@ view:
interaction_mode: inactive
render_entities:
- switch.render_card
- dark_mode: 'off'
+ dim: false
triggers:
show_trigger_status: false
filter_selected_camera: true
@@ -161,6 +195,12 @@ view:
key: '-'
ptz_home:
key: 'h'
+ theme:
+ themes:
+ - ha
+ overrides:
+ '--frigate-card-menu-button-active-color': red
+ '--frigate-card-menu-position-left-style-overlay-alignment-left-background': pink
actions:
entity: light.office_main_lights
tap_action:
diff --git a/docs/images/icons/iris.svg b/docs/images/icons/iris.svg
new file mode 100644
index 00000000..0616d097
--- /dev/null
+++ b/docs/images/icons/iris.svg
@@ -0,0 +1,49 @@
+
+
diff --git a/docs/index.html b/docs/index.html
index 973ccb40..bc9cfc82 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -31,7 +31,7 @@
.svg-icon {
background-color: rgb(255, 255, 255);
vertical-align: middle;
- background: repeating-conic-gradient(#8080804f 0% 25%, transparent 0% 50%) 50% /
+ background: repeating-conic-gradient(#80808091 0% 25%, transparent 0% 50%) 50% /
4px 4px;
}
diff --git a/docs/usage/custom-icons.md b/docs/usage/custom-icons.md
index 558380dc..ce309191 100644
--- a/docs/usage/custom-icons.md
+++ b/docs/usage/custom-icons.md
@@ -8,5 +8,6 @@ builtin custom items not found in the traditional [Material Design Icons
| Icon Name | Resulting Icon |
| ----------- | --------------------------------------------------------------------------- |
| `frigate` |  |
+| `iris` |  |
| `motioneye` |  |
| `reolink` |  |
diff --git a/package.json b/package.json
index 467d2fcb..ed742528 100644
--- a/package.json
+++ b/package.json
@@ -81,6 +81,7 @@
"rollup-plugin-git-info": "^1.0.0",
"rollup-plugin-serve": "^1.1.1",
"rollup-plugin-styler": "^1.8.0",
+ "rollup-plugin-svgo": "^2.0.0",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "patch:sass@npm%3A1.77.4#~/.yarn/patches/sass-npm-1.77.4-13b6910aea.patch",
"semantic-release": "^24.1.1",
diff --git a/rollup.config.js b/rollup.config.js
index c6202371..ede62169 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -8,6 +8,7 @@ import typescript from '@rollup/plugin-typescript';
import gitInfo from 'rollup-plugin-git-info';
import serve from 'rollup-plugin-serve';
import styles from 'rollup-plugin-styler';
+import svgo from 'rollup-plugin-svgo';
import { visualizer } from 'rollup-plugin-visualizer';
const watch = process.env.ROLLUP_WATCH === 'true' || process.env.ROLLUP_WATCH === '1';
@@ -40,7 +41,8 @@ const plugins = [
includePaths: ['./node_modules/'],
},
}),
- image(),
+ svgo(),
+ image({ exclude: '**/*.svg' }),
nodeResolve({
browser: true,
}),
diff --git a/scripts/docs-update-icons.sh b/scripts/docs-update-icons.sh
index 32dc1399..0bb7de8a 100755
--- a/scripts/docs-update-icons.sh
+++ b/scripts/docs-update-icons.sh
@@ -3,6 +3,7 @@
# This script copies icons out of the source tree for documentation.
FRIGATE="./src/camera-manager/frigate/assets/frigate.svg"
+IRIS="./src/images/iris.svg"
MOTIONEYE="./src/camera-manager/motioneye/assets/motioneye.svg"
REOLINK="./src/camera-manager/reolink/assets/reolink.svg"
DIR_ICONS="./docs/images/icons"
@@ -14,5 +15,6 @@ copy() {
}
copy "$FRIGATE"
+copy "$IRIS"
copy "$MOTIONEYE"
copy "$REOLINK"
diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts
index f8408720..2aaa033e 100644
--- a/src/card-controller/config/config-manager.ts
+++ b/src/card-controller/config/config-manager.ts
@@ -97,7 +97,6 @@ export class ConfigManager {
this._api.getViewManager().reset();
this._api.getMessageManager().reset();
- this._api.getStyleManager().setPerformance();
this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems();
setKeyboardShortcutsFromConfig(this._api, this);
@@ -134,7 +133,7 @@ export class ConfigManager {
const previousConfig = this._overriddenConfig;
this._overriddenConfig = overriddenConfig;
- this._api.getStyleManager().setMinMaxHeight();
+ this._api.getStyleManager().updateFromConfig();
if (
previousConfig &&
diff --git a/src/card-controller/hass/hass-manager.ts b/src/card-controller/hass/hass-manager.ts
index 86ce88eb..fe967abf 100644
--- a/src/card-controller/hass/hass-manager.ts
+++ b/src/card-controller/hass/hass-manager.ts
@@ -53,8 +53,8 @@ export class HASSManager {
});
}
- // Dark mode may depend on HASS.
- this._api.getStyleManager().setLightOrDarkMode();
+ // Theme may depend on HASS.
+ this._api.getStyleManager().applyTheme();
this._stateWatcher.setHASS(oldHass, hass);
}
diff --git a/src/card-controller/interaction-manager.ts b/src/card-controller/interaction-manager.ts
index 287fe4ca..546ee633 100644
--- a/src/card-controller/interaction-manager.ts
+++ b/src/card-controller/interaction-manager.ts
@@ -1,10 +1,12 @@
import throttle from 'lodash-es/throttle';
+import { setOrRemoveAttribute } from '../utils/basic';
import { Timer } from '../utils/timer';
import { CardInteractionAPI } from './types';
export class InteractionManager {
protected _timer = new Timer();
protected _api: CardInteractionAPI;
+ protected _interacted = false;
constructor(api: CardInteractionAPI) {
this._api = api;
@@ -17,29 +19,32 @@ export class InteractionManager {
}, 1 * 1000);
public initialize(): void {
- this._api.getConditionsManager().setState({ interaction: false });
+ this._setInteraction(false);
}
public hasInteraction(): boolean {
- return this._timer.isRunning();
+ return this._interacted;
+ }
+
+ protected _setInteraction(val: boolean): void {
+ this._interacted = val;
+ setOrRemoveAttribute(
+ this._api.getCardElementManager().getElement(),
+ val,
+ 'interaction',
+ );
+ this._api.getConditionsManager().setState({ interaction: val });
}
- /**
- * Start the user interaction ('screensaver') timer to reset the view to
- * default `view.interaction_seconds` after user interaction.
- */
protected _reportInteraction(): void {
this._timer.stop();
+ this._setInteraction(true);
const timeoutSeconds = this._api.getConfigManager().getConfig()
?.view.interaction_seconds;
-
if (timeoutSeconds) {
- this._api.getConditionsManager().setState({ interaction: true });
-
this._timer.start(timeoutSeconds, () => {
- this._api.getConditionsManager().setState({ interaction: false });
- this._api.getStyleManager().setLightOrDarkMode();
+ this._setInteraction(false);
});
}
}
diff --git a/src/card-controller/style-manager.ts b/src/card-controller/style-manager.ts
index e959e0f3..894673a1 100644
--- a/src/card-controller/style-manager.ts
+++ b/src/card-controller/style-manager.ts
@@ -1,5 +1,10 @@
import { StyleInfo } from 'lit/directives/style-map';
-import { FrigateCardConfig } from '../config/types';
+import {
+ FrigateCardConfig,
+ frigateCardConfigDefaults,
+ ThemeConfig,
+ ThemeName,
+} from '../config/types';
import { aspectRatioToStyle, setOrRemoveAttribute } from '../utils/basic';
import { View } from '../view/view';
import { CardStyleAPI } from './types';
@@ -11,21 +16,6 @@ export class StyleManager {
this._api = api;
}
- public setLightOrDarkMode = (): void => {
- const config = this._api.getConfigManager().getConfig();
- const isDarkMode =
- config?.view.dark_mode === 'on' ||
- (config?.view.dark_mode === 'auto' &&
- (!this._api.getInteractionManager().hasInteraction() ||
- !!this._api.getHASSManager().getHASS()?.themes.darkMode));
-
- setOrRemoveAttribute(
- this._api.getCardElementManager().getElement(),
- isDarkMode,
- 'dark',
- );
- };
-
public setExpandedMode(): void {
const card = this._api.getCardElementManager().getElement();
const view = this._api.getViewManager().getView();
@@ -58,7 +48,47 @@ export class StyleManager {
);
}
- public setMinMaxHeight(): void {
+ public updateFromConfig(): void {
+ this.applyTheme();
+ this._setMinMaxHeight();
+ this._setPerformance();
+ this._setDimmable();
+ }
+
+ public applyTheme() {
+ const themeConfig = this._api.getConfigManager().getConfig()?.view.theme;
+ if (!themeConfig) {
+ return;
+ }
+
+ const element = this._api.getCardElementManager().getElement();
+ const themes = this._getThemeNames(themeConfig);
+
+ setOrRemoveAttribute(element, !!themes, 'themes', themes?.join(' '));
+
+ if (themeConfig.overrides) {
+ for (const [key, value] of Object.entries(themeConfig.overrides)) {
+ element.style.setProperty(key, value);
+ }
+ }
+ }
+
+ protected _getThemeNames(themeConfig: ThemeConfig): ThemeName[] | null {
+ return themeConfig.themes.length
+ ? themeConfig.themes
+ : frigateCardConfigDefaults.view.theme.themes;
+ }
+
+ protected _setDimmable(): void {
+ const config = this._api.getConfigManager().getConfig();
+ setOrRemoveAttribute(
+ this._api.getCardElementManager().getElement(),
+ !!config?.view.dim,
+ 'dimmable',
+ );
+ }
+
+ protected _setMinMaxHeight(): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
const card = this._api.getCardElementManager().getElement();
@@ -66,7 +96,7 @@ export class StyleManager {
}
}
- public setPerformance(): void {
+ protected _setPerformance(): void {
const STYLE_DISABLE_MAP = {
box_shadow: 'none',
border_radius: '0px',
diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts
index f55dae66..e9e25e86 100644
--- a/src/card-controller/types.ts
+++ b/src/card-controller/types.ts
@@ -180,6 +180,7 @@ export interface CardInitializerAPI {
}
export interface CardInteractionAPI {
+ getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getStyleManager(): StyleManager;
diff --git a/src/card.ts b/src/card.ts
index 8ecdc343..f993804f 100644
--- a/src/card.ts
+++ b/src/card.ts
@@ -218,12 +218,14 @@ class FrigateCard extends LitElement {
const getContents = (kind: 'overlay' | 'outerlay'): TemplateResult => {
const shouldRenderMenu =
menuStyle !== 'none' &&
- ((menuStyle === 'outside' && kind === 'outerlay') ||
+ ((menuStyle === 'outside' && kind === 'outerlay' && menuPosition === position) ||
(menuStyle !== 'outside' && kind === 'overlay'));
const shouldRenderStatusBar =
statusBarStyle !== 'none' &&
- ((statusBarStyle === 'outside' && kind === 'outerlay') ||
+ ((statusBarStyle === 'outside' &&
+ kind === 'outerlay' &&
+ statusBarPosition === position) ||
(statusBarStyle !== 'outside' && kind === 'overlay'));
// Complex logic to try to always put the menu in the right-looking place.
@@ -233,8 +235,7 @@ class FrigateCard extends LitElement {
(menuPosition === 'bottom' &&
menuStyle === 'hidden' &&
statusBarStyle !== 'popup') ||
- (menuPosition === 'top' &&
- (menuStyle !== 'hidden' || statusBarStyle === 'popup'));
+ (menuPosition === 'top' && statusBarStyle === 'popup');
return html`
${shouldRenderMenu && renderMenuFirst ? this._renderMenu(menuPosition) : ''}
@@ -329,12 +330,20 @@ class FrigateCard extends LitElement {
return;
}
+ const outerlayUsed =
+ this._config?.menu.style === 'outside' ||
+ this._config?.status_bar.style === 'outside';
+
const mainClasses = {
main: true,
'curve-top':
- this._config?.menu.style !== 'outside' || this._config?.menu.position !== 'top',
+ !outerlayUsed ||
+ (this._config?.menu.position !== 'top' &&
+ this._config?.status_bar.position !== 'top'),
'curve-bottom':
- this._config?.menu.style !== 'outside' || this._config?.menu.position === 'top',
+ !outerlayUsed ||
+ (this._config?.menu.position !== 'bottom' &&
+ this._config?.status_bar.position !== 'bottom'),
};
const actions = this._controller.getActionsManager().getMergedActions();
diff --git a/src/components-lib/icon-controller.ts b/src/components-lib/icon-controller.ts
index 4b4b4ab7..97ab232e 100644
--- a/src/components-lib/icon-controller.ts
+++ b/src/components-lib/icon-controller.ts
@@ -1,9 +1,10 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
+import { HassEntity } from 'home-assistant-js-websocket';
import frigateSVG from '../camera-manager/frigate/assets/frigate.svg';
import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg';
import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg';
+import irisSVG from '../images/iris.svg';
import { Icon } from '../types';
-import { HassEntity } from 'home-assistant-js-websocket';
export class IconController {
public getCustomIcon(icon?: Icon): string | null {
@@ -14,6 +15,8 @@ export class IconController {
return motioneyeSVG;
case 'reolink':
return reolinkSVG;
+ case 'iris':
+ return irisSVG;
default:
return null;
}
diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts
index b6ff9eec..d7125df0 100644
--- a/src/components-lib/menu-button-controller.ts
+++ b/src/components-lib/menu-button-controller.ts
@@ -647,11 +647,11 @@ export class MenuButtonController {
if (critical) {
return {
animation: 'pulse 3s infinite',
- color: 'var(--error-color, white)',
+ color: 'var(--frigate-card-menu-button-critical-color)',
};
}
return {
- color: 'var(--primary-color, white)',
+ color: 'var(--frigate-card-menu-button-active-color)',
};
}
diff --git a/src/components-lib/status-bar-controller.ts b/src/components-lib/status-bar-controller.ts
index 172e2770..fc16323c 100644
--- a/src/components-lib/status-bar-controller.ts
+++ b/src/components-lib/status-bar-controller.ts
@@ -67,6 +67,10 @@ export class StatusBarController {
this._host.requestUpdate();
}
+ public getConfig(): StatusBarConfig | null {
+ return this._config;
+ }
+
public shouldRender(): boolean {
return this._items.some((item) => item.enabled !== false && item.sufficient);
}
diff --git a/src/components/icon.ts b/src/components/icon.ts
index a5d6fd23..94749f99 100644
--- a/src/components/icon.ts
+++ b/src/components/icon.ts
@@ -1,7 +1,13 @@
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
-import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
+import {
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+ unsafeCSS,
+} from 'lit';
import { customElement, property } from 'lit/decorators.js';
-import { styleMap } from 'lit/directives/style-map.js';
import { IconController } from '../components-lib/icon-controller';
import iconStyle from '../scss/icon.scss';
import { Icon } from '../types';
@@ -14,17 +20,35 @@ export class FrigateCardIcon extends LitElement {
@property({ attribute: false })
public icon?: Icon;
+ // Note: This attribute will allow non-active entity state styles (e.g. 'off',
+ // 'unavailable') to be overriden from outside the icon itself. This is useful
+ // in the menu / submenus where we want icons to follow menu theming, unless
+ // they are 'active'. This attribute is not used in code, but matched in
+ // icon.scss .
+ @property({ attribute: 'allow-override-non-active-styles', type: Boolean })
+ public allowOverrideNonActiveStyles = false;
+
private _controller = new IconController();
+ private _svg: HTMLElement | null = null;
+
+ protected willUpdate(changedProps: PropertyValues): void {
+ if (changedProps.has('icon')) {
+ const customIcon = this._controller.getCustomIcon(this.icon);
+ if (customIcon) {
+ const svgElement = document.createElement('svg');
+ svgElement.innerHTML = customIcon;
+ this._svg = svgElement;
+ } else {
+ this._svg = null;
+ }
+ }
+ }
protected render(): TemplateResult {
- const customIconURL = this._controller.getCustomIcon(this.icon);
- const style = styleMap(this.icon?.style || {});
-
- if (customIconURL) {
- return html``;
- }
- if (this.icon?.icon) {
- return html`
) to ensure styling applies
+ // correctly.
+ return html`${this._svg}`;
}
if (this.hass && this.icon?.entity) {
const stateObj = this._controller.createStateObjectForStateBadge(
@@ -35,16 +59,19 @@ export class FrigateCardIcon extends LitElement {
// As a special case, need to pass in a color in order for the state
// color to be overridden.
return html`
`;
+ return html`