Allow custom actions to control the frigate card.

This commit is contained in:
Dermot Duffy
2021-11-10 07:45:59 -08:00
parent 6096b0e81e
commit 19291ae184
6 changed files with 240 additions and 158 deletions
+95 -79
View File
@@ -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<MenuInteraction>): 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<CardAction>): 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)}
></frigate-card-menu>
`;
}
@@ -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)}
>
</frigate-card-elements>
`
+39 -3
View File
@@ -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<T>(element: HTMLElement, name: string, detail?: T): void {
export function dispatchFrigateCardEvent<T>(
element: HTMLElement,
name: string,
detail?: T,
): void {
element.dispatchEvent(
new CustomEvent<T>(`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<Message>(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,
});
}
}
+6 -1
View File
@@ -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)}
>
</frigate-card-elements-core>`;
}
+79 -54
View File
@@ -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<MenuInteraction>(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<CardAction>(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 {
</ha-icon-button>`;
}
// 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`
<div class=${classMap(classes)}>
${Array.from(this.buttons).map((button) => {
return isFrigateButton(button)
? this._renderFrigateButton(button)
: this._renderButton(button);
})}
${Array.from(this.buttons).map((button) => this._renderButton(button))}
</div>
`;
}
-4
View File
@@ -10,7 +10,3 @@ ha-icon-button.button {
/* Buttons can always be clicked */
pointer-events: auto;
}
ha-icon-button.button.emphasize {
color: var(--primary-color, white);
}
+20 -16
View File
@@ -99,12 +99,21 @@ const moreInfoActionSchema = schemaForType<MoreInfoActionConfig>()(
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<typeof frigateCardCustomActionSchema>;
const elementsActionSchema = z.union([
toggleActionSchema,
callServiceActionSchema,
navigateActionSchema,
urlActionSchema,
moreInfoActionSchema,
frigateCardCustomActionSchema,
]);
export type ElementsActionType = z.infer<typeof elementsActionSchema>;
@@ -244,8 +253,6 @@ const frigateConditionalSchema = z.object({
});
export type FrigateConditional = z.infer<typeof frigateConditionalSchema>;
// '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<typeof menuButtonSchema>;
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;
}
/**