diff --git a/src/card.ts b/src/card.ts index 557fa614..f970088f 100644 --- a/src/card.ts +++ b/src/card.ts @@ -15,15 +15,15 @@ import { HomeAssistant, LovelaceCardEditor, getLovelace, - handleAction, } from 'custom-card-helpers'; import screenfull from 'screenfull'; import { z } from 'zod'; import { + CardAction, entitySchema, frigateCardConfigSchema, - MenuInteraction, + GetFrigateCardMenuButtonParameters, RawFrigateCardConfig, } from './types.js'; import type { @@ -38,9 +38,10 @@ import type { import { CARD_VERSION, REPO_URL } from './const.js'; import { FrigateCardElements } from './components/elements.js'; -import { FrigateCardMenu, MENU_HEIGHT } from './components/menu.js'; +import { FrigateCardMenu, FRIGATE_BUTTON_MENU_ICON, MENU_HEIGHT } from './components/menu.js'; import { View } from './view.js'; import { + createFrigateCardCustomAction, homeAssistantSignPath, homeAssistantWSRequest, isValidMediaShowInfo, @@ -63,7 +64,7 @@ import './patches/ha-hls-player.js'; import cardStyle from './scss/card.scss'; import { ResolvedMediaCache } from './resolved-media.js'; import { BrowseMediaUtil } from './browse-media-util.js'; -import { copyConfig, isConfigUpgradeable, upgradeConfig } from './config-mgmt.js'; +import { isConfigUpgradeable } from './config-mgmt.js'; /** A note on media callbacks: * @@ -191,6 +192,23 @@ export class FrigateCard extends LitElement { } as FrigateCardConfig; } + protected _getFrigateCardMenuButton( + params: GetFrigateCardMenuButtonParameters, + ): MenuButton { + return { + type: 'custom:frigate-card-menu-icon', + title: params.title, + icon: params.icon, + style: params.emphasize ? FrigateCardMenu.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. @@ -199,73 +217,86 @@ export class FrigateCard extends LitElement { const buttons: MenuButton[] = []; if (this.config.menu.buttons.frigate) { - buttons.push({ - type: 'internal-menu-icon', - tap_action: 'frigate', - title: localize('config.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'), + }), + ); } if (this.config.menu.buttons.live) { - buttons.push({ - type: 'internal-menu-icon', - tap_action: 'live', - title: localize('config.view.views.live'), - icon: 'mdi:cctv', - emphasize: this._view.is('live'), - }); + buttons.push( + this._getFrigateCardMenuButton({ + tap_action: 'live', + title: localize('config.view.views.live'), + icon: 'mdi:cctv', + emphasize: this._view.is('live'), + }), + ); } + if (this.config.menu.buttons.clips) { - buttons.push({ - type: 'internal-menu-icon', - tap_action: 'clips', - hold_action: 'clip', - title: localize('config.view.views.clips'), - icon: 'mdi:filmstrip', - emphasize: this._view.is('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'), + }), + ); } if (this.config.menu.buttons.snapshots) { - buttons.push({ - type: 'internal-menu-icon', - tap_action: 'snapshots', - hold_action: 'snapshot', - title: localize('config.view.views.snapshots'), - icon: 'mdi:camera', - emphasize: this._view.is('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'), + }), + ); } if (this.config.menu.buttons.image) { - buttons.push({ - type: 'internal-menu-icon', - tap_action: 'image', - title: localize('config.view.views.image'), - icon: 'mdi:image', - emphasize: this._view.is('image'), - }); + buttons.push( + this._getFrigateCardMenuButton({ + tap_action: 'image', + title: localize('config.view.views.image'), + icon: 'mdi:image', + emphasize: this._view.is('image'), + }), + ); } if (this.config.menu.buttons.download && this._view.isViewerView()) { - buttons.push({ - type: 'internal-menu-icon', - tap_action: 'download', - title: localize('config.menu.buttons.download'), - icon: 'mdi:download', - }); + buttons.push( + this._getFrigateCardMenuButton({ + tap_action: 'download', + title: localize('config.menu.buttons.download'), + icon: 'mdi:download', + }), + ); } if (this.config.menu.buttons.frigate_ui && this.config.frigate.url) { - buttons.push({ - type: 'internal-menu-icon', - tap_action: 'frigate_ui', - title: localize('config.menu.buttons.frigate_ui'), - icon: 'mdi:web', - }); + buttons.push( + this._getFrigateCardMenuButton({ + tap_action: 'frigate_ui', + title: localize('config.menu.buttons.frigate_ui'), + icon: 'mdi:web', + }), + ); } if (this.config.menu.buttons.fullscreen && screenfull.isEnabled) { - buttons.push({ - type: 'internal-menu-icon', - tap_action: 'fullscreen', - title: localize('config.menu.buttons.fullscreen'), - icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', - }); + buttons.push( + this._getFrigateCardMenuButton({ + tap_action: 'fullscreen', + title: localize('config.menu.buttons.fullscreen'), + icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', + }), + ); } return buttons.concat(this._dynamicMenuButtons); } @@ -546,27 +577,11 @@ export class FrigateCard extends LitElement { } /** - * Handle a menu button being clicked. - * @param interaction The interaction that was applied to the button (e.g. - * tap, double_tap, hold). - * @param button The button that was interacted with. + * Handle a request for a card action. + * @param action The action requested (e.g. clips, fullscreen) */ - protected _menuInteractionHandler(event: CustomEvent): void { - const interaction = event.detail.interaction; - const button = event.detail.button; - - if (button.type != 'internal-menu-icon') { - handleAction(this, this._hass as HomeAssistant, button, interaction); - return; - } - - let action: string | undefined = undefined; - if (interaction == 'tap') { - action = button.tap_action; - } else if (interaction == 'hold') { - action = button.hold_action; - } - + protected _cardActionHandler(event: CustomEvent): void { + const action = event.detail.action; if (!action) { return; } @@ -598,7 +613,7 @@ export class FrigateCard extends LitElement { } break; default: - console.warn(`Frigate card received unknown menu action: ${action}`); + console.warn(`Frigate card received unknown card action: ${action}`); } } @@ -648,7 +663,7 @@ export class FrigateCard extends LitElement { .menuConfig=${this.config.menu} .buttons=${this._getMenuButtons()} class="${classMap(classes)}" - @frigate-card:menu-interaction=${this._menuInteractionHandler.bind(this)} + @frigate-card:card-action=${this._cardActionHandler.bind(this)} > `; } @@ -984,6 +999,7 @@ export class FrigateCard extends LitElement { // 'frigate-card-elements' to re-render (by being a property). e.view = this._view; }} + @frigate-card:card-action=${this._cardActionHandler.bind(this)} > ` diff --git a/src/common.ts b/src/common.ts index 158f962b..df2fbdf5 100644 --- a/src/common.ts +++ b/src/common.ts @@ -3,7 +3,10 @@ import { MessageBase } from 'home-assistant-js-websocket'; import { HomeAssistant } from 'custom-card-helpers'; import { localize } from './localize/localize.js'; import { + ElementsActionType, ExtendedHomeAssistant, + FrigateCardCustomAction, + frigateCardCustomActionSchema, MediaShowInfo, Message, SignedPath, @@ -91,7 +94,11 @@ export async function homeAssistantSignPath( * @param name The name of the Frigate card event to send. * @param detail An optional detail object to attach. */ -export function dispatchFrigateCardEvent(element: HTMLElement, name: string, detail?: T): void { +export function dispatchFrigateCardEvent( + element: HTMLElement, + name: string, + detail?: T, +): void { element.dispatchEvent( new CustomEvent(`frigate-card:${name}`, { bubbles: true, @@ -198,7 +205,7 @@ export function dispatchMessageEvent( * Dispatch an event with an error message to show to the user. * @param element The element to send the event. * @param message The message to show. - */ + */ export function dispatchErrorMessageEvent(element: HTMLElement, message: string): void { dispatchFrigateCardEvent(element, 'message', { message: message, @@ -246,5 +253,34 @@ export function shouldUpdateBasedOnHass( * @returns True if the object is valid, false otherwise. */ export function isValidMediaShowInfo(info: MediaShowInfo): boolean { - return info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF; + return ( + info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF + ); +} + +export function convertActionToFrigateCardCustomAction(action: ElementsActionType): FrigateCardCustomAction | null { + // Parse a custom event as other things could generate ll-custom events that + // are not related to Frigate Card. + const parseResult = frigateCardCustomActionSchema.safeParse(action); + return parseResult.success ? parseResult.data : null; +} + +export function createFrigateCardCustomAction(action: string): FrigateCardCustomAction { + return { + action: 'fire-dom-event', + frigate_card_action: action, + } +} + +export function convertLovelaceEventToCardActionEvent( + node: HTMLElement, + ev: CustomEvent, +): void { + const frigateCardAction = convertActionToFrigateCardCustomAction(ev.detail); + if (frigateCardAction) { + ev.stopPropagation(); + dispatchFrigateCardEvent(node, 'card-action', { + action: frigateCardAction.frigate_card_action, + }); + } } diff --git a/src/components/elements.ts b/src/components/elements.ts index 7dd296d3..366c9055 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -10,7 +10,11 @@ import { MenuStateIcon, PictureElements, } from '../types.js'; -import { dispatchErrorMessageEvent, dispatchFrigateCardEvent } from '../common.js'; +import { + convertLovelaceEventToCardActionEvent, + dispatchErrorMessageEvent, + dispatchFrigateCardEvent, +} from '../common.js'; import elementsStyle from '../scss/elements.scss'; import { localize } from '../localize/localize.js'; @@ -180,6 +184,7 @@ export class FrigateCardElements extends LitElement { .hass=${this._hass} .view=${this.view} .elements=${this.elements} + @ll-custom=${(ev: CustomEvent) => convertLovelaceEventToCardActionEvent(this, ev)} > `; } diff --git a/src/components/menu.ts b/src/components/menu.ts index a18a9f59..fcf5f0bf 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -1,5 +1,5 @@ import { HassEntity } from 'home-assistant-js-websocket'; -import { HomeAssistant, hasAction, stateIcon } from 'custom-card-helpers'; +import { HomeAssistant, handleAction, hasAction, stateIcon } from 'custom-card-helpers'; import { CSSResultGroup, LitElement, @@ -10,21 +10,28 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; -import { styleMap } from 'lit/directives/style-map.js'; +import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../action-handler-directive.js'; import type { + CardAction, + ElementsActionType, ExtendedHomeAssistant, MenuButton, MenuConfig, - MenuInteraction, } from '../types.js'; -import { dispatchFrigateCardEvent, shouldUpdateBasedOnHass } from '../common.js'; +import { + convertActionToFrigateCardCustomAction, + convertLovelaceEventToCardActionEvent, + dispatchFrigateCardEvent, + shouldUpdateBasedOnHass, +} from '../common.js'; import menuStyle from '../scss/menu.scss'; export const MENU_HEIGHT = 46; +export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; // A menu for the Frigate card. @customElement('frigate-card-menu') @@ -48,19 +55,50 @@ export class FrigateCardMenu extends LitElement { public buttons: MenuButton[] = []; protected _interactionHandler(ev: CustomEvent, button: MenuButton): void { - if (this._menuConfig?.mode.startsWith('hidden-')) { - if (button.type == 'internal-menu-icon' && button.tap_action === 'frigate') { - this.expand = !this.expand; - return; - } - // Collapse menu after the user clicks on something. - this.expand = false; + if (!ev) { + return; } - dispatchFrigateCardEvent(this, 'menu-interaction', { - interaction: ev.detail.action, - button: button, - }); + const interaction: string = ev.detail.action; + let action: ElementsActionType | undefined; + + if (interaction == 'tap') { + action = button.tap_action; + } else if (interaction == 'hold') { + action = button.hold_action; + } else if (interaction == 'double_tap') { + action = button.double_tap_action; + } + if (!action) { + return; + } + + // Determine if this action is a Frigate card action, if so handle it + // internally. + const frigateCardAction = convertActionToFrigateCardCustomAction(action); + if (frigateCardAction) { + if (frigateCardAction.frigate_card_action == 'frigate') { + // If the user presses the frigate button and it's a hide-away menu, + // then expand the menu and return. + if (this._menuConfig?.mode.startsWith('hidden-')) { + this.expand = !this.expand; + return; + } + } + + // Collapse menu after the user clicks on something. + this.expand = false; + + dispatchFrigateCardEvent(this, 'card-action', { + action: frigateCardAction.frigate_card_action, + }); + } + + const node: HTMLElement | null = ev.currentTarget as HTMLElement | null; + if (node) { + handleAction(node, this.hass as HomeAssistant, button, interaction); + return; + } } // Determine whether the menu should be updated. @@ -82,38 +120,47 @@ export class FrigateCardMenu extends LitElement { return shouldUpdateBasedOnHass(this.hass, oldHass, entities); } + public static getEmphasizedStyle(): StyleInfo { + return { + color: 'var(--primary-color, white)', + }; + } + // Render a menu button. protected _renderButton(button: MenuButton): TemplateResult | void { let state: HassEntity | null = null; - let emphasize = false; let title = button.title; let icon = button.icon; - const style = ('style' in button ? button.style : {}) || {}; + let style = ('style' in button ? button.style : {}) || {}; + + if (icon == FRIGATE_BUTTON_MENU_ICON) { + icon = + this._menuConfig?.mode.startsWith('hidden-') && !this.expand + ? 'mdi:alpha-f-box-outline' + : 'mdi:alpha-f-box'; + } if (button.type === 'custom:frigate-card-menu-state-icon') { if (!this.hass) { return; } state = this.hass.states[button.entity]; - emphasize = - !!state && button.state_color && ['on', 'active', 'home'].includes(state.state); + if ( + !!state && + button.state_color && + ['on', 'active', 'home'].includes(state.state) + ) { + style = { ...style, ...FrigateCardMenu.getEmphasizedStyle() }; + } 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 hasHold = hasAction(button.hold_action); + const hasDoubleClick = hasAction(button.double_tap_action); const classes = { button: true, - emphasize: emphasize, }; // TODO: Upon a safe distance from the release of HA 2021.11 these @@ -127,6 +174,7 @@ export class FrigateCardMenu extends LitElement { .label=${title || ''} title=${title || ''} @action=${(ev) => this._interactionHandler(ev, button)} + @ll-custom=${(ev: CustomEvent) => convertLovelaceEventToCardActionEvent(this, ev)} .actionHandler=${actionHandler({ hasHold: hasHold, hasDoubleClick: hasDoubleClick, @@ -136,16 +184,6 @@ export class FrigateCardMenu extends LitElement { `; } - // Render the Frigate menu button. - protected _renderFrigateButton(button: MenuButton): TemplateResult | void { - const icon = - this._menuConfig?.mode.startsWith('hidden-') && !this.expand - ? 'mdi:alpha-f-box-outline' - : 'mdi:alpha-f-box'; - - return this._renderButton(Object.assign({}, button, { icon: icon })); - } - // Render the menu. protected render(): TemplateResult | void { if (!this._menuConfig) { @@ -153,16 +191,7 @@ export class FrigateCardMenu extends LitElement { } const mode = this._menuConfig.mode; - const isFrigateButton = function (button: MenuButton): boolean { - return button.type === 'internal-menu-icon' && button.tap_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 ( - mode == 'none' || - (mode.startsWith('hidden-') && !this.buttons.find(isFrigateButton)) - ) { + if (mode == 'none') { return; } @@ -187,11 +216,7 @@ export class FrigateCardMenu extends LitElement { return html`
- ${Array.from(this.buttons).map((button) => { - return isFrigateButton(button) - ? this._renderFrigateButton(button) - : this._renderButton(button); - })} + ${Array.from(this.buttons).map((button) => this._renderButton(button))}
`; } diff --git a/src/scss/button.scss b/src/scss/button.scss index ebeaaa88..66cc8ec2 100644 --- a/src/scss/button.scss +++ b/src/scss/button.scss @@ -9,8 +9,4 @@ ha-icon-button.button { --ha-icon-display: block; /* Buttons can always be clicked */ pointer-events: auto; -} - -ha-icon-button.button.emphasize { - color: var(--primary-color, white); -} +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index b3b09b4c..0e37f670 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,12 +99,21 @@ const moreInfoActionSchema = schemaForType()( action: z.literal('more-info'), }), ); +const customActionSchema = z.object({ + action: z.literal('fire-dom-event'), +}) +export const frigateCardCustomActionSchema = customActionSchema.merge(z.object({ + frigate_card_action: z.string(), +})) +export type FrigateCardCustomAction = z.infer; + const elementsActionSchema = z.union([ toggleActionSchema, callServiceActionSchema, navigateActionSchema, urlActionSchema, moreInfoActionSchema, + frigateCardCustomActionSchema, ]); export type ElementsActionType = z.infer; @@ -244,8 +253,6 @@ const frigateConditionalSchema = z.object({ }); export type FrigateConditional = z.infer; -// 'internalMenuIconSchema' is excluded to disallow the user from manually -// changing the internal menu buttons. const pictureElementSchema = z.union([ menuStateIconSchema, menuIconSchema, @@ -476,20 +483,9 @@ export const frigateCardConfigDefaults = { event_viewer: viewerConfigDefault, }; -// Schema for card (non-user configured) menu icons. -const internalMenuIconSchema = z.object({ - type: z.literal('internal-menu-icon'), - title: z.string(), - icon: z.string().optional(), - emphasize: z.boolean().default(false).optional(), - tap_action: z.string(), - hold_action: z.string().optional(), -}); - const menuButtonSchema = z.union([ menuIconSchema, menuStateIconSchema, - internalMenuIconSchema, ]); export type MenuButton = z.infer; export interface ExtendedHomeAssistant { @@ -506,6 +502,15 @@ export interface BrowseMediaQueryParameters { after?: number; } +export interface GetFrigateCardMenuButtonParameters { + icon: string; + title: string; + tap_action: string; + + hold_action?: string; + emphasize?: boolean; +} + export interface BrowseMediaNeighbors { previous: BrowseMediaSource | null; previousIndex: number | null; @@ -525,9 +530,8 @@ export interface Message { icon?: string; } -export interface MenuInteraction { - interaction: 'hold' | 'tap' | 'double_tap'; - button: MenuButton; +export interface CardAction { + action: string; } /**