diff --git a/package.json b/package.json index 94bd4a2a..437e41a7 100644 --- a/package.json +++ b/package.json @@ -16,28 +16,28 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^5.0.1", "@material/image-list": "^12.0.0", - "custom-card-helpers": "^1.7.2", - "dayjs": "^1.10.6", + "custom-card-helpers": "^1.8.0", + "dayjs": "^1.10.7", "home-assistant-js-websocket": "^5.11.1", - "lit": "^2.0.0-rc.2", + "lit": "^2.0.0", "screenfull": "^5.1.0", - "zod": "^3.8.2" + "zod": "^3.9.5" }, "devDependencies": { "@babel/core": "^7.15.5", "@babel/plugin-proposal-class-properties": "^7.14.5", "@babel/plugin-proposal-decorators": "^7.15.4", "@rollup/plugin-json": "^4.1.0", - "@typescript-eslint/eslint-plugin": "^4.30.0", - "@typescript-eslint/parser": "^4.30.0", + "@typescript-eslint/eslint-plugin": "^4.32.0", + "@typescript-eslint/parser": "^4.32.0", "eslint": "^7.32.0", "eslint-config-airbnb-base": "^14.2.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-import": "^2.24.2", "eslint-plugin-prettier": "^3.4.1", "npm-check-updates": "^11.8.5", - "prettier": "^2.3.2", - "rollup": "^2.56.3", + "prettier": "^2.4.1", + "rollup": "^2.58.0", "rollup-plugin-babel": "^4.4.0", "rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-node-resolve": "^5.2.0", @@ -45,8 +45,8 @@ "rollup-plugin-styles": "^3.14.1", "rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript2": "^0.30.0", - "sass": "^1.39.0", - "typescript": "^4.4.2" + "sass": "^1.42.1", + "typescript": "^4.4.3" }, "scripts": { "start": "rollup -c rollup.config.dev.js --watch", diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 220b36bd..822a6a31 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -7,10 +7,8 @@ import { } from 'custom-card-helpers/dist/types'; import { fireEvent } from 'custom-card-helpers'; -const isTouch = - 'ontouchstart' in window || - navigator.maxTouchPoints > 0 || - navigator.msMaxTouchPoints > 0; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || (navigator as any).msMaxTouchPoints > 0; interface ActionHandler extends HTMLElement { holdTime: number; diff --git a/src/card.ts b/src/card.ts index 932171d3..96acfc09 100644 --- a/src/card.ts +++ b/src/card.ts @@ -14,9 +14,9 @@ import { until } from 'lit/directives/until'; import { HomeAssistant, LovelaceCardEditor, - fireEvent, getLovelace, - stateIcon, + handleAction, + ActionHandlerEvent, } from 'custom-card-helpers'; import screenfull from 'screenfull'; @@ -33,7 +33,12 @@ import type { import { CARD_VERSION } from './const'; import { FrigateCardMenu, MENU_HEIGHT } from './components/menu'; import { View } from './view'; -import { getParseErrorKeys, homeAssistantWSRequest } from './common'; +import { actionHandler } from './action-handler-directive'; +import { + getParseErrorKeys, + homeAssistantWSRequest, + shouldUpdateBasedOnHass, +} from './common'; import { localize } from './localize/localize'; import { renderErrorMessage, renderProgressIndicator } from './components/message'; @@ -83,34 +88,6 @@ console.info( description: localize('common.frigate_card_description'), }); -// Determine whether the card should be updated based on Home Assistant changes. -function shouldUpdateBasedOnHass( - newHass: HomeAssistant | null, - oldHass: HomeAssistant | undefined, - entities: string[] | null, -): boolean { - if (!newHass || !entities) { - return false; - } - if (!entities.length) { - return false; - } - - if (oldHass) { - for (let i = 0; i < entities.length; i++) { - const entity = entities[i]; - if (!entity) { - continue; - } - if (oldHass.states[entity] !== newHass.states[entity]) { - return true; - } - } - return false; - } - return false; -} - // Main FrigateCard class. @customElement('frigate-card') export class FrigateCard extends LitElement { @@ -144,7 +121,13 @@ export class FrigateCard extends LitElement { set hass(hass: HomeAssistant & ExtendedHomeAssistant) { this._hass = hass; - this._updateMenu(); + + // Manually set hass in the menu. This is to allow the menu to update, + // without necessarily re-rendering the entire card (re-rendering interrupts + // clip playing). + if (this._menu && this._hass) { + this._menu.hass = this._hass; + } } // Get the configuration element. @@ -157,68 +140,66 @@ export class FrigateCard extends LitElement { return {}; } - protected _updateMenu(): void { - // Manually set hass in the menu. This is to allow the menu to update, - // without necessarily re-rendering the entire card (re-rendering interrupts - // clip playing). - if (!this._menu || !this._hass) { - return; - } - this._menu.buttons = this._getMenuButtons(); - } - - protected _getMenuButtons(): Map { - const buttons: Map = new Map(); + protected _getMenuButtons(): MenuButton[] { + const buttons: MenuButton[] = []; if (this.config.menu_buttons?.frigate ?? true) { - buttons.set('frigate', { description: localize('menu.frigate') }); + buttons.push({ + type: 'internal-menu-icon', + card_action: 'frigate', + title: localize('menu.frigate'), + }); } if (this.config.menu_buttons?.live ?? true) { - buttons.set('live', { + buttons.push({ + type: 'internal-menu-icon', + card_action: 'live', + title: localize('menu.live'), icon: 'mdi:cctv', - description: localize('menu.live'), emphasize: this._view.is('live'), }); } if (this.config.menu_buttons?.clips ?? true) { - buttons.set('clips', { + buttons.push({ + type: 'internal-menu-icon', + card_action: 'clips', + title: localize('menu.clips'), icon: 'mdi:filmstrip', - description: localize('menu.clips'), emphasize: this._view.is('clips'), }); } if (this.config.menu_buttons?.snapshots ?? true) { - buttons.set('snapshots', { + buttons.push({ + type: 'internal-menu-icon', + card_action: 'snapshots', + title: localize('menu.snapshots'), icon: 'mdi:camera', - description: localize('menu.snapshots'), emphasize: this._view.is('snapshots'), }); } if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) { - buttons.set('frigate_ui', { + buttons.push({ + type: 'internal-menu-icon', + card_action: 'frigate_ui', + title: localize('menu.frigate_ui'), icon: 'mdi:web', - description: localize('menu.frigate_ui'), }); } if ((this.config.menu_buttons?.fullscreen ?? true) && screenfull.isEnabled) { - buttons.set('fullscreen', { + buttons.push({ + type: 'internal-menu-icon', + card_action: 'fullscreen', + title: localize('menu.fullscreen'), icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', - description: localize('menu.fullscreen'), }); } - const entities = this.config.entities || []; - for (let i = 0; this._hass && i < entities.length; i++) { - if (!entities[i].show) { - continue; + const elements = this.config.elements || []; + for (let i = 0; this._hass && i < elements.length; i++) { + const element = elements[i]; + if (['menu-icon', 'menu-state-icon'].includes(element.type)) { + buttons.push(element); } - const entity = entities[i].entity; - const state = this._hass.states[entity]; - buttons.set(entity, { - description: state.attributes.friendly_name || entity, - emphasize: ['on', 'active', 'home'].includes(state.state), - icon: entities[i].icon || stateIcon(state), - }); } return buttons; } @@ -285,7 +266,7 @@ export class FrigateCard extends LitElement { this.config = config; this._entitiesToMonitor = [ - ...(this.config.entities || []).map((entity) => entity.entity), + ...(this.config.update_entities || []), this.config.camera_entity, ]; this._changeView(); @@ -328,15 +309,20 @@ export class FrigateCard extends LitElement { return true; } - protected _menuActionHandler(name: string): void { - switch (name) { + protected _menuActionHandler(action: string, button: MenuButton): void { + if (button.type != 'internal-menu-icon') { + handleAction(this, this._hass as HomeAssistant, button, action); + return; + } + + switch (button.card_action) { case 'frigate': this._changeView(); break; case 'live': case 'clips': case 'snapshots': - this._changeView(new View({ view: name })); + this._changeView(new View({ view: button.card_action })); break; case 'frigate_ui': const frigate_url = this._getFrigateURLFromContext(); @@ -350,8 +336,7 @@ export class FrigateCard extends LitElement { } break; default: - // If it's unknown, it's assumed to be an entity_id. - fireEvent(this, 'hass-more-info', { entityId: name }); + console.warn(`Frigate card received unknown menu action: ${button.card_action}`); } } @@ -389,6 +374,7 @@ export class FrigateCard extends LitElement { return html` (element: HTMLElement, name: string, detail?: T) } export function dispatchPlayEvent(element: HTMLElement): void { - dispatchEvent(element, 'play') + dispatchEvent(element, 'play'); } export function dispatchPauseEvent(element: HTMLElement): void { - dispatchEvent(element, 'pause') + dispatchEvent(element, 'pause'); } -export function dispatchMediaLoadEvent(element: HTMLElement, source: Event | HTMLElement): void { +export function dispatchMediaLoadEvent( + element: HTMLElement, + source: Event | HTMLElement, +): void { let target: HTMLElement | EventTarget; if (source instanceof Event) { target = source.composedPath()[0]; @@ -139,4 +142,32 @@ export function dispatchMediaLoadEvent(element: HTMLElement, source: Event | HTM height: (target as HTMLCanvasElement).height, }); } -} \ No newline at end of file +} + +// Determine whether the card should be updated based on Home Assistant changes. +export function shouldUpdateBasedOnHass( + newHass: HomeAssistant | null, + oldHass: HomeAssistant | undefined, + entities: string[] | null, +): boolean { + if (!newHass || !entities) { + return false; + } + if (!entities.length) { + return false; + } + + if (oldHass) { + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + if (!entity) { + continue; + } + if (oldHass.states[entity] !== newHass.states[entity]) { + return true; + } + } + return false; + } + return false; +} diff --git a/src/components/menu.ts b/src/components/menu.ts index 37d9b45a..977839df 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -1,19 +1,26 @@ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { HomeAssistant, hasAction, stateIcon } from 'custom-card-helpers'; +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS, PropertyValues } from 'lit'; +import { actionHandler } from '../action-handler-directive'; import { customElement, property } from 'lit/decorators'; import { classMap } from 'lit/directives/class-map.js'; - +import { styleMap } from 'lit/directives/style-map.js'; import { MenuButton } from '../types'; -import type { FrigateMenuMode } from '../types'; +import type { ExtendedHomeAssistant, FrigateMenuMode } from '../types'; import menuStyle from '../scss/menu.scss'; +import { HassEntity } from 'home-assistant-js-websocket'; +import { shouldUpdateBasedOnHass } from '../common'; -type FrigateCardMenuCallback = (name: string) => void; +type FrigateCardMenuCallback = (name: string, button: MenuButton) => void; export const MENU_HEIGHT = 46; // A menu for the Frigate card. @customElement('frigate-card-menu') export class FrigateCardMenu extends LitElement { + @property({ attribute: false }) + public hass!: HomeAssistant & ExtendedHomeAssistant; + @property({ attribute: false }) protected menuMode: FrigateMenuMode = 'hidden-top'; @@ -24,12 +31,12 @@ export class FrigateCardMenu extends LitElement { protected actionCallback: FrigateCardMenuCallback | null = null; @property({ attribute: false }) - public buttons: Map = new Map(); + public buttons: MenuButton[] = []; // Call the callback. - protected _callAction(name: string): void { + protected _callAction(ev: CustomEvent, button: MenuButton): void { if (this.menuMode.startsWith('hidden-')) { - if (name == 'frigate') { + if (button.type == 'internal-menu-icon' && button.card_action === 'frigate') { this.expand = !this.expand; return; } @@ -38,42 +45,93 @@ export class FrigateCardMenu extends LitElement { } if (this.actionCallback) { - this.actionCallback(name); + this.actionCallback(ev.detail.action, button); } } + // Determine whether the menu should be updated. + protected shouldUpdate(changedProps: PropertyValues): boolean { + const oldHass = changedProps.get('hass') as HomeAssistant | undefined; + + if (!oldHass) { + return true; + } + + // Extract the entities the menu rendering depends on (if any). + const entities: string[] = [] + for (let i = 0; i < this.buttons.length; i++) { + const button = this.buttons[i]; + if (button.type == 'menu-state-icon') { + entities.push(button.entity); + } + } + return shouldUpdateBasedOnHass(this.hass, oldHass, entities); + } + // Render a menu button. - protected _renderButton(name: string, button: MenuButton): TemplateResult { + protected _renderButton(button: MenuButton): TemplateResult { + let state: HassEntity | null = null; + let emphasize = false; + let title = button.title; + let icon = button.icon; + + if (button.type === 'menu-state-icon') { + state = this.hass.states[button.entity]; + emphasize = + !!state && button.state_color && ['on', 'active', 'home'].includes(state.state); + title = title ?? (state.attributes.friendly_name || button.entity); + icon = icon ?? stateIcon(state); + } else if (button.type === 'internal-menu-icon') { + emphasize = button.emphasize ?? false; + } + + let hasHold = false; + let hasDoubleClick = false; + + if (button.type != 'internal-menu-icon') { + hasHold = hasAction(button.hold_action); + hasDoubleClick = hasAction(button.double_tap_action); + } + const classes = { button: true, - emphasize: button.emphasize ?? false, + emphasize: emphasize, }; return html` this._callAction(name)} + style="${styleMap(button.style || {})}" + icon=${icon || 'mdi:gesture-tap-button'} + title=${title || ''} + @action=${(ev) => this._callAction(ev, button)} + .actionHandler=${actionHandler({ + hasHold: hasHold, + hasDoubleClick: hasDoubleClick, + })} >`; } // Render the Frigate menu button. - protected _renderFrigateButton(name: string, button: MenuButton): TemplateResult { + protected _renderFrigateButton(button: MenuButton): TemplateResult { const icon = this.menuMode.startsWith('hidden-') && !this.expand ? 'mdi:alpha-f-box-outline' : 'mdi:alpha-f-box'; - return this._renderButton(name, Object.assign({}, button, { icon: icon })); + return this._renderButton(Object.assign({}, button, { icon: icon })); } // Render the menu. protected render(): TemplateResult { + const isFrigateButton = function (button: MenuButton): boolean { + return button.type === 'internal-menu-icon' && button.card_action === 'frigate'; + }; + // If the menu is off, or if it's in hidden mode but there's no button to // unhide it, just show nothing. if ( this.menuMode == 'none' || - (this.menuMode.startsWith('hidden-') && !this.buttons.get('frigate')) + (this.menuMode.startsWith('hidden-') && !this.buttons.find(isFrigateButton)) ) { return html``; } @@ -103,14 +161,10 @@ export class FrigateCardMenu extends LitElement { return html`
- ${Array.from(this.buttons.keys()).map((name) => { - const button = this.buttons.get(name); - if (button) { - return name === 'frigate' - ? this._renderFrigateButton(name, button) - : this._renderButton(name, button); - } - return html``; + ${Array.from(this.buttons).map((button) => { + return isFrigateButton(button) + ? this._renderFrigateButton(button) + : this._renderButton(button); })}
`; diff --git a/src/types.ts b/src/types.ts index 704be24e..0a9c8d87 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,12 @@ -import { LovelaceCard, LovelaceCardEditor } from 'custom-card-helpers'; +import { + CallServiceActionConfig, + LovelaceCard, + LovelaceCardEditor, + MoreInfoActionConfig, + NavigateActionConfig, + ToggleActionConfig, + UrlActionConfig, +} from 'custom-card-helpers'; import { z } from 'zod'; declare global { @@ -46,6 +54,112 @@ export type NextPreviousControlStyle = typeof NEXT_PREVIOUS_CONTROL_STYLES[numbe export const LIVE_PROVIDERS = ['frigate', 'frigate-jsmpeg', 'webrtc'] as const; export type LiveProvider = typeof LIVE_PROVIDERS[number]; +/** + * Action Types (for "Picture Elements" / Menu) + */ + +// Declare schemas to existing types: +// - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const schemaForType = + () => + >(arg: S) => { + return arg; + }; +const toggleActionSchema = schemaForType()( + z.object({ + action: z.literal('toggle'), + }), +); +const callServiceActionSchema = schemaForType()( + z.object({ + action: z.literal('call-service'), + service: z.string(), + service_data: z.object({}).passthrough().optional(), + }), +); +const navigateActionSchema = schemaForType()( + z.object({ + action: z.literal('navigate'), + navigation_path: z.string(), + }), +); +const urlActionSchema = schemaForType()( + z.object({ + action: z.literal('url'), + url_path: z.string(), + }), +); +const moreInfoActionSchema = schemaForType()( + z.object({ + action: z.literal('more-info'), + }), +); +const elementsActionSchema = z.union([ + toggleActionSchema, + callServiceActionSchema, + navigateActionSchema, + urlActionSchema, + moreInfoActionSchema, +]); +export type ElementsActionType = z.infer; + +const elementsActionsSchema = z.object({ + tap_action: elementsActionSchema.optional(), + hold_action: elementsActionSchema.optional(), + double_tap_action: elementsActionSchema.optional(), +}); + +/** + * Menu Types + */ + +const menuItemBaseSchema = z.object({ + title: z.string().optional(), + style: z.object({}).passthrough().optional(), +}); + +const menuIconSchema = menuItemBaseSchema + .merge( + z.object({ + type: z.literal('menu-icon'), + icon: z.string(), + }), + ) + .merge(elementsActionsSchema); + +const menuStateIconSchema = menuItemBaseSchema + .merge( + z.object({ + type: z.literal('menu-state-icon'), + entity: z.string(), + icon: z.string().optional(), + state_color: z.boolean().default(true), + }), + ) + .merge(elementsActionsSchema); + +// Schema for card (non-user configured) menu icons. +const internalMenuIconSchema = menuItemBaseSchema.merge( + z.object({ + type: z.literal('internal-menu-icon'), + icon: z.string().optional(), + emphasize: z.boolean().default(false).optional(), + card_action: z.string(), + }), +); + +const menuButtonSchema = z.union([ + menuIconSchema, + menuStateIconSchema, + internalMenuIconSchema, +]); +export type MenuButton = z.infer; + +// 'internalMenuIconSchema' is excluded to disallow the user from manually +// changing the internal menu buttons. +const elementsSchema = z.union([menuStateIconSchema, menuIconSchema]); + export const frigateCardConfigSchema = z.object({ camera_entity: z.string(), // No URL validation to allow relative URLs within HA (e.g. addons). @@ -85,14 +199,8 @@ export const frigateCardConfigSchema = z.object({ fullscreen: z.boolean().default(true), }) .optional(), - entities: z - .object({ - entity: z.string(), - show: z.boolean().default(true), - icon: z.string().optional(), - }) - .array() - .optional(), + update_entities: z.string().array().optional(), + elements: elementsSchema.array().optional(), controls: z .object({ nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'), @@ -125,12 +233,6 @@ export const frigateCardConfigSchema = z.object({ }); export type FrigateCardConfig = z.infer; -export interface MenuButton { - icon?: string; - description: string; - emphasize?: boolean; -} - export interface ExtendedHomeAssistant { hassUrl(path?): string; }