diff --git a/README.md b/README.md index c9261299..a81fd506 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,10 @@ See the [action documentation](https://www.home-assistant.io/lovelace/actions/#hold-action) for more information on the action options available. +**Note**: The Frigate Card allows either a single action (as in stock Home +Assistant) or list of actions to be defined for each class of user interaction +(e.g. `tap`, `double_tap`, `hold`, etc). See [an example of multiple actions](#example-multiple-actions). + ### Special Elements This card supports all [Picture Elements](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) using the same syntax. The card also supports a handful of custom special elements to add special Frigate card functionality. @@ -551,16 +555,17 @@ Parameters for the `custom:frigate-card-conditional` element: | Action name | Description | | - | - | -| `custom:frigate-card-action` | Call a Frigate Card action. Acceptable values are `frigate`, `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots`, `download`, `frigate_ui`, `fullscreen`.| +| `custom:frigate-card-action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots`, `download`, `frigate_ui`, `fullscreen`, `camera_select`, `menu_toggle`.| | Value | Description | | - | - | -| `frigate` | Show/hide the menu or trigger the default view. | +| `default` | Trigger the default view. | | `clip`, `clips`, `image`, `live`, `snapshot`, `snapshots` | Trigger the named [view](#views).| |`download`|Download the displayed media.| |`frigate_ui`|Open the Frigate UI at the configured URL.| |`fullscreen`|Toggle fullscreen.| |`camera_select`|Select a given camera. Takes a single additional `camera` parameter with the [camera ID](#camera-ids) of the camera to select.| +|`menu_toggle` | Show/hide the menu (for `hidden-*` mode menus). | @@ -1111,6 +1116,29 @@ image: ``` + + +### Defining multiple actions for Elements + +
+ Expand: Changing camera and view simultaneously + +This example shows how to configure multiple actions for a single Frigate card user interaction, in this case both selecting a different camera and changing the view on `tap`. + +```yaml +[...] +elements: + - type: custom:frigate-card-menu-icon + icon: mdi:chair-rolling + tap_action: + - action: custom:frigate-card-action + frigate_card_action: camera_select + camera: camera.office + - action: custom:frigate-card-action + frigate_card_action: live +``` +
+ ## Card Refreshes diff --git a/src/card.ts b/src/card.ts index 8c3eacf4..d6743a36 100644 --- a/src/card.ts +++ b/src/card.ts @@ -14,7 +14,6 @@ import { HomeAssistant, LovelaceCardEditor, getLovelace, - hasAction, } from 'custom-card-helpers'; import screenfull from 'screenfull'; import { z } from 'zod'; @@ -23,10 +22,10 @@ import { Actions, ActionType, CameraConfig, - GetFrigateCardMenuButtonParameters, RawFrigateCardConfig, entitySchema, frigateCardConfigSchema, + FrigateCardCustomAction, } from './types.js'; import type { Entity, @@ -46,6 +45,7 @@ import { convertActionToFrigateCardCustomAction, createFrigateCardCustomAction, frigateCardHandleAction, + frigateCardHasAction, getActionConfigGivenAction, getCameraIcon, getCameraTitle, @@ -256,28 +256,6 @@ export class FrigateCard extends LitElement { }; } - /** - * Get a FrigateCard MenuButton given a set of parameters. - * @param params Menu button parameters. - * @returns A MenuButton. - */ - protected _getFrigateCardMenuButton( - params: GetFrigateCardMenuButtonParameters, - ): MenuButton { - return { - type: 'custom:frigate-card-menu-icon', - title: params.title, - icon: params.icon, - style: params.emphasize ? this._getEmphasizedStyle() : {}, - tap_action: params.tap_action - ? createFrigateCardCustomAction(params.tap_action) - : undefined, - hold_action: params.hold_action - ? createFrigateCardCustomAction(params.hold_action) - : undefined, - }; - } - /** * Get the menu buttons to display. * @returns An array of menu buttons. @@ -286,16 +264,17 @@ export class FrigateCard extends LitElement { const buttons: MenuButton[] = []; if (this._getConfig().menu.buttons.frigate) { - buttons.push( - this._getFrigateCardMenuButton({ - tap_action: 'frigate', - // Use a magic icon value that the menu will use to render the icon as - // it deems appropriate (certain menu configurations change the menu - // icon for the 'Frigate' button). - icon: FRIGATE_BUTTON_MENU_ICON, - title: localize('config.menu.buttons.frigate'), - }), - ); + buttons.push({ + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.frigate'), + // Use a magic icon value that the menu will use to render the icon as + // it deems appropriate (certain menu configurations change the menu + // icon for the 'Frigate' button). + icon: FRIGATE_BUTTON_MENU_ICON, + tap_action: FrigateCardMenu.isHidingMenu(this._getConfig().menu) + ? (createFrigateCardCustomAction('menu_toggle') as FrigateCardCustomAction) + : (createFrigateCardCustomAction('default') as FrigateCardCustomAction), + }); } if ( @@ -323,56 +302,58 @@ export class FrigateCard extends LitElement { } if (this._getConfig().menu.buttons.live) { - buttons.push( - this._getFrigateCardMenuButton({ - tap_action: 'live', - title: localize('config.view.views.live'), - icon: 'mdi:cctv', - emphasize: this._view?.is('live'), - }), - ); + buttons.push({ + type: 'custom:frigate-card-menu-icon', + title: localize('config.view.views.live'), + icon: 'mdi:cctv', + style: this._view?.is('live') ? this._getEmphasizedStyle() : {}, + tap_action: createFrigateCardCustomAction('live') as FrigateCardCustomAction, + }); } if (this._getConfig().menu.buttons.clips) { - buttons.push( - this._getFrigateCardMenuButton({ - tap_action: 'clips', - hold_action: 'clip', - title: localize('config.view.views.clips'), - icon: 'mdi:filmstrip', - emphasize: this._view?.is('clips'), - }), - ); + buttons.push({ + type: 'custom:frigate-card-menu-icon', + title: localize('config.view.views.clips'), + icon: 'mdi:filmstrip', + style: this._view?.is('clips') ? this._getEmphasizedStyle() : {}, + tap_action: createFrigateCardCustomAction('clips') as FrigateCardCustomAction, + hold_action: createFrigateCardCustomAction('clip') as FrigateCardCustomAction, + }); } + if (this._getConfig().menu.buttons.snapshots) { - buttons.push( - this._getFrigateCardMenuButton({ - tap_action: 'snapshots', - hold_action: 'snapshot', - title: localize('config.view.views.snapshots'), - icon: 'mdi:camera', - emphasize: this._view?.is('snapshots'), - }), - ); + buttons.push({ + type: 'custom:frigate-card-menu-icon', + title: localize('config.view.views.snapshots'), + icon: 'mdi:camera', + style: this._view?.is('snapshots') ? this._getEmphasizedStyle() : {}, + tap_action: createFrigateCardCustomAction( + 'snapshots', + ) as FrigateCardCustomAction, + hold_action: createFrigateCardCustomAction( + 'snapshot', + ) as FrigateCardCustomAction, + }); } + if (this._getConfig().menu.buttons.image) { - buttons.push( - this._getFrigateCardMenuButton({ - tap_action: 'image', - title: localize('config.view.views.image'), - icon: 'mdi:image', - emphasize: this._view?.is('image'), - }), - ); + buttons.push({ + type: 'custom:frigate-card-menu-icon', + title: localize('config.view.views.image'), + icon: 'mdi:image', + style: this._view?.is('image') ? this._getEmphasizedStyle() : {}, + tap_action: createFrigateCardCustomAction('image') as FrigateCardCustomAction, + }); } + if (this._getConfig().menu.buttons.download && this._view?.isViewerView()) { - buttons.push( - this._getFrigateCardMenuButton({ - tap_action: 'download', - title: localize('config.menu.buttons.download'), - icon: 'mdi:download', - }), - ); + buttons.push({ + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.download'), + icon: 'mdi:download', + tap_action: createFrigateCardCustomAction('download') as FrigateCardCustomAction, + }); } const cameraConfig = this._getSelectedCameraConfig(); @@ -381,22 +362,26 @@ export class FrigateCard extends LitElement { cameraConfig && cameraConfig.frigate_url ) { - buttons.push( - this._getFrigateCardMenuButton({ - tap_action: 'frigate_ui', - title: localize('config.menu.buttons.frigate_ui'), - icon: 'mdi:web', - }), - ); + buttons.push({ + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.frigate_ui'), + icon: 'mdi:web', + tap_action: createFrigateCardCustomAction( + 'frigate_ui', + ) as FrigateCardCustomAction, + }); } + if (this._getConfig().menu.buttons.fullscreen && screenfull.isEnabled) { - buttons.push( - this._getFrigateCardMenuButton({ - tap_action: 'fullscreen', - title: localize('config.menu.buttons.fullscreen'), - icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', - }), - ); + buttons.push({ + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.fullscreen'), + icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', + tap_action: createFrigateCardCustomAction( + 'fullscreen', + ) as FrigateCardCustomAction, + style: screenfull.isFullscreen ? this._getEmphasizedStyle() : {}, + }); } return buttons.concat(this._dynamicMenuButtons); } @@ -804,7 +789,7 @@ export class FrigateCard extends LitElement { const action = frigateCardAction.frigate_card_action; switch (action) { - case 'frigate': + case 'default': this._changeView(); break; case 'clip': @@ -874,16 +859,7 @@ export class FrigateCard extends LitElement { * Handle an action called on an element. * @param ev The actionHandler event. */ - protected _actionHandler( - ev: CustomEvent, - config?: { - hold_action?: ActionType; - tap_action?: ActionType; - double_tap_action?: ActionType; - start_tap_action?: ActionType; - end_tap_action?: ActionType; - }, - ): void { + protected _actionHandler(ev: CustomEvent, config?: Actions): void { const interaction = ev.detail.action; const node: HTMLElement | null = ev.currentTarget as HTMLElement | null; if ( @@ -1143,8 +1119,8 @@ export class FrigateCard extends LitElement { return html` this._actionHandler(ev, actions)} @ll-custom=${this._cardActionHandler.bind(this)} diff --git a/src/common.ts b/src/common.ts index f12c463c..34e5511a 100644 --- a/src/common.ts +++ b/src/common.ts @@ -1,15 +1,13 @@ import { HassEntity, MessageBase } from 'home-assistant-js-websocket'; -import { - HomeAssistant, - handleActionConfig, - stateIcon, -} from 'custom-card-helpers'; +import { HomeAssistant, handleActionConfig, hasAction, stateIcon } from 'custom-card-helpers'; import { StyleInfo } from 'lit/directives/style-map'; import { ZodSchema, z } from 'zod'; import { isEqual } from 'lodash-es'; import { localize } from './localize/localize.js'; import { + Actions, + ActionsConfig, ActionType, CameraConfig, ExtendedHomeAssistant, @@ -302,14 +300,8 @@ export function createFrigateCardCustomAction( */ export function getActionConfigGivenAction( interaction?: string, - config?: { - hold_action?: ActionType; - tap_action?: ActionType; - double_tap_action?: ActionType; - start_tap_action?: ActionType; - end_tap_action?: ActionType; - }, -): ActionType | undefined { + config?: Actions, +): ActionType | ActionType[] | undefined { if (!interaction || !config) { return undefined; } @@ -499,24 +491,63 @@ export function contentsChanged(n: unknown, o: unknown): boolean { * @param hass The Home Assistant object. * @param config The multi-action configuration. * @param action The action string (e.g. 'hold') + * @returns Whether or not an action was executed. */ export const frigateCardHandleAction = ( + node: HTMLElement, + hass: HomeAssistant, + config: ActionsConfig, + action: string, +): boolean => { + return frigateCardHandleActionConfig( + node, + hass, + config, + action, + getActionConfigGivenAction(action, config), + ); +}; + +/** + * Handle an ActionConfig or array of ActionConfigs. + * @param node The node that fired the event. + * @param hass The Home Assistant object. + * @param actionConfig A single action config, array of action configs or + * undefined for the default action config for 'tap'. + * @param action The action string (e.g. 'hold') + * @returns Whether or not an action was executed. + */ +export const frigateCardHandleActionConfig = ( node: HTMLElement, hass: HomeAssistant, config: { - entity?: string; camera_image?: string; - hold_action?: ActionType; - tap_action?: ActionType; - double_tap_action?: ActionType; - start_tap_action?: ActionType; - end_tap_action?: ActionType; + entity?: string; }, action: string, -): void => { - const actionConfig = getActionConfigGivenAction(action, config); + actionConfig: ActionType | ActionType[] | undefined, +): boolean => { if (actionConfig || action == 'tap') { // Only allow a tap action to use a default non-config (the more-info config). - handleActionConfig(node, hass, config, actionConfig); + if (Array.isArray(actionConfig)) { + actionConfig.forEach((action) => handleActionConfig(node, hass, config, action)); + } else { + handleActionConfig(node, hass, config, actionConfig); + } + return true; } + return false; }; + +/** + * Determine if an action config has a real action. A modified version of + * custom-card-helpers hasAction to also work with arrays of action configs. + * @param config The action config in question. + * @returns `true` if there's a real action defined, `false` otherwise. + */ +export const frigateCardHasAction = (config?: ActionType | ActionType[] | undefined): boolean => { + if (Array.isArray(config)) { + return !!config.find((item) => hasAction(item)); + } + return hasAction(config); +} \ No newline at end of file diff --git a/src/components/menu.ts b/src/components/menu.ts index 737bfa38..f1d48453 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -1,4 +1,4 @@ -import { HASSDomEvent, HomeAssistant, hasAction } from 'custom-card-helpers'; +import { HASSDomEvent, HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; @@ -9,7 +9,7 @@ import { actionHandler } from '../action-handler-directive.js'; import './submenu.js'; import type { - Actions, + ActionsConfig, ActionType, ExtendedHomeAssistant, MenuButton, @@ -18,7 +18,8 @@ import type { } from '../types.js'; import { convertActionToFrigateCardCustomAction, - frigateCardHandleAction, + frigateCardHandleActionConfig, + frigateCardHasAction, getActionConfigGivenAction, refreshDynamicStateParameters, } from '../common.js'; @@ -37,8 +38,6 @@ export class FrigateCardMenu extends LitElement { public hass?: HomeAssistant & ExtendedHomeAssistant; set menuConfig(menuConfig: MenuConfig) { - this.expanded = !menuConfig?.mode.startsWith('hidden-'); - this._menuConfig = menuConfig; if (menuConfig) { this.style.setProperty('--frigate-card-menu-button-size', menuConfig.button_size); @@ -53,11 +52,36 @@ export class FrigateCardMenu extends LitElement { @property({ attribute: false }) public buttons: MenuButton[] = []; - protected _isFrigateCardAction(action: ActionType): boolean { + /** + * Determine if a given menu configuration is a hiding menu. + * @param menuConfig The menu configuration. + * @returns `true` if the menu is hiding, `false` otherwise. + */ + static isHidingMenu(menuConfig: MenuConfig | undefined): boolean { + return menuConfig?.mode.startsWith('hidden-') ?? false; + } + + /** + * Determine if a given menu configuration is a hiding menu (internal version). + * @returns `true` if the menu is hiding, `false` otherwise. + */ + protected _isHidingMenu(): boolean { + return FrigateCardMenu.isHidingMenu(this._menuConfig); + } + + /** + * Determine if a given action is intended to toggle the menu. + * @param action The action to check. + * @returns `true` if the action toggles the menu, `false` otherwise. + */ + protected _isMenuToggleAction(action: ActionType | undefined): boolean { // Determine if this action is a Frigate card action, if so handle it // internally. + if (!action) { + return false; + } const frigateCardAction = convertActionToFrigateCardCustomAction(action); - return !!frigateCardAction && frigateCardAction.frigate_card_action == 'frigate'; + return !!frigateCardAction && frigateCardAction.frigate_card_action == 'menu_toggle'; } /** @@ -66,8 +90,8 @@ export class FrigateCardMenu extends LitElement { * @param button The button configuration. */ protected _actionHandler( - ev: HASSDomEvent<{ action: string; config?: Actions }>, - config?: Actions, + ev: HASSDomEvent<{ action: string; config?: ActionsConfig }>, + config?: ActionsConfig, ): void { if (!ev) { return; @@ -86,25 +110,56 @@ export class FrigateCardMenu extends LitElement { ev.stopPropagation(); const interaction: string = ev.detail.action; - const action = getActionConfigGivenAction(interaction, config); + let action = getActionConfigGivenAction(interaction, config); if (!config || !interaction) { return; } - if ( - action && - this._isFrigateCardAction(action) && - this._menuConfig?.mode.startsWith('hidden-') - ) { - // If the user presses the frigate button and it's a hide-away menu, - // then expand the menu and return. - this.expanded = !this.expanded; - return; + let tookAction = false; + let menuToggle = false; + + if (Array.isArray(action)) { + // Case 1: An array of actions. + // Strip out actions that toggle the menu. + const actionCount = action.length; + action = action.filter((item) => !this._isMenuToggleAction(item)); + if (action.length != actionCount) { + menuToggle = true; + } + + // If there are still actions left, handle them as usual. + if (action.length) { + tookAction = frigateCardHandleActionConfig( + this, + this.hass as HomeAssistant, + config, + interaction, + action, + ); + } + } else { + // Case 2: Either a specific action, or no action at all (i.e. default + // action for `tap`). + if (this._isMenuToggleAction(action)) { + menuToggle = true; + } else { + tookAction = frigateCardHandleActionConfig( + this, + this.hass as HomeAssistant, + config, + interaction, + action, + ); + } } - // Collapse menu after the user clicks on something. - this.expanded = false; - frigateCardHandleAction(this, this.hass as HomeAssistant, config, interaction); + if (this._isHidingMenu()) { + if (menuToggle) { + this.expanded = !this.expanded; + } else if (tookAction) { + this.expanded = false; + } + } } /** @@ -146,8 +201,8 @@ export class FrigateCardMenu extends LitElement { stateParameters = refreshDynamicStateParameters(this.hass, stateParameters); } - const hasHold = hasAction(button.hold_action); - const hasDoubleClick = hasAction(button.double_tap_action); + const hasHold = frigateCardHasAction(button.hold_action); + const hasDoubleClick = frigateCardHasAction(button.double_tap_action); const classes = { button: true, diff --git a/src/components/submenu.ts b/src/components/submenu.ts index fc45cbb9..b111d4db 100644 --- a/src/components/submenu.ts +++ b/src/components/submenu.ts @@ -1,11 +1,11 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators'; -import { hasAction, HomeAssistant } from 'custom-card-helpers'; +import { HomeAssistant } from 'custom-card-helpers'; import { styleMap } from 'lit/directives/style-map'; import { ExtendedHomeAssistant, MenuSubmenu, MenuSubmenuItem } from '../types.js'; import { actionHandler } from '../action-handler-directive.js'; -import { refreshDynamicStateParameters } from '../common.js'; +import { frigateCardHasAction, refreshDynamicStateParameters } from '../common.js'; import submenuStyle from '../scss/submenu.scss'; import type { Corner } from "@material/mwc-menu"; @@ -39,8 +39,8 @@ export class FrigateCardSubmenu extends LitElement { ev.detail.config = item; }} .actionHandler=${actionHandler({ - hasHold: hasAction(item.hold_action), - hasDoubleClick: hasAction(item.double_tap_action), + hasHold: frigateCardHasAction(item.hold_action), + hasDoubleClick: frigateCardHasAction(item.double_tap_action), })} > ${stateParameters.title || ''} @@ -71,8 +71,8 @@ export class FrigateCardSubmenu extends LitElement { slot="trigger" .label=${this.submenu.title || ''} .actionHandler=${actionHandler({ - hasHold: hasAction(this.submenu.hold_action), - hasDoubleClick: hasAction(this.submenu.double_tap_action), + hasHold: frigateCardHasAction(this.submenu.hold_action), + hasDoubleClick: frigateCardHasAction(this.submenu.double_tap_action), })} > diff --git a/src/types.ts b/src/types.ts index 060017df..7c6d7aa0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -119,7 +119,7 @@ const frigateCardCustomActionBaseSchema = customActionSchema.extend({ }); const FRIGATE_CARD_GENERAL_ACTIONS = [ - 'frigate', + 'default', 'clip', 'clips', 'image', @@ -129,6 +129,7 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'download', 'frigate_ui', 'fullscreen', + 'menu_toggle', ] as const; const FRIGATE_CARD_ACTIONS = [...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select'] as const; export type FrigateCardAction = typeof FRIGATE_CARD_ACTIONS[number]; @@ -159,17 +160,21 @@ export type ActionType = z.infer; const actionBaseSchema = z .object({ - tap_action: actionSchema.optional(), - hold_action: actionSchema.optional(), - double_tap_action: actionSchema.optional(), - start_tap_action: actionSchema.optional(), - end_tap_action: actionSchema.optional(), + tap_action: actionSchema.or(actionSchema.array()).optional(), + hold_action: actionSchema.or(actionSchema.array()).optional(), + double_tap_action: actionSchema.or(actionSchema.array()).optional(), + start_tap_action: actionSchema.or(actionSchema.array()).optional(), + end_tap_action: actionSchema.or(actionSchema.array()).optional(), }) // Passthrough to allow (at least) entity/camera_image to go through. This // card doesn't need these attributes, but handleAction() in // custom_card_helpers may depending on how the action is configured. .passthrough(); export type Actions = z.infer; +export type ActionsConfig = Actions & { + camera_image?: string; + entity?: string; +}; const actionsSchema = z.object({ actions: actionBaseSchema.optional(), @@ -746,14 +751,6 @@ export interface BrowseMediaQueryParameters { after?: number; } -export interface GetFrigateCardMenuButtonParameters { - icon: string; - title: string; - tap_action: FrigateCardAction; - hold_action?: FrigateCardAction; - emphasize?: boolean; -} - export interface BrowseMediaNeighbors { previous: BrowseMediaSource | null; previousIndex: number | null;