diff --git a/README.md b/README.md index 0cb0e155..9b90c3dd 100644 --- a/README.md +++ b/README.md @@ -874,6 +874,7 @@ All variables listed are under a `conditions:` section. | `fullscreen` | If `true` the condition is satisfied if the card is in fullscreen mode. If `false` the condition is satisfied if the card is **NOT** in fullscreen mode.| | `state` | A list of state conditions to compare with Home Assistant state. See below. | | `mediaLoaded` | If `true` the condition is satisfied if there is media load**ED** (not load**ING**) in the card (e.g. a clip, snapshot or live view). This may be used to hide controls during media loading or when a message (not media) is being displayed. Note that if `true` this condition will never be satisfied for views that do not themselves load media directly (e.g. gallery).| +| `media_query` | Any valid [media query](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries) string. Media queries must start and end with parentheses. This may be used to alter card configuration based on device/media properties (e.g. viewport width, orientation). Please note that `width` and `height` refer to the entire viewport not just the card. See the [media query example](#media-query-example).| See the [example below](#frigate-card-conditional-example) for a real-world example of how these conditions can be used. @@ -2960,8 +2961,55 @@ overrides: ``` + - +### Using Media Query conditions + +Alter the card configuration based on device or viewport properties. + +
+ Expand: Hide menu & controls when viewport width <= 300 (e.g. PIP mode) + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.back_yard + - camera_entity: camera.sitting_room +overrides: + - conditions: + media_query: '(max-width: 300px)' + overrides: + menu: + style: none + live: + controls: + next_previous: + style: none + thumbnails: + mode: none +``` +
+ +
+ Expand: Change menu position when orientation changes + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.back_yard + - camera_entity: camera.sitting_room +menu: + style: overlay +overrides: + - conditions: + media_query: '(orientation: landscape)' + overrides: + menu: + position: left +``` +
+ + ## Card Refreshes diff --git a/src/card-condition.ts b/src/card-condition.ts index cff5bd88..b5af413c 100644 --- a/src/card-condition.ts +++ b/src/card-condition.ts @@ -1,5 +1,7 @@ -import type { +import { FrigateCardCondition, + FrigateCardConfig, + frigateConditionalSchema, OverrideConfigurationKey, RawFrigateCardConfig, } from './types'; @@ -54,6 +56,9 @@ export function evaluateCondition( result &&= state.mediaLoaded !== undefined && condition.mediaLoaded == state.mediaLoaded; } + if (condition?.media_query) { + result &&= window.matchMedia(condition.media_query).matches; + } return result; } @@ -138,3 +143,82 @@ export function getOverridesByKey( })) ?? [] ); } + +export class CardConditionManager { + // Whether or not to include HA state in ConditionState. Doing so increases + // CPU usage as HA state is pumped out very fast, so this is only enabled if + // the configuration needs to consume it. + protected _hasHAStateConditions = false; + + protected _callback: () => void; + protected _mediaQueries: MediaQueryList[] = []; + protected _boundTriggerChange = this._triggerChange.bind(this); + + constructor(config: FrigateCardConfig, callback: () => void) { + this._initConditions(config); + this._callback = callback; + } + + /** + * Destroy the object. + */ + public destroy(): void { + this._mediaQueries.forEach((mql) => + mql.removeEventListener('change', this._boundTriggerChange), + ); + this._mediaQueries = []; + } + + /** + * Determine if the conditions have state conditions. + */ + get hasHAStateConditions(): boolean { + return this._hasHAStateConditions; + } + + /** + * Trigger the callback. + * @param _ Ignored parameter. + */ + protected _triggerChange(_): void { + this._callback(); + } + + /** + * Init the conditions. + * @param config The card configuration. + */ + protected _initConditions(config: FrigateCardConfig): void { + const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => { + const conditions: FrigateCardCondition[] = []; + config.overrides?.forEach((override) => conditions.push(override.conditions)); + + // Element conditions can be arbitrarily nested underneath conditionals and + // custom elements that this card may not known. Here we recursively parse + // down the elements tree, parsing as we go to find valid conditions. + const getElementsConditions = (data: unknown): void => { + const parseResult = frigateConditionalSchema.safeParse(data); + if (parseResult.success) { + conditions.push(parseResult.data.conditions); + parseResult.data.elements?.forEach(getElementsConditions); + } else if (data && typeof data === 'object') { + Object.keys(data).forEach((key) => getElementsConditions(data[key])); + } + }; + config.elements?.forEach(getElementsConditions); + return conditions; + }; + + const conditions = getAllConditions(config); + this._hasHAStateConditions = conditions.some( + (condition) => !!condition.state?.length, + ); + conditions.forEach((condition) => { + if (condition.media_query) { + const mql = window.matchMedia(condition.media_query); + mql.addEventListener('change', this._boundTriggerChange); + this._mediaQueries.push(mql); + } + }); + } +} diff --git a/src/card.ts b/src/card.ts index 4a9ec670..48b86397 100644 --- a/src/card.ts +++ b/src/card.ts @@ -17,6 +17,7 @@ import screenfull from 'screenfull'; import { z } from 'zod'; import { actionHandler } from './action-handler-directive.js'; import { + CardConditionManager, ConditionState, conditionStateRequestHandler, getOverriddenConfig, @@ -212,10 +213,7 @@ export class FrigateCard extends LitElement { protected _triggers: Map = new Map(); protected _untriggerTimerID: number | null = null; - // Whether or not to include HA state in ConditionState. Doing so increases - // CPU usage as HA state is pumped out very fast, so this is only enabled if - // the configuration is likely to consume it. - protected _needHAStateInConditionState = false; + protected _conditionManager: CardConditionManager | null = null; /** * Set the Home Assistant object. @@ -238,7 +236,7 @@ export class FrigateCard extends LitElement { } } - if (this._needHAStateInConditionState) { + if (this._conditionManager?.hasHAStateConditions) { // HA entity state is part of the condition state. this._generateConditionState(); } @@ -288,9 +286,9 @@ export class FrigateCard extends LitElement { fullscreen: screenfull.isEnabled && screenfull.isFullscreen, camera: this._view?.camera, mediaLoaded: !!this._currentMediaLoadedInfo, - ...(this._needHAStateInConditionState && { + ...(this._conditionManager?.hasHAStateConditions && { state: this._hass?.states, - }) + }), }; // Update the components that need the new condition state. Passed directly @@ -822,7 +820,9 @@ export class FrigateCard extends LitElement { // Load all cameras in parallel, but remember the order they were provided // (they must be added to the cameraMap in this same order). - await Promise.all(configCameras.map((configCamera, index) => addCameraConfig(configCamera, index))) + await Promise.all( + configCameras.map((configCamera, index) => addCameraConfig(configCamera, index)), + ); loadedCameras.forEach((loadedCamera: CameraConfig) => { const id = getCameraID(loadedCamera); @@ -990,24 +990,16 @@ export class FrigateCard extends LitElement { this._cameras = undefined; this._view = undefined; this._message = null; + + this._conditionManager?.destroy(); + this._conditionManager = new CardConditionManager( + config, + this._generateConditionState.bind(this), + ); + this._generateConditionState(); this._setLightOrDarkMode(); this._untrigger(); - - const hasStateCondition = (): boolean => { - for (const override of this._config.overrides ?? []) { - if (override.conditions.state?.length) { - return true; - } - } - // For elements there can be arbitrary custom elements that not related to - // this card so we cannot easily determine if state is used further down - // the chain by a custom:frigate-card-conditional element. Instead, include state - // if elements are configured at all. - return !!this._config.elements; - } - - this._needHAStateInConditionState = hasStateCondition(); } /** @@ -1970,7 +1962,7 @@ export class FrigateCard extends LitElement { this._changeView({ resetMessage: false }); return this._render(); })(), - renderProgressIndicator({cardWideConfig: this._cardWideConfig}), + renderProgressIndicator({ cardWideConfig: this._cardWideConfig }), ) : // Always want to call render even if there's a message, to // ensure live preload is always present (even if not displayed). diff --git a/src/types.ts b/src/types.ts index 870f117a..e7239ddb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -495,10 +495,11 @@ const frigateCardConditionSchema = z.object({ camera: z.string().array().optional(), mediaLoaded: z.boolean().optional(), state: stateConditions.optional(), + media_query: z.string().optional(), }); export type FrigateCardCondition = z.infer; -const frigateConditionalSchema = z.object({ +export const frigateConditionalSchema = z.object({ type: z.literal('custom:frigate-card-conditional'), conditions: frigateCardConditionSchema, elements: z.lazy(() => pictureElementsSchema),