Merge pull request #190 from dermotduffy/trigger-fullscreen

Allow custom user-defined actions to control the frigate card (e.g. activate fullscreen)
This commit is contained in:
Dermot Duffy
2021-11-11 20:10:58 -08:00
committed by GitHub
7 changed files with 301 additions and 172 deletions
+41 -1
View File
@@ -270,7 +270,7 @@ This card supports all [Picture Elements](https://www.home-assistant.io/lovelace
<a name="frigate-card-conditional"></a> <a name="frigate-card-conditional"></a>
### `custom:frigate-card-conditional` #### `custom:frigate-card-conditional`
Parameters for the `custom:frigate-card-conditional` element: Parameters for the `custom:frigate-card-conditional` element:
@@ -282,6 +282,23 @@ Parameters for the `custom:frigate-card-conditional` element:
| `elements` | The elements to render. Can be any supported element, include additional condition or custom elements. | | `elements` | The elements to render. Can be any supported element, include additional condition or custom elements. |
See the [PTZ example below](#frigate-card-conditional-example) for a real-world example. See the [PTZ example below](#frigate-card-conditional-example) for a real-world example.
### Special Actions
#### `custom:frigate-card-action`
| 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`.|
| Value | Description |
| - | - |
| `frigate` | Show/hide the menu or 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.|
### Elements Examples ### Elements Examples
#### Menu icons #### Menu icons
@@ -440,6 +457,29 @@ elements:
``` ```
</details> </details>
#### Triggering card actions
You can control the card itself with the `custom:frigate-card-action` action.
<details>
<summary>Expand: Custom fullscreen button</summary>
This example shows an icon that toggles the card fullscreen mode.
```yaml
elements:
- type: icon
icon: mdi:fullscreen
style:
left: 40px
top: 40px
tap_action:
action: custom:frigate-card-action
frigate_card_action: fullscreen
```
</details>
<a name="views"></a> <a name="views"></a>
## Views ## Views
+104 -88
View File
@@ -15,15 +15,15 @@ import {
HomeAssistant, HomeAssistant,
LovelaceCardEditor, LovelaceCardEditor,
getLovelace, getLovelace,
handleAction,
} from 'custom-card-helpers'; } from 'custom-card-helpers';
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { z } from 'zod'; import { z } from 'zod';
import { import {
CardAction,
entitySchema, entitySchema,
frigateCardConfigSchema, frigateCardConfigSchema,
MenuInteraction, GetFrigateCardMenuButtonParameters,
RawFrigateCardConfig, RawFrigateCardConfig,
} from './types.js'; } from './types.js';
import type { import type {
@@ -38,9 +38,10 @@ import type {
import { CARD_VERSION, REPO_URL } from './const.js'; import { CARD_VERSION, REPO_URL } from './const.js';
import { FrigateCardElements } from './components/elements.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 { View } from './view.js';
import { import {
createFrigateCardCustomAction,
homeAssistantSignPath, homeAssistantSignPath,
homeAssistantWSRequest, homeAssistantWSRequest,
isValidMediaShowInfo, isValidMediaShowInfo,
@@ -63,7 +64,7 @@ import './patches/ha-hls-player.js';
import cardStyle from './scss/card.scss'; import cardStyle from './scss/card.scss';
import { ResolvedMediaCache } from './resolved-media.js'; import { ResolvedMediaCache } from './resolved-media.js';
import { BrowseMediaUtil } from './browse-media-util.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: /** A note on media callbacks:
* *
@@ -191,6 +192,23 @@ export class FrigateCard extends LitElement {
} as FrigateCardConfig; } 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. * Get the menu buttons to display.
* @returns An array of menu buttons. * @returns An array of menu buttons.
@@ -199,73 +217,86 @@ export class FrigateCard extends LitElement {
const buttons: MenuButton[] = []; const buttons: MenuButton[] = [];
if (this.config.menu.buttons.frigate) { if (this.config.menu.buttons.frigate) {
buttons.push({ buttons.push(
type: 'internal-menu-icon', this._getFrigateCardMenuButton({
tap_action: 'frigate', tap_action: 'frigate',
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,
title: localize('config.menu.buttons.frigate'),
}),
);
} }
if (this.config.menu.buttons.live) { if (this.config.menu.buttons.live) {
buttons.push({ buttons.push(
type: 'internal-menu-icon', this._getFrigateCardMenuButton({
tap_action: 'live', tap_action: 'live',
title: localize('config.view.views.live'), title: localize('config.view.views.live'),
icon: 'mdi:cctv', icon: 'mdi:cctv',
emphasize: this._view.is('live'), emphasize: this._view.is('live'),
}); }),
);
} }
if (this.config.menu.buttons.clips) { if (this.config.menu.buttons.clips) {
buttons.push({ buttons.push(
type: 'internal-menu-icon', this._getFrigateCardMenuButton({
tap_action: 'clips', tap_action: 'clips',
hold_action: 'clip', hold_action: 'clip',
title: localize('config.view.views.clips'), title: localize('config.view.views.clips'),
icon: 'mdi:filmstrip', icon: 'mdi:filmstrip',
emphasize: this._view.is('clips'), emphasize: this._view.is('clips'),
}); }),
);
} }
if (this.config.menu.buttons.snapshots) { if (this.config.menu.buttons.snapshots) {
buttons.push({ buttons.push(
type: 'internal-menu-icon', this._getFrigateCardMenuButton({
tap_action: 'snapshots', tap_action: 'snapshots',
hold_action: 'snapshot', hold_action: 'snapshot',
title: localize('config.view.views.snapshots'), title: localize('config.view.views.snapshots'),
icon: 'mdi:camera', icon: 'mdi:camera',
emphasize: this._view.is('snapshots'), emphasize: this._view.is('snapshots'),
}); }),
);
} }
if (this.config.menu.buttons.image) { if (this.config.menu.buttons.image) {
buttons.push({ buttons.push(
type: 'internal-menu-icon', this._getFrigateCardMenuButton({
tap_action: 'image', tap_action: 'image',
title: localize('config.view.views.image'), title: localize('config.view.views.image'),
icon: 'mdi:image', icon: 'mdi:image',
emphasize: this._view.is('image'), emphasize: this._view.is('image'),
}); }),
);
} }
if (this.config.menu.buttons.download && this._view.isViewerView()) { if (this.config.menu.buttons.download && this._view.isViewerView()) {
buttons.push({ buttons.push(
type: 'internal-menu-icon', this._getFrigateCardMenuButton({
tap_action: 'download', tap_action: 'download',
title: localize('config.menu.buttons.download'), title: localize('config.menu.buttons.download'),
icon: 'mdi:download', icon: 'mdi:download',
}); }),
);
} }
if (this.config.menu.buttons.frigate_ui && this.config.frigate.url) { if (this.config.menu.buttons.frigate_ui && this.config.frigate.url) {
buttons.push({ buttons.push(
type: 'internal-menu-icon', this._getFrigateCardMenuButton({
tap_action: 'frigate_ui', tap_action: 'frigate_ui',
title: localize('config.menu.buttons.frigate_ui'), title: localize('config.menu.buttons.frigate_ui'),
icon: 'mdi:web', icon: 'mdi:web',
}); }),
);
} }
if (this.config.menu.buttons.fullscreen && screenfull.isEnabled) { if (this.config.menu.buttons.fullscreen && screenfull.isEnabled) {
buttons.push({ buttons.push(
type: 'internal-menu-icon', this._getFrigateCardMenuButton({
tap_action: 'fullscreen', tap_action: 'fullscreen',
title: localize('config.menu.buttons.fullscreen'), title: localize('config.menu.buttons.fullscreen'),
icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
}); }),
);
} }
return buttons.concat(this._dynamicMenuButtons); return buttons.concat(this._dynamicMenuButtons);
} }
@@ -346,7 +377,7 @@ export class FrigateCard extends LitElement {
* @param error The ZodError object from parsing. * @param error The ZodError object from parsing.
* @returns An array of string error paths. * @returns An array of string error paths.
*/ */
protected _getParseErrorPaths<T>(error: z.ZodError<T>): string[] { protected _getParseErrorPaths<T>(error: z.ZodError<T>): Set<string> | null {
/* Zod errors involving unions are complex, as Zod may not be able to tell /* Zod errors involving unions are complex, as Zod may not be able to tell
* where the 'real' error is vs simply a union option not matching. This * where the 'real' error is vs simply a union option not matching. This
* function finds all ZodError "issues" that don't have an error with 'type' * function finds all ZodError "issues" that don't have an error with 'type'
@@ -357,7 +388,7 @@ export class FrigateCard extends LitElement {
* exactly why (or rather Zod simply says it doesn't match any of the * exactly why (or rather Zod simply says it doesn't match any of the
* available unions). This usually suggests the user specified an incorrect * available unions). This usually suggests the user specified an incorrect
* type name entirely. */ * type name entirely. */
let contenders: string[] = []; const contenders = new Set<string>();
if (error && error.issues) { if (error && error.issues) {
for (let i = 0; i < error.issues.length; i++) { for (let i = 0; i < error.issues.length; i++) {
const issue = error.issues[i]; const issue = error.issues[i];
@@ -365,17 +396,17 @@ export class FrigateCard extends LitElement {
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors; const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
for (let j = 0; j < unionErrors.length; j++) { for (let j = 0; j < unionErrors.length; j++) {
const nestedErrors = this._getParseErrorPaths(unionErrors[j]); const nestedErrors = this._getParseErrorPaths(unionErrors[j]);
if (nestedErrors.length) { if (nestedErrors && nestedErrors.size) {
contenders = contenders.concat(nestedErrors); nestedErrors.forEach(contenders.add, contenders);
} }
} }
} else if (issue.code == 'invalid_type') { } else if (issue.code == 'invalid_type') {
if (issue.path[issue.path.length - 1] == 'type') { if (issue.path[issue.path.length - 1] == 'type') {
return []; return null;
} }
contenders.push(this._getParseErrorPathString(issue.path)); contenders.add(this._getParseErrorPathString(issue.path));
} else if (issue.code != 'custom') { } else if (issue.code != 'custom') {
contenders.push(this._getParseErrorPathString(issue.path)); contenders.add(this._getParseErrorPathString(issue.path));
} }
} }
} }
@@ -423,8 +454,8 @@ export class FrigateCard extends LitElement {
throw new Error( throw new Error(
upgradeMessage + upgradeMessage +
`${localize('error.invalid_configuration')}: ` + `${localize('error.invalid_configuration')}: ` +
(hint.length (hint && hint.size
? JSON.stringify(hint, null, ' ') ? JSON.stringify([...hint], null, ' ')
: localize('error.invalid_configuration_no_hint')), : localize('error.invalid_configuration_no_hint')),
); );
} }
@@ -546,27 +577,11 @@ export class FrigateCard extends LitElement {
} }
/** /**
* Handle a menu button being clicked. * Handle a request for a card action.
* @param interaction The interaction that was applied to the button (e.g. * @param action The action requested (e.g. clips, fullscreen)
* tap, double_tap, hold).
* @param button The button that was interacted with.
*/ */
protected _menuInteractionHandler(event: CustomEvent<MenuInteraction>): void { protected _cardActionHandler(event: CustomEvent<CardAction>): void {
const interaction = event.detail.interaction; const action = event.detail.action;
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;
}
if (!action) { if (!action) {
return; return;
} }
@@ -598,7 +613,7 @@ export class FrigateCard extends LitElement {
} }
break; break;
default: 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} .menuConfig=${this.config.menu}
.buttons=${this._getMenuButtons()} .buttons=${this._getMenuButtons()}
class="${classMap(classes)}" class="${classMap(classes)}"
@frigate-card:menu-interaction=${this._menuInteractionHandler.bind(this)} @frigate-card:card-action=${this._cardActionHandler.bind(this)}
></frigate-card-menu> ></frigate-card-menu>
`; `;
} }
@@ -984,6 +999,7 @@ export class FrigateCard extends LitElement {
// 'frigate-card-elements' to re-render (by being a property). // 'frigate-card-elements' to re-render (by being a property).
e.view = this._view; e.view = this._view;
}} }}
@frigate-card:card-action=${this._cardActionHandler.bind(this)}
> >
</frigate-card-elements> </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 { HomeAssistant } from 'custom-card-helpers';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
import { import {
ElementsActionType,
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateCardCustomAction,
frigateCardCustomActionSchema,
MediaShowInfo, MediaShowInfo,
Message, Message,
SignedPath, SignedPath,
@@ -91,7 +94,11 @@ export async function homeAssistantSignPath(
* @param name The name of the Frigate card event to send. * @param name The name of the Frigate card event to send.
* @param detail An optional detail object to attach. * @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( element.dispatchEvent(
new CustomEvent<T>(`frigate-card:${name}`, { new CustomEvent<T>(`frigate-card:${name}`, {
bubbles: true, bubbles: true,
@@ -198,7 +205,7 @@ export function dispatchMessageEvent(
* Dispatch an event with an error message to show to the user. * Dispatch an event with an error message to show to the user.
* @param element The element to send the event. * @param element The element to send the event.
* @param message The message to show. * @param message The message to show.
*/ */
export function dispatchErrorMessageEvent(element: HTMLElement, message: string): void { export function dispatchErrorMessageEvent(element: HTMLElement, message: string): void {
dispatchFrigateCardEvent<Message>(element, 'message', { dispatchFrigateCardEvent<Message>(element, 'message', {
message: message, message: message,
@@ -246,5 +253,34 @@ export function shouldUpdateBasedOnHass(
* @returns True if the object is valid, false otherwise. * @returns True if the object is valid, false otherwise.
*/ */
export function isValidMediaShowInfo(info: MediaShowInfo): boolean { 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, MenuStateIcon,
PictureElements, PictureElements,
} from '../types.js'; } from '../types.js';
import { dispatchErrorMessageEvent, dispatchFrigateCardEvent } from '../common.js'; import {
convertLovelaceEventToCardActionEvent,
dispatchErrorMessageEvent,
dispatchFrigateCardEvent,
} from '../common.js';
import elementsStyle from '../scss/elements.scss'; import elementsStyle from '../scss/elements.scss';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
@@ -180,6 +184,7 @@ export class FrigateCardElements extends LitElement {
.hass=${this._hass} .hass=${this._hass}
.view=${this.view} .view=${this.view}
.elements=${this.elements} .elements=${this.elements}
@ll-custom=${(ev: CustomEvent) => convertLovelaceEventToCardActionEvent(this, ev)}
> >
</frigate-card-elements-core>`; </frigate-card-elements-core>`;
} }
+79 -54
View File
@@ -1,5 +1,5 @@
import { HassEntity } from 'home-assistant-js-websocket'; 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 { import {
CSSResultGroup, CSSResultGroup,
LitElement, LitElement,
@@ -10,21 +10,28 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.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 { actionHandler } from '../action-handler-directive.js';
import type { import type {
CardAction,
ElementsActionType,
ExtendedHomeAssistant, ExtendedHomeAssistant,
MenuButton, MenuButton,
MenuConfig, MenuConfig,
MenuInteraction,
} from '../types.js'; } from '../types.js';
import { dispatchFrigateCardEvent, shouldUpdateBasedOnHass } from '../common.js'; import {
convertActionToFrigateCardCustomAction,
convertLovelaceEventToCardActionEvent,
dispatchFrigateCardEvent,
shouldUpdateBasedOnHass,
} from '../common.js';
import menuStyle from '../scss/menu.scss'; import menuStyle from '../scss/menu.scss';
export const MENU_HEIGHT = 46; export const MENU_HEIGHT = 46;
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
// A menu for the Frigate card. // A menu for the Frigate card.
@customElement('frigate-card-menu') @customElement('frigate-card-menu')
@@ -48,19 +55,50 @@ export class FrigateCardMenu extends LitElement {
public buttons: MenuButton[] = []; public buttons: MenuButton[] = [];
protected _interactionHandler(ev: CustomEvent, button: MenuButton): void { protected _interactionHandler(ev: CustomEvent, button: MenuButton): void {
if (this._menuConfig?.mode.startsWith('hidden-')) { if (!ev) {
if (button.type == 'internal-menu-icon' && button.tap_action === 'frigate') { return;
this.expand = !this.expand;
return;
}
// Collapse menu after the user clicks on something.
this.expand = false;
} }
dispatchFrigateCardEvent<MenuInteraction>(this, 'menu-interaction', { const interaction: string = ev.detail.action;
interaction: ev.detail.action, let action: ElementsActionType | undefined;
button: button,
}); 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. // Determine whether the menu should be updated.
@@ -82,38 +120,47 @@ export class FrigateCardMenu extends LitElement {
return shouldUpdateBasedOnHass(this.hass, oldHass, entities); return shouldUpdateBasedOnHass(this.hass, oldHass, entities);
} }
public static getEmphasizedStyle(): StyleInfo {
return {
color: 'var(--primary-color, white)',
};
}
// Render a menu button. // Render a menu button.
protected _renderButton(button: MenuButton): TemplateResult | void { protected _renderButton(button: MenuButton): TemplateResult | void {
let state: HassEntity | null = null; let state: HassEntity | null = null;
let emphasize = false;
let title = button.title; let title = button.title;
let icon = button.icon; 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 (button.type === 'custom:frigate-card-menu-state-icon') {
if (!this.hass) { if (!this.hass) {
return; return;
} }
state = this.hass.states[button.entity]; state = this.hass.states[button.entity];
emphasize = if (
!!state && button.state_color && ['on', 'active', 'home'].includes(state.state); !!state &&
button.state_color &&
['on', 'active', 'home'].includes(state.state)
) {
style = { ...style, ...FrigateCardMenu.getEmphasizedStyle() };
}
title = title ?? (state?.attributes?.friendly_name || button.entity); title = title ?? (state?.attributes?.friendly_name || button.entity);
icon = icon ?? stateIcon(state); icon = icon ?? stateIcon(state);
} else if (button.type === 'internal-menu-icon') {
emphasize = button.emphasize ?? false;
} }
let hasHold = false; const hasHold = hasAction(button.hold_action);
let hasDoubleClick = false; const hasDoubleClick = hasAction(button.double_tap_action);
if (button.type != 'internal-menu-icon') {
hasHold = hasAction(button.hold_action);
hasDoubleClick = hasAction(button.double_tap_action);
}
const classes = { const classes = {
button: true, button: true,
emphasize: emphasize,
}; };
// TODO: Upon a safe distance from the release of HA 2021.11 these // TODO: Upon a safe distance from the release of HA 2021.11 these
@@ -127,6 +174,7 @@ export class FrigateCardMenu extends LitElement {
.label=${title || ''} .label=${title || ''}
title=${title || ''} title=${title || ''}
@action=${(ev) => this._interactionHandler(ev, button)} @action=${(ev) => this._interactionHandler(ev, button)}
@ll-custom=${(ev: CustomEvent) => convertLovelaceEventToCardActionEvent(this, ev)}
.actionHandler=${actionHandler({ .actionHandler=${actionHandler({
hasHold: hasHold, hasHold: hasHold,
hasDoubleClick: hasDoubleClick, hasDoubleClick: hasDoubleClick,
@@ -136,16 +184,6 @@ export class FrigateCardMenu extends LitElement {
</ha-icon-button>`; </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. // Render the menu.
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this._menuConfig) { if (!this._menuConfig) {
@@ -153,16 +191,7 @@ export class FrigateCardMenu extends LitElement {
} }
const mode = this._menuConfig.mode; const mode = this._menuConfig.mode;
const isFrigateButton = function (button: MenuButton): boolean { if (mode == 'none') {
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))
) {
return; return;
} }
@@ -187,11 +216,7 @@ export class FrigateCardMenu extends LitElement {
return html` return html`
<div class=${classMap(classes)}> <div class=${classMap(classes)}>
${Array.from(this.buttons).map((button) => { ${Array.from(this.buttons).map((button) => this._renderButton(button))}
return isFrigateButton(button)
? this._renderFrigateButton(button)
: this._renderButton(button);
})}
</div> </div>
`; `;
} }
-4
View File
@@ -10,7 +10,3 @@ ha-icon-button.button {
/* Buttons can always be clicked */ /* Buttons can always be clicked */
pointer-events: auto; pointer-events: auto;
} }
ha-icon-button.button.emphasize {
color: var(--primary-color, white);
}
+31 -20
View File
@@ -1,5 +1,6 @@
import { import {
CallServiceActionConfig, CallServiceActionConfig,
CustomActionConfig,
LovelaceCard, LovelaceCard,
LovelaceCardEditor, LovelaceCardEditor,
MoreInfoActionConfig, MoreInfoActionConfig,
@@ -99,12 +100,30 @@ const moreInfoActionSchema = schemaForType<MoreInfoActionConfig>()(
action: z.literal('more-info'), action: z.literal('more-info'),
}), }),
); );
const customActionSchema = schemaForType<CustomActionConfig>()(
z.object({
action: z.literal('fire-dom-event'),
}),
);
export const frigateCardCustomActionSchema = customActionSchema.merge(
z.object({
// Syntactic sugar to avoid 'fire-dom-event' as part of an external API.
action: z
.literal('custom:frigate-card-action')
.transform((): 'fire-dom-event' => 'fire-dom-event')
.or(z.literal('fire-dom-event')),
frigate_card_action: z.string(),
}),
);
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
const elementsActionSchema = z.union([ const elementsActionSchema = z.union([
toggleActionSchema, toggleActionSchema,
callServiceActionSchema, callServiceActionSchema,
navigateActionSchema, navigateActionSchema,
urlActionSchema, urlActionSchema,
moreInfoActionSchema, moreInfoActionSchema,
frigateCardCustomActionSchema,
]); ]);
export type ElementsActionType = z.infer<typeof elementsActionSchema>; export type ElementsActionType = z.infer<typeof elementsActionSchema>;
@@ -244,8 +263,6 @@ const frigateConditionalSchema = z.object({
}); });
export type FrigateConditional = z.infer<typeof frigateConditionalSchema>; 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([ const pictureElementSchema = z.union([
menuStateIconSchema, menuStateIconSchema,
menuIconSchema, menuIconSchema,
@@ -476,21 +493,7 @@ export const frigateCardConfigDefaults = {
event_viewer: viewerConfigDefault, event_viewer: viewerConfigDefault,
}; };
// Schema for card (non-user configured) menu icons. const menuButtonSchema = z.union([menuIconSchema, menuStateIconSchema]);
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 type MenuButton = z.infer<typeof menuButtonSchema>;
export interface ExtendedHomeAssistant { export interface ExtendedHomeAssistant {
hassUrl(path?): string; hassUrl(path?): string;
@@ -506,6 +509,15 @@ export interface BrowseMediaQueryParameters {
after?: number; after?: number;
} }
export interface GetFrigateCardMenuButtonParameters {
icon: string;
title: string;
tap_action: string;
hold_action?: string;
emphasize?: boolean;
}
export interface BrowseMediaNeighbors { export interface BrowseMediaNeighbors {
previous: BrowseMediaSource | null; previous: BrowseMediaSource | null;
previousIndex: number | null; previousIndex: number | null;
@@ -525,9 +537,8 @@ export interface Message {
icon?: string; icon?: string;
} }
export interface MenuInteraction { export interface CardAction {
interaction: 'hold' | 'tap' | 'double_tap'; action: string;
button: MenuButton;
} }
/** /**