diff --git a/src/card.ts b/src/card.ts
index 7fb158aa..57633f66 100644
--- a/src/card.ts
+++ b/src/card.ts
@@ -10,7 +10,7 @@ import pkg from '../package.json';
import { actionHandler } from './action-handler-directive.js';
import { ConditionEvaluateRequestEvent } from './card-controller/conditions-manager.js';
import { CardController } from './card-controller/controller';
-import { MenuButtonController } from './components-lib/menu-controller';
+import { MenuButtonController } from './components-lib/menu-button-controller';
import './components/elements.js';
import { FrigateCardElements } from './components/elements.js';
import './components/menu.js';
diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts
new file mode 100644
index 00000000..56639fb6
--- /dev/null
+++ b/src/components-lib/menu-button-controller.ts
@@ -0,0 +1,519 @@
+import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
+import { StyleInfo } from 'lit/directives/style-map';
+import { CameraManager } from '../camera-manager/manager';
+import { MediaPlayerManager } from '../card-controller/media-player-manager';
+import { MicrophoneManager } from '../card-controller/microphone-manager';
+import {
+ FrigateCardConfig,
+ FrigateCardCustomAction,
+ FRIGATE_CARD_VIEWS_USER_SPECIFIED,
+ MenuItem,
+} from '../config/types';
+import { FRIGATE_BUTTON_MENU_ICON } from '../const';
+import { localize } from '../localize/localize.js';
+import { MediaLoadedInfo } from '../types';
+import {
+ createFrigateCardCameraAction,
+ createFrigateCardDisplayModeAction,
+ createFrigateCardMediaPlayerAction,
+ createFrigateCardShowPTZAction,
+ createFrigateCardSimpleAction,
+} from '../utils/action';
+import { getEntityIcon, getEntityTitle } from '../utils/ha';
+import { hasUsablePTZ } from '../utils/ptz';
+import { hasSubstream } from '../utils/substream';
+import { View } from '../view/view';
+
+export interface MenuButtonControllerOptions {
+ currentMediaLoadedInfo?: MediaLoadedInfo | null;
+ showCameraUIButton?: boolean;
+ inFullscreenMode?: boolean;
+ inExpandedMode?: boolean;
+ microphoneManager?: MicrophoneManager | null;
+ mediaPlayerController?: MediaPlayerManager | null;
+}
+
+export class MenuButtonController {
+ // Array of dynamic menu buttons to be added to menu.
+ protected _dynamicMenuButtons: MenuItem[] = [];
+
+ public addDynamicMenuButton(button: MenuItem): void {
+ if (!this._dynamicMenuButtons.includes(button)) {
+ this._dynamicMenuButtons.push(button);
+ }
+ }
+
+ public removeDynamicMenuButton(button: MenuItem): void {
+ this._dynamicMenuButtons = this._dynamicMenuButtons.filter(
+ (existingButton) => existingButton != button,
+ );
+ }
+
+ /**
+ * Get the menu buttons to display.
+ * @returns An array of menu buttons.
+ */
+ public calculateButtons(
+ hass: HomeAssistant,
+ config: FrigateCardConfig,
+ cameraManager: CameraManager,
+ view: View,
+ options?: MenuButtonControllerOptions,
+ ): MenuItem[] {
+ const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
+ const selectedCameraID = view.camera;
+ const selectedCameraConfig = cameraManager
+ .getStore()
+ .getCameraConfig(selectedCameraID);
+ const allSelectedCameraIDs = cameraManager
+ .getStore()
+ .getAllDependentCameras(selectedCameraID);
+ const selectedMedia = view.queryResults?.getSelectedResult();
+
+ const selectedCameraCapabilities =
+ cameraManager.getCameraCapabilities(selectedCameraID);
+ const aggregateCapabilities =
+ cameraManager.getAggregateCameraCapabilities(allSelectedCameraIDs);
+ const mediaCapabilities = selectedMedia
+ ? cameraManager?.getMediaCapabilities(selectedMedia)
+ : null;
+
+ const buttons: MenuItem[] = [];
+ buttons.push({
+ // Use a magic icon value that the menu will use to render the custom
+ // Frigate icon.
+ icon: FRIGATE_BUTTON_MENU_ICON,
+ ...config.menu.buttons.frigate,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.menu.buttons.frigate'),
+ tap_action:
+ config.menu?.style === 'hidden'
+ ? (createFrigateCardSimpleAction('menu_toggle') as FrigateCardCustomAction)
+ : (createFrigateCardSimpleAction('default') as FrigateCardCustomAction),
+ hold_action: createFrigateCardSimpleAction(
+ 'diagnostics',
+ ) as FrigateCardCustomAction,
+ });
+
+ if (visibleCameraIDs.size) {
+ const menuItems = Array.from(
+ cameraManager.getStore().getCameraConfigEntries(visibleCameraIDs),
+ ([cameraID, config]) => {
+ const action = createFrigateCardCameraAction('camera_select', cameraID);
+ const metadata = cameraManager.getCameraMetadata(cameraID);
+
+ return {
+ enabled: true,
+ icon: metadata?.icon,
+ entity: config.camera_entity,
+ state_color: true,
+ title: metadata?.title,
+ selected: selectedCameraID === cameraID,
+ ...(action && { tap_action: action }),
+ };
+ },
+ );
+
+ buttons.push({
+ icon: 'mdi:video-switch',
+ ...config.menu.buttons.cameras,
+ type: 'custom:frigate-card-menu-submenu',
+ title: localize('config.menu.buttons.cameras'),
+ items: menuItems,
+ });
+ }
+
+ if (selectedCameraID && allSelectedCameraIDs && view.is('live')) {
+ const dependencies = [...allSelectedCameraIDs];
+ const override = view.context?.live?.overrides?.get(selectedCameraID);
+
+ if (dependencies.length === 2) {
+ // If there are only two dependencies (the main camera, and 1 other)
+ // then use a button not a menu to toggle.
+ buttons.push({
+ icon: 'mdi:video-input-component',
+ style:
+ override && override !== selectedCameraID ? this._getEmphasizedStyle() : {},
+ title: localize('config.menu.buttons.substreams'),
+ ...config.menu.buttons.substreams,
+ type: 'custom:frigate-card-menu-icon',
+ tap_action: createFrigateCardSimpleAction(
+ hasSubstream(view) ? 'live_substream_off' : 'live_substream_on',
+ ) as FrigateCardCustomAction,
+ });
+ } else if (dependencies.length > 2) {
+ const menuItems = Array.from(dependencies, (cameraID) => {
+ const action = createFrigateCardCameraAction(
+ 'live_substream_select',
+ cameraID,
+ );
+ const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
+ const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID);
+ return {
+ enabled: true,
+ icon: metadata?.icon,
+ entity: cameraConfig?.camera_entity,
+ state_color: true,
+ title: metadata?.title,
+ selected:
+ (view.context?.live?.overrides?.get(selectedCameraID) ??
+ selectedCameraID) === cameraID,
+ ...(action && { tap_action: action }),
+ };
+ });
+
+ buttons.push({
+ icon: 'mdi:video-input-component',
+ title: localize('config.menu.buttons.substreams'),
+ style:
+ override && override !== selectedCameraID ? this._getEmphasizedStyle() : {},
+ ...config.menu.buttons.substreams,
+ type: 'custom:frigate-card-menu-submenu',
+ items: menuItems,
+ });
+ }
+ }
+
+ buttons.push({
+ icon: 'mdi:cctv',
+ ...config.menu.buttons.live,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.view.views.live'),
+ style: view.is('live') ? this._getEmphasizedStyle() : {},
+ tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction,
+ });
+
+ if (aggregateCapabilities?.supportsClips) {
+ buttons.push({
+ icon: 'mdi:filmstrip',
+ ...config.menu.buttons.clips,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.view.views.clips'),
+ style: view?.is('clips') ? this._getEmphasizedStyle() : {},
+ tap_action: createFrigateCardSimpleAction('clips') as FrigateCardCustomAction,
+ hold_action: createFrigateCardSimpleAction('clip') as FrigateCardCustomAction,
+ });
+ }
+
+ if (aggregateCapabilities?.supportsSnapshots) {
+ buttons.push({
+ icon: 'mdi:camera',
+ ...config.menu.buttons.snapshots,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.view.views.snapshots'),
+ style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
+ tap_action: createFrigateCardSimpleAction(
+ 'snapshots',
+ ) as FrigateCardCustomAction,
+ hold_action: createFrigateCardSimpleAction(
+ 'snapshot',
+ ) as FrigateCardCustomAction,
+ });
+ }
+
+ if (aggregateCapabilities?.supportsRecordings) {
+ buttons.push({
+ icon: 'mdi:album',
+ ...config.menu.buttons.recordings,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.view.views.recordings'),
+ style: view.is('recordings') ? this._getEmphasizedStyle() : {},
+ tap_action: createFrigateCardSimpleAction(
+ 'recordings',
+ ) as FrigateCardCustomAction,
+ hold_action: createFrigateCardSimpleAction(
+ 'recording',
+ ) as FrigateCardCustomAction,
+ });
+ }
+
+ buttons.push({
+ icon: 'mdi:image',
+ ...config.menu.buttons.image,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.view.views.image'),
+ style: view?.is('image') ? this._getEmphasizedStyle() : {},
+ tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction,
+ });
+
+ // Don't show the timeline button unless there's at least one non-birdseye
+ // camera with a Frigate camera name.
+ if (aggregateCapabilities?.supportsTimeline) {
+ buttons.push({
+ icon: 'mdi:chart-gantt',
+ ...config.menu.buttons.timeline,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.view.views.timeline'),
+ style: view.is('timeline') ? this._getEmphasizedStyle() : {},
+ tap_action: createFrigateCardSimpleAction('timeline') as FrigateCardCustomAction,
+ });
+ }
+
+ if (mediaCapabilities?.canDownload && !this._isBeingCasted()) {
+ buttons.push({
+ icon: 'mdi:download',
+ ...config.menu.buttons.download,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.menu.buttons.download'),
+ tap_action: createFrigateCardSimpleAction('download') as FrigateCardCustomAction,
+ });
+ }
+
+ if (options?.showCameraUIButton) {
+ buttons.push({
+ icon: 'mdi:web',
+ ...config.menu.buttons.camera_ui,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.menu.buttons.camera_ui'),
+ tap_action: createFrigateCardSimpleAction(
+ 'camera_ui',
+ ) as FrigateCardCustomAction,
+ });
+ }
+
+ if (
+ options?.microphoneManager &&
+ options?.currentMediaLoadedInfo?.capabilities?.supports2WayAudio
+ ) {
+ const forbidden = options.microphoneManager.isForbidden();
+ const muted = options.microphoneManager.isMuted();
+ const buttonType = config.menu.buttons.microphone.type;
+ buttons.push({
+ icon: forbidden
+ ? 'mdi:microphone-message-off'
+ : muted
+ ? 'mdi:microphone-off'
+ : 'mdi:microphone',
+ ...config.menu.buttons.microphone,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.menu.buttons.microphone'),
+ style: forbidden || muted ? {} : this._getEmphasizedStyle(true),
+ ...(!forbidden &&
+ buttonType === 'momentary' && {
+ start_tap_action: createFrigateCardSimpleAction(
+ 'microphone_unmute',
+ ) as FrigateCardCustomAction,
+ end_tap_action: createFrigateCardSimpleAction(
+ 'microphone_mute',
+ ) as FrigateCardCustomAction,
+ }),
+ ...(!forbidden &&
+ buttonType === 'toggle' && {
+ tap_action: createFrigateCardSimpleAction(
+ options.microphoneManager.isMuted()
+ ? 'microphone_unmute'
+ : 'microphone_mute',
+ ) as FrigateCardCustomAction,
+ }),
+ });
+ }
+
+ if (!this._isBeingCasted()) {
+ buttons.push({
+ icon: options?.inFullscreenMode ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
+ ...config.menu.buttons.fullscreen,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.menu.buttons.fullscreen'),
+ tap_action: createFrigateCardSimpleAction(
+ 'fullscreen',
+ ) as FrigateCardCustomAction,
+ style: options?.inFullscreenMode ? this._getEmphasizedStyle() : {},
+ });
+ }
+
+ buttons.push({
+ icon: options?.inExpandedMode ? 'mdi:arrow-collapse-all' : 'mdi:arrow-expand-all',
+ ...config.menu.buttons.expand,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.menu.buttons.expand'),
+ tap_action: createFrigateCardSimpleAction('expand') as FrigateCardCustomAction,
+ style: options?.inExpandedMode ? this._getEmphasizedStyle() : {},
+ });
+
+ if (
+ options?.mediaPlayerController?.hasMediaPlayers() &&
+ (view?.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity))
+ ) {
+ const mediaPlayerItems = options.mediaPlayerController
+ .getMediaPlayers()
+ .map((playerEntityID) => {
+ const title = getEntityTitle(hass, playerEntityID) || playerEntityID;
+ const state = hass.states[playerEntityID];
+ const playAction = createFrigateCardMediaPlayerAction(playerEntityID, 'play');
+ const stopAction = createFrigateCardMediaPlayerAction(playerEntityID, 'stop');
+ const disabled = !state || state.state === 'unavailable';
+
+ return {
+ enabled: true,
+ selected: false,
+ icon: getEntityIcon(hass, playerEntityID),
+ entity: playerEntityID,
+ state_color: false,
+ title: title,
+ disabled: disabled,
+ ...(!disabled && playAction && { tap_action: playAction }),
+ ...(!disabled && stopAction && { hold_action: stopAction }),
+ };
+ });
+
+ buttons.push({
+ icon: 'mdi:cast',
+ ...config.menu.buttons.media_player,
+ type: 'custom:frigate-card-menu-submenu',
+ title: localize('config.menu.buttons.media_player'),
+ items: mediaPlayerItems,
+ });
+ }
+
+ if (options?.currentMediaLoadedInfo && options.currentMediaLoadedInfo.player) {
+ if (options.currentMediaLoadedInfo.capabilities?.supportsPause) {
+ const paused = options.currentMediaLoadedInfo.player.isPaused();
+ buttons.push({
+ icon: paused ? 'mdi:play' : 'mdi:pause',
+ ...config.menu.buttons.play,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.menu.buttons.play'),
+ tap_action: createFrigateCardSimpleAction(
+ paused ? 'play' : 'pause',
+ ) as FrigateCardCustomAction,
+ });
+ }
+
+ if (options.currentMediaLoadedInfo.capabilities?.hasAudio) {
+ const muted = options.currentMediaLoadedInfo.player.isMuted();
+ buttons.push({
+ icon: muted ? 'mdi:volume-off' : 'mdi:volume-high',
+ ...config.menu.buttons.mute,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.menu.buttons.mute'),
+ tap_action: createFrigateCardSimpleAction(
+ muted ? 'unmute' : 'mute',
+ ) as FrigateCardCustomAction,
+ });
+ }
+ }
+
+ if (options?.currentMediaLoadedInfo && options.currentMediaLoadedInfo.player) {
+ buttons.push({
+ icon: 'mdi:monitor-screenshot',
+ ...config.menu.buttons.screenshot,
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.menu.buttons.screenshot'),
+ tap_action: createFrigateCardSimpleAction(
+ 'screenshot',
+ ) as FrigateCardCustomAction,
+ });
+ }
+
+ if (view.supportsMultipleDisplayModes() && visibleCameraIDs.size > 1) {
+ const isGrid = view.isGrid();
+ buttons.push({
+ icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
+ ...config.menu.buttons.display_mode,
+ style: isGrid ? this._getEmphasizedStyle() : {},
+ type: 'custom:frigate-card-menu-icon',
+ title: isGrid
+ ? localize('display_modes.single')
+ : localize('display_modes.grid'),
+ tap_action: createFrigateCardDisplayModeAction(isGrid ? 'single' : 'grid'),
+ });
+ }
+
+ if (hasUsablePTZ(selectedCameraCapabilities, config.live.controls.ptz)) {
+ const isOn =
+ view.context?.live?.ptzVisible === false
+ ? false
+ : config.live.controls.ptz.mode === 'on';
+ buttons.push({
+ icon: 'mdi:pan',
+ ...config.menu.buttons.ptz,
+ style: isOn ? this._getEmphasizedStyle() : {},
+ type: 'custom:frigate-card-menu-icon',
+ title: localize('config.menu.buttons.ptz'),
+ tap_action: createFrigateCardShowPTZAction(!isOn),
+ });
+ }
+
+ const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
+ style: this._getStyleFromActions(config, view, button, options),
+ ...button,
+ }));
+
+ return buttons.concat(styledDynamicButtons);
+ }
+
+ /**
+ * Get the style of emphasized menu items.
+ * @returns A StyleInfo.
+ */
+ protected _getEmphasizedStyle(critical?: boolean): StyleInfo {
+ if (critical) {
+ return {
+ animation: 'pulse 3s infinite',
+ color: 'var(--error-color, white)',
+ };
+ }
+ return {
+ color: 'var(--primary-color, white)',
+ };
+ }
+
+ /**
+ * Given a button determine if the style should be emphasized by examining all
+ * of the actions sequentially.
+ * @param button The button to examine.
+ * @returns A StyleInfo object.
+ */
+ protected _getStyleFromActions(
+ config: FrigateCardConfig,
+ view: View,
+ button: MenuItem,
+ options?: MenuButtonControllerOptions,
+ ): StyleInfo {
+ for (const actionSet of [
+ button.tap_action,
+ button.double_tap_action,
+ button.hold_action,
+ button.start_tap_action,
+ button.end_tap_action,
+ ]) {
+ const actions = Array.isArray(actionSet) ? actionSet : [actionSet];
+ for (const action of actions) {
+ // All frigate card actions will have action of 'fire-dom-event' and
+ // styling only applies to those.
+ if (
+ !action ||
+ action.action !== 'fire-dom-event' ||
+ !('frigate_card_action' in action)
+ ) {
+ continue;
+ }
+ const frigateCardAction = action as FrigateCardCustomAction;
+ if (
+ FRIGATE_CARD_VIEWS_USER_SPECIFIED.some(
+ (viewName) =>
+ viewName === frigateCardAction.frigate_card_action &&
+ view?.is(frigateCardAction.frigate_card_action),
+ ) ||
+ (frigateCardAction.frigate_card_action === 'default' &&
+ view.is(config.view.default)) ||
+ (frigateCardAction.frigate_card_action === 'fullscreen' &&
+ !!options?.inFullscreenMode) ||
+ (frigateCardAction.frigate_card_action === 'camera_select' &&
+ view.camera === frigateCardAction.camera)
+ ) {
+ return this._getEmphasizedStyle();
+ }
+ }
+ }
+ return {};
+ }
+
+ /**
+ * Determine if the card is currently being casted.
+ * @returns
+ */
+ protected _isBeingCasted(): boolean {
+ return !!navigator.userAgent.match(/CrKey\//);
+ }
+}
diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts
index 56639fb6..0410a340 100644
--- a/src/components-lib/menu-controller.ts
+++ b/src/components-lib/menu-controller.ts
@@ -1,519 +1,215 @@
-import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
-import { StyleInfo } from 'lit/directives/style-map';
-import { CameraManager } from '../camera-manager/manager';
-import { MediaPlayerManager } from '../card-controller/media-player-manager';
-import { MicrophoneManager } from '../card-controller/microphone-manager';
-import {
- FrigateCardConfig,
- FrigateCardCustomAction,
- FRIGATE_CARD_VIEWS_USER_SPECIFIED,
+import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
+import { LitElement } from 'lit';
+import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
+import type {
+ ActionType,
+ ActionsConfig,
+ MenuConfig,
MenuItem,
-} from '../config/types';
-import { FRIGATE_BUTTON_MENU_ICON } from '../const';
-import { localize } from '../localize/localize.js';
-import { MediaLoadedInfo } from '../types';
+} from '../config/types.js';
+import { FRIGATE_BUTTON_MENU_ICON } from '../const.js';
+import { StateParameters } from '../types.js';
import {
- createFrigateCardCameraAction,
- createFrigateCardDisplayModeAction,
- createFrigateCardMediaPlayerAction,
- createFrigateCardShowPTZAction,
- createFrigateCardSimpleAction,
+ convertActionToFrigateCardCustomAction,
+ frigateCardHandleActionConfig,
+ getActionConfigGivenAction,
} from '../utils/action';
-import { getEntityIcon, getEntityTitle } from '../utils/ha';
-import { hasUsablePTZ } from '../utils/ptz';
-import { hasSubstream } from '../utils/substream';
-import { View } from '../view/view';
+import { arrayify, isTruthy } from '../utils/basic.js';
+import { refreshDynamicStateParameters } from '../utils/ha/index.js';
-export interface MenuButtonControllerOptions {
- currentMediaLoadedInfo?: MediaLoadedInfo | null;
- showCameraUIButton?: boolean;
- inFullscreenMode?: boolean;
- inExpandedMode?: boolean;
- microphoneManager?: MicrophoneManager | null;
- mediaPlayerController?: MediaPlayerManager | null;
-}
+export class MenuController {
+ protected _host: LitElement;
+ protected _config: MenuConfig | null = null;
+ protected _buttons: MenuItem[] = [];
+ protected _expanded = false;
-export class MenuButtonController {
- // Array of dynamic menu buttons to be added to menu.
- protected _dynamicMenuButtons: MenuItem[] = [];
-
- public addDynamicMenuButton(button: MenuItem): void {
- if (!this._dynamicMenuButtons.includes(button)) {
- this._dynamicMenuButtons.push(button);
- }
+ constructor(host: LitElement) {
+ this._host = host;
}
- public removeDynamicMenuButton(button: MenuItem): void {
- this._dynamicMenuButtons = this._dynamicMenuButtons.filter(
- (existingButton) => existingButton != button,
+ public setMenuConfig(config: MenuConfig): void {
+ this._config = config;
+ this._host.style.setProperty(
+ '--frigate-card-menu-button-size',
+ `${config.button_size}px`,
+ );
+
+ // Store the menu style, position and alignment as attributes (used for
+ // styling).
+ this._host.setAttribute('data-style', config.style);
+ this._host.setAttribute('data-position', config.position);
+ this._host.setAttribute('data-alignment', config.alignment);
+
+ this._sortButtons();
+ this._host.requestUpdate();
+ }
+
+ public getMenuConfig(): MenuConfig | null {
+ return this._config;
+ }
+
+ public isExpanded(): boolean {
+ return this._expanded;
+ }
+
+ public setButtons(buttons: MenuItem[]): void {
+ this._buttons = buttons;
+ this._sortButtons();
+ this._host.requestUpdate();
+ }
+
+ public getButtons(alignment: 'matching' | 'opposing'): MenuItem[] {
+ const style = this._config?.style;
+
+ const aligned = (button: MenuItem): boolean => {
+ return (
+ button.alignment === alignment || (alignment === 'matching' && !button.alignment)
+ );
+ };
+
+ const enabled = (button: MenuItem): boolean => {
+ return button.enabled !== false;
+ };
+
+ const suitableToShowIfHiddenMenu = (button: MenuItem): boolean => {
+ // If the hidden menu isn't expanded, only show the Frigate button.
+ return (
+ style !== 'hidden' || this._expanded || button.icon === FRIGATE_BUTTON_MENU_ICON
+ );
+ };
+
+ return this._buttons.filter(
+ (button) =>
+ enabled(button) && aligned(button) && suitableToShowIfHiddenMenu(button),
);
}
- /**
- * Get the menu buttons to display.
- * @returns An array of menu buttons.
- */
- public calculateButtons(
+ public setExpanded(expanded: boolean): void {
+ this._expanded = expanded;
+ this._host.setAttribute('expanded', '');
+ this._host.requestUpdate();
+ }
+
+ public toggleExpanded(): void {
+ this.setExpanded(!this._expanded);
+ }
+
+ public actionHandler(
hass: HomeAssistant,
- config: FrigateCardConfig,
- cameraManager: CameraManager,
- view: View,
- options?: MenuButtonControllerOptions,
- ): MenuItem[] {
- const visibleCameraIDs = cameraManager.getStore().getVisibleCameraIDs();
- const selectedCameraID = view.camera;
- const selectedCameraConfig = cameraManager
- .getStore()
- .getCameraConfig(selectedCameraID);
- const allSelectedCameraIDs = cameraManager
- .getStore()
- .getAllDependentCameras(selectedCameraID);
- const selectedMedia = view.queryResults?.getSelectedResult();
+ ev: HASSDomEvent<{ action: string; config?: ActionsConfig }>,
+ config?: ActionsConfig,
+ ): void {
+ // These interactions should only be handled by the menu, as nothing
+ // upstream has the user-provided configuration.
+ ev.stopPropagation();
- const selectedCameraCapabilities =
- cameraManager.getCameraCapabilities(selectedCameraID);
- const aggregateCapabilities =
- cameraManager.getAggregateCameraCapabilities(allSelectedCameraIDs);
- const mediaCapabilities = selectedMedia
- ? cameraManager?.getMediaCapabilities(selectedMedia)
- : null;
-
- const buttons: MenuItem[] = [];
- buttons.push({
- // Use a magic icon value that the menu will use to render the custom
- // Frigate icon.
- icon: FRIGATE_BUTTON_MENU_ICON,
- ...config.menu.buttons.frigate,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.menu.buttons.frigate'),
- tap_action:
- config.menu?.style === 'hidden'
- ? (createFrigateCardSimpleAction('menu_toggle') as FrigateCardCustomAction)
- : (createFrigateCardSimpleAction('default') as FrigateCardCustomAction),
- hold_action: createFrigateCardSimpleAction(
- 'diagnostics',
- ) as FrigateCardCustomAction,
- });
-
- if (visibleCameraIDs.size) {
- const menuItems = Array.from(
- cameraManager.getStore().getCameraConfigEntries(visibleCameraIDs),
- ([cameraID, config]) => {
- const action = createFrigateCardCameraAction('camera_select', cameraID);
- const metadata = cameraManager.getCameraMetadata(cameraID);
-
- return {
- enabled: true,
- icon: metadata?.icon,
- entity: config.camera_entity,
- state_color: true,
- title: metadata?.title,
- selected: selectedCameraID === cameraID,
- ...(action && { tap_action: action }),
- };
- },
- );
-
- buttons.push({
- icon: 'mdi:video-switch',
- ...config.menu.buttons.cameras,
- type: 'custom:frigate-card-menu-submenu',
- title: localize('config.menu.buttons.cameras'),
- items: menuItems,
- });
+ // If the event itself contains a configuration then use that. This is
+ // useful in cases where the registration of the event handler does not have
+ // access to the actual desired configuration (e.g. action events generated
+ // by a submenu).
+ if (ev.detail.config) {
+ config = ev.detail.config;
+ }
+ if (!config) {
+ return;
}
- if (selectedCameraID && allSelectedCameraIDs && view.is('live')) {
- const dependencies = [...allSelectedCameraIDs];
- const override = view.context?.live?.overrides?.get(selectedCameraID);
+ const interaction: string = ev.detail.action;
+ const action = getActionConfigGivenAction(interaction, config);
+ if (!action) {
+ return;
+ }
+ const actions = arrayify(action);
- if (dependencies.length === 2) {
- // If there are only two dependencies (the main camera, and 1 other)
- // then use a button not a menu to toggle.
- buttons.push({
- icon: 'mdi:video-input-component',
- style:
- override && override !== selectedCameraID ? this._getEmphasizedStyle() : {},
- title: localize('config.menu.buttons.substreams'),
- ...config.menu.buttons.substreams,
- type: 'custom:frigate-card-menu-icon',
- tap_action: createFrigateCardSimpleAction(
- hasSubstream(view) ? 'live_substream_off' : 'live_substream_on',
- ) as FrigateCardCustomAction,
- });
- } else if (dependencies.length > 2) {
- const menuItems = Array.from(dependencies, (cameraID) => {
- const action = createFrigateCardCameraAction(
- 'live_substream_select',
- cameraID,
- );
- const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined;
- const cameraConfig = cameraManager.getStore().getCameraConfig(cameraID);
- return {
- enabled: true,
- icon: metadata?.icon,
- entity: cameraConfig?.camera_entity,
- state_color: true,
- title: metadata?.title,
- selected:
- (view.context?.live?.overrides?.get(selectedCameraID) ??
- selectedCameraID) === cameraID,
- ...(action && { tap_action: action }),
- };
- });
+ // A note on the complexity below: By default the menu should close when a
+ // user takes an action, an exception is if the user is specifically
+ // manipulating the menu in the actions themselves.
+ let menuToggle = false;
- buttons.push({
- icon: 'mdi:video-input-component',
- title: localize('config.menu.buttons.substreams'),
- style:
- override && override !== selectedCameraID ? this._getEmphasizedStyle() : {},
- ...config.menu.buttons.substreams,
- type: 'custom:frigate-card-menu-submenu',
- items: menuItems,
- });
+ const toggleLessActions = actions.filter(
+ (item) => isTruthy(item) && !this._isMenuToggleAction(item),
+ );
+ if (toggleLessActions.length != actions.length) {
+ menuToggle = true;
+ }
+
+ if (toggleLessActions.length) {
+ frigateCardHandleActionConfig(this._host, hass, config, interaction, actions);
+ }
+
+ if (this._isHidingMenu()) {
+ if (menuToggle) {
+ this.setExpanded(!this._expanded);
+ } else {
+ // Don't close the menu if there is another action to come.
+ const holdAction = getActionConfigGivenAction('hold', config);
+ const doubleTapAction = getActionConfigGivenAction('double_tap', config);
+ const tapAction = getActionConfigGivenAction('tap', config);
+ const endTapAction = getActionConfigGivenAction('end_tap', config);
+
+ if (
+ interaction === 'end_tap' ||
+ (interaction === 'start_tap' &&
+ !holdAction &&
+ !doubleTapAction &&
+ !tapAction &&
+ !endTapAction) ||
+ (interaction !== 'end_tap' && !endTapAction)
+ ) {
+ this.setExpanded(false);
+ }
}
}
-
- buttons.push({
- icon: 'mdi:cctv',
- ...config.menu.buttons.live,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.view.views.live'),
- style: view.is('live') ? this._getEmphasizedStyle() : {},
- tap_action: createFrigateCardSimpleAction('live') as FrigateCardCustomAction,
- });
-
- if (aggregateCapabilities?.supportsClips) {
- buttons.push({
- icon: 'mdi:filmstrip',
- ...config.menu.buttons.clips,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.view.views.clips'),
- style: view?.is('clips') ? this._getEmphasizedStyle() : {},
- tap_action: createFrigateCardSimpleAction('clips') as FrigateCardCustomAction,
- hold_action: createFrigateCardSimpleAction('clip') as FrigateCardCustomAction,
- });
- }
-
- if (aggregateCapabilities?.supportsSnapshots) {
- buttons.push({
- icon: 'mdi:camera',
- ...config.menu.buttons.snapshots,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.view.views.snapshots'),
- style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
- tap_action: createFrigateCardSimpleAction(
- 'snapshots',
- ) as FrigateCardCustomAction,
- hold_action: createFrigateCardSimpleAction(
- 'snapshot',
- ) as FrigateCardCustomAction,
- });
- }
-
- if (aggregateCapabilities?.supportsRecordings) {
- buttons.push({
- icon: 'mdi:album',
- ...config.menu.buttons.recordings,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.view.views.recordings'),
- style: view.is('recordings') ? this._getEmphasizedStyle() : {},
- tap_action: createFrigateCardSimpleAction(
- 'recordings',
- ) as FrigateCardCustomAction,
- hold_action: createFrigateCardSimpleAction(
- 'recording',
- ) as FrigateCardCustomAction,
- });
- }
-
- buttons.push({
- icon: 'mdi:image',
- ...config.menu.buttons.image,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.view.views.image'),
- style: view?.is('image') ? this._getEmphasizedStyle() : {},
- tap_action: createFrigateCardSimpleAction('image') as FrigateCardCustomAction,
- });
-
- // Don't show the timeline button unless there's at least one non-birdseye
- // camera with a Frigate camera name.
- if (aggregateCapabilities?.supportsTimeline) {
- buttons.push({
- icon: 'mdi:chart-gantt',
- ...config.menu.buttons.timeline,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.view.views.timeline'),
- style: view.is('timeline') ? this._getEmphasizedStyle() : {},
- tap_action: createFrigateCardSimpleAction('timeline') as FrigateCardCustomAction,
- });
- }
-
- if (mediaCapabilities?.canDownload && !this._isBeingCasted()) {
- buttons.push({
- icon: 'mdi:download',
- ...config.menu.buttons.download,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.menu.buttons.download'),
- tap_action: createFrigateCardSimpleAction('download') as FrigateCardCustomAction,
- });
- }
-
- if (options?.showCameraUIButton) {
- buttons.push({
- icon: 'mdi:web',
- ...config.menu.buttons.camera_ui,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.menu.buttons.camera_ui'),
- tap_action: createFrigateCardSimpleAction(
- 'camera_ui',
- ) as FrigateCardCustomAction,
- });
- }
-
- if (
- options?.microphoneManager &&
- options?.currentMediaLoadedInfo?.capabilities?.supports2WayAudio
- ) {
- const forbidden = options.microphoneManager.isForbidden();
- const muted = options.microphoneManager.isMuted();
- const buttonType = config.menu.buttons.microphone.type;
- buttons.push({
- icon: forbidden
- ? 'mdi:microphone-message-off'
- : muted
- ? 'mdi:microphone-off'
- : 'mdi:microphone',
- ...config.menu.buttons.microphone,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.menu.buttons.microphone'),
- style: forbidden || muted ? {} : this._getEmphasizedStyle(true),
- ...(!forbidden &&
- buttonType === 'momentary' && {
- start_tap_action: createFrigateCardSimpleAction(
- 'microphone_unmute',
- ) as FrigateCardCustomAction,
- end_tap_action: createFrigateCardSimpleAction(
- 'microphone_mute',
- ) as FrigateCardCustomAction,
- }),
- ...(!forbidden &&
- buttonType === 'toggle' && {
- tap_action: createFrigateCardSimpleAction(
- options.microphoneManager.isMuted()
- ? 'microphone_unmute'
- : 'microphone_mute',
- ) as FrigateCardCustomAction,
- }),
- });
- }
-
- if (!this._isBeingCasted()) {
- buttons.push({
- icon: options?.inFullscreenMode ? 'mdi:fullscreen-exit' : 'mdi:fullscreen',
- ...config.menu.buttons.fullscreen,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.menu.buttons.fullscreen'),
- tap_action: createFrigateCardSimpleAction(
- 'fullscreen',
- ) as FrigateCardCustomAction,
- style: options?.inFullscreenMode ? this._getEmphasizedStyle() : {},
- });
- }
-
- buttons.push({
- icon: options?.inExpandedMode ? 'mdi:arrow-collapse-all' : 'mdi:arrow-expand-all',
- ...config.menu.buttons.expand,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.menu.buttons.expand'),
- tap_action: createFrigateCardSimpleAction('expand') as FrigateCardCustomAction,
- style: options?.inExpandedMode ? this._getEmphasizedStyle() : {},
- });
-
- if (
- options?.mediaPlayerController?.hasMediaPlayers() &&
- (view?.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity))
- ) {
- const mediaPlayerItems = options.mediaPlayerController
- .getMediaPlayers()
- .map((playerEntityID) => {
- const title = getEntityTitle(hass, playerEntityID) || playerEntityID;
- const state = hass.states[playerEntityID];
- const playAction = createFrigateCardMediaPlayerAction(playerEntityID, 'play');
- const stopAction = createFrigateCardMediaPlayerAction(playerEntityID, 'stop');
- const disabled = !state || state.state === 'unavailable';
-
- return {
- enabled: true,
- selected: false,
- icon: getEntityIcon(hass, playerEntityID),
- entity: playerEntityID,
- state_color: false,
- title: title,
- disabled: disabled,
- ...(!disabled && playAction && { tap_action: playAction }),
- ...(!disabled && stopAction && { hold_action: stopAction }),
- };
- });
-
- buttons.push({
- icon: 'mdi:cast',
- ...config.menu.buttons.media_player,
- type: 'custom:frigate-card-menu-submenu',
- title: localize('config.menu.buttons.media_player'),
- items: mediaPlayerItems,
- });
- }
-
- if (options?.currentMediaLoadedInfo && options.currentMediaLoadedInfo.player) {
- if (options.currentMediaLoadedInfo.capabilities?.supportsPause) {
- const paused = options.currentMediaLoadedInfo.player.isPaused();
- buttons.push({
- icon: paused ? 'mdi:play' : 'mdi:pause',
- ...config.menu.buttons.play,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.menu.buttons.play'),
- tap_action: createFrigateCardSimpleAction(
- paused ? 'play' : 'pause',
- ) as FrigateCardCustomAction,
- });
- }
-
- if (options.currentMediaLoadedInfo.capabilities?.hasAudio) {
- const muted = options.currentMediaLoadedInfo.player.isMuted();
- buttons.push({
- icon: muted ? 'mdi:volume-off' : 'mdi:volume-high',
- ...config.menu.buttons.mute,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.menu.buttons.mute'),
- tap_action: createFrigateCardSimpleAction(
- muted ? 'unmute' : 'mute',
- ) as FrigateCardCustomAction,
- });
- }
- }
-
- if (options?.currentMediaLoadedInfo && options.currentMediaLoadedInfo.player) {
- buttons.push({
- icon: 'mdi:monitor-screenshot',
- ...config.menu.buttons.screenshot,
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.menu.buttons.screenshot'),
- tap_action: createFrigateCardSimpleAction(
- 'screenshot',
- ) as FrigateCardCustomAction,
- });
- }
-
- if (view.supportsMultipleDisplayModes() && visibleCameraIDs.size > 1) {
- const isGrid = view.isGrid();
- buttons.push({
- icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
- ...config.menu.buttons.display_mode,
- style: isGrid ? this._getEmphasizedStyle() : {},
- type: 'custom:frigate-card-menu-icon',
- title: isGrid
- ? localize('display_modes.single')
- : localize('display_modes.grid'),
- tap_action: createFrigateCardDisplayModeAction(isGrid ? 'single' : 'grid'),
- });
- }
-
- if (hasUsablePTZ(selectedCameraCapabilities, config.live.controls.ptz)) {
- const isOn =
- view.context?.live?.ptzVisible === false
- ? false
- : config.live.controls.ptz.mode === 'on';
- buttons.push({
- icon: 'mdi:pan',
- ...config.menu.buttons.ptz,
- style: isOn ? this._getEmphasizedStyle() : {},
- type: 'custom:frigate-card-menu-icon',
- title: localize('config.menu.buttons.ptz'),
- tap_action: createFrigateCardShowPTZAction(!isOn),
- });
- }
-
- const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
- style: this._getStyleFromActions(config, view, button, options),
- ...button,
- }));
-
- return buttons.concat(styledDynamicButtons);
}
- /**
- * Get the style of emphasized menu items.
- * @returns A StyleInfo.
- */
- protected _getEmphasizedStyle(critical?: boolean): StyleInfo {
- if (critical) {
- return {
- animation: 'pulse 3s infinite',
- color: 'var(--error-color, white)',
- };
- }
- return {
- color: 'var(--primary-color, white)',
+ public getFreshButtonState(hass: HomeAssistant, button: MenuItem): StateParameters {
+ const stateParameters = { ...button };
+ return hass && button.type === 'custom:frigate-card-menu-state-icon'
+ ? refreshDynamicStateParameters(hass, stateParameters)
+ : stateParameters;
+ }
+
+ public getSVGPath(button: MenuItem): string {
+ return button.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
+ }
+
+ protected _sortButtons(): void {
+ const style = this._config?.style;
+ const sortButtons = (a: MenuItem, b: MenuItem): number => {
+ // If the menu is hidden, the Frigate button must come first.
+ if (style === 'hidden') {
+ if (a.icon === FRIGATE_BUTTON_MENU_ICON) {
+ return -1;
+ } else if (b.icon === FRIGATE_BUTTON_MENU_ICON) {
+ return 1;
+ }
+ }
+
+ // Otherwise sort by priority.
+ if (
+ a.priority === undefined ||
+ (b.priority !== undefined && b.priority > a.priority)
+ ) {
+ return 1;
+ }
+ if (
+ b.priority === undefined ||
+ (a.priority !== undefined && b.priority < a.priority)
+ ) {
+ return -1;
+ }
+ return 0;
};
+
+ this._buttons.sort(sortButtons);
}
- /**
- * Given a button determine if the style should be emphasized by examining all
- * of the actions sequentially.
- * @param button The button to examine.
- * @returns A StyleInfo object.
- */
- protected _getStyleFromActions(
- config: FrigateCardConfig,
- view: View,
- button: MenuItem,
- options?: MenuButtonControllerOptions,
- ): StyleInfo {
- for (const actionSet of [
- button.tap_action,
- button.double_tap_action,
- button.hold_action,
- button.start_tap_action,
- button.end_tap_action,
- ]) {
- const actions = Array.isArray(actionSet) ? actionSet : [actionSet];
- for (const action of actions) {
- // All frigate card actions will have action of 'fire-dom-event' and
- // styling only applies to those.
- if (
- !action ||
- action.action !== 'fire-dom-event' ||
- !('frigate_card_action' in action)
- ) {
- continue;
- }
- const frigateCardAction = action as FrigateCardCustomAction;
- if (
- FRIGATE_CARD_VIEWS_USER_SPECIFIED.some(
- (viewName) =>
- viewName === frigateCardAction.frigate_card_action &&
- view?.is(frigateCardAction.frigate_card_action),
- ) ||
- (frigateCardAction.frigate_card_action === 'default' &&
- view.is(config.view.default)) ||
- (frigateCardAction.frigate_card_action === 'fullscreen' &&
- !!options?.inFullscreenMode) ||
- (frigateCardAction.frigate_card_action === 'camera_select' &&
- view.camera === frigateCardAction.camera)
- ) {
- return this._getEmphasizedStyle();
- }
- }
- }
- return {};
+ protected _isHidingMenu(): boolean {
+ return this._config?.style === 'hidden' ?? false;
}
- /**
- * Determine if the card is currently being casted.
- * @returns
- */
- protected _isBeingCasted(): boolean {
- return !!navigator.userAgent.match(/CrKey\//);
+ protected _isMenuToggleAction(action: ActionType): boolean {
+ const frigateCardAction = convertActionToFrigateCardCustomAction(action);
+ return !!frigateCardAction && frigateCardAction.frigate_card_action == 'menu_toggle';
}
}
diff --git a/src/components/menu.ts b/src/components/menu.ts
index 59e729c1..1e92952a 100644
--- a/src/components/menu.ts
+++ b/src/components/menu.ts
@@ -1,249 +1,52 @@
-import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers';
-import {
- CSSResultGroup,
- LitElement,
- PropertyValues,
- TemplateResult,
- html,
- unsafeCSS,
-} from 'lit';
-import { customElement, property, state } from 'lit/decorators.js';
-import { classMap } from 'lit/directives/class-map.js';
+import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
+import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { actionHandler } from '../action-handler-directive.js';
-import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
-import type {
- ActionType,
- ActionsConfig,
- MenuConfig,
- MenuItem,
-} from '../config/types.js';
-import { FRIGATE_BUTTON_MENU_ICON } from '../const.js';
+import { MenuController } from '../components-lib/menu-controller.js';
+import type { MenuConfig, MenuItem } from '../config/types.js';
import menuStyle from '../scss/menu.scss';
-import type { StateParameters } from '../types.js';
-import {
- convertActionToFrigateCardCustomAction,
- frigateCardHandleActionConfig,
- frigateCardHasAction,
- getActionConfigGivenAction,
-} from '../utils/action.js';
-import { refreshDynamicStateParameters } from '../utils/ha';
+import { frigateCardHasAction } from '../utils/action.js';
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
import './submenu.js';
-/**
- * A menu for the FrigateCard.
- */
@customElement('frigate-card-menu')
export class FrigateCardMenu extends LitElement {
- @property({ attribute: false })
- public hass?: HomeAssistant;
-
- @property({ attribute: true, type: Boolean, reflect: true })
- public expanded = false;
-
- set menuConfig(menuConfig: MenuConfig) {
- this._menuConfig = menuConfig;
- if (menuConfig) {
- this.style.setProperty(
- '--frigate-card-menu-button-size',
- `${menuConfig.button_size}px`,
- );
- }
- // Store the menu style, position and alignment as attributes (used for
- // styling).
- this.setAttribute('data-style', menuConfig.style);
- this.setAttribute('data-position', menuConfig.position);
- this.setAttribute('data-alignment', menuConfig.alignment);
- }
- @state()
- protected _menuConfig?: MenuConfig;
-
- @property({ attribute: false })
- public buttons: MenuItem[] = [];
+ protected _controller = new MenuController(this);
@property({ attribute: false })
public entityRegistryManager?: EntityRegistryManager;
- /**
- * 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?.style === 'hidden' ?? false;
+ @property({ attribute: false })
+ public hass?: HomeAssistant;
+
+ set menuConfig(menuConfig: MenuConfig) {
+ this._controller.setMenuConfig(menuConfig);
+ }
+
+ set buttons(buttons: MenuItem[]) {
+ this._controller.setButtons(buttons);
+ }
+
+ set expanded(expanded: boolean) {
+ this._controller.setExpanded(expanded);
}
- /**
- * Toggle the menu. Has no action if menu is not hiding/expandable.
- */
public toggleMenu(): void {
- if (this._isHidingMenu()) {
- this.expanded = !this.expanded;
- }
- }
-
- /**
- * 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 | null): 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 == 'menu_toggle';
- }
-
- /**
- * Handle an action on a menu button.
- * @param ev The action event.
- * @param button The button configuration.
- */
- protected _actionHandler(
- ev: HASSDomEvent<{ action: string; config?: ActionsConfig }>,
- config?: ActionsConfig,
- ): void {
- if (!ev) {
- return;
- }
-
- // If the event itself contains a configuration then use that. This is
- // useful in cases where the registration of the event handler does not have
- // access to the actual desired configuration (e.g. action events generated
- // by a submenu).
- if (ev.detail.config) {
- config = ev.detail.config;
- }
-
- // These interactions should only be handled by the menu, as nothing
- // upstream has the user-provided configuration.
- ev.stopPropagation();
-
- const interaction: string = ev.detail.action;
- let action = getActionConfigGivenAction(interaction, config);
- if (!config || !interaction) {
- 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,
- );
- }
- }
-
- if (this._isHidingMenu()) {
- if (menuToggle) {
- this.expanded = !this.expanded;
- } else if (tookAction) {
- // Don't close the menu if there is another action to come.
- const holdAction = getActionConfigGivenAction('hold', config);
- const doubleTapAction = getActionConfigGivenAction('double_tap', config);
- const tapAction = getActionConfigGivenAction('tap', config);
- const endTapAction = getActionConfigGivenAction('end_tap', config);
-
- if (
- interaction === 'end_tap' ||
- (interaction === 'start_tap' &&
- !holdAction &&
- !doubleTapAction &&
- !tapAction &&
- !endTapAction) ||
- (interaction !== 'end_tap' && !endTapAction)
- ) {
- this.expanded = false;
- }
- }
- }
- }
-
- /**
- * Ensure menu buttons are sorted before the render.
- * @param changedProps The changed properties
- */
- protected willUpdate(changedProps: PropertyValues): void {
- const style = this._menuConfig?.style;
- const sortButtons = (a: MenuItem, b: MenuItem): number => {
- // If the menu is hidden, the Frigate button must come first.
- if (style === 'hidden') {
- if (a.icon === FRIGATE_BUTTON_MENU_ICON) {
- return -1;
- } else if (b.icon === FRIGATE_BUTTON_MENU_ICON) {
- return 1;
- }
- }
-
- // Otherwise sort by priority.
- if (
- a.priority === undefined ||
- (b.priority !== undefined && b.priority > a.priority)
- ) {
- return 1;
- }
- if (
- b.priority === undefined ||
- (a.priority !== undefined && b.priority < a.priority)
- ) {
- return -1;
- }
- return 0;
- };
-
- if (changedProps.has('_menuConfig') || changedProps.has('buttons')) {
- this.buttons.sort(sortButtons);
- }
+ this._controller.toggleExpanded();
}
protected _renderButton(button: MenuItem): TemplateResult | void {
+ if (!this.hass) {
+ return;
+ }
+
if (button.type === 'custom:frigate-card-menu-submenu') {
return html` this.hass && this._controller.actionHandler(this.hass, ev)}
>
`;
} else if (button.type === 'custom:frigate-card-menu-submenu-select') {
@@ -251,26 +54,11 @@ export class FrigateCardMenu extends LitElement {
.hass=${this.hass}
.submenuSelect=${button}
.entityRegistryManager=${this.entityRegistryManager}
- @action=${this._actionHandler.bind(this)}
+ @action=${(ev) => this.hass && this._controller.actionHandler(this.hass, ev)}
>
`;
}
- let stateParameters = { ...button } as StateParameters;
- const svgPath =
- stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
-
- if (this.hass && button.type === 'custom:frigate-card-menu-state-icon') {
- stateParameters = refreshDynamicStateParameters(this.hass, stateParameters);
- }
-
- const hasHold = frigateCardHasAction(button.hold_action);
- const hasDoubleClick = frigateCardHasAction(button.double_tap_action);
-
- const classes = {
- button: true,
- };
-
// =====================================================================================
// For `data-domain` and `data-state`, see: See
// https://github.com/home-assistant/frontend/blob/dev/src/components/entity/state-badge.ts#L54
@@ -283,62 +71,53 @@ export class FrigateCardMenu extends LitElement {
// - Static styling based on domain (`data-domain`) and state
// (`data-state`). This looks up a CSS style in `menu.scss`.
+ const buttonState = this._controller.getFreshButtonState(this.hass, button);
+ const svgPath = this._controller.getSVGPath(button);
+
return html` this._actionHandler(ev, button)}
+ .label=${buttonState.title || ''}
+ @action=${(ev) =>
+ this.hass && this._controller.actionHandler(this.hass, ev, button)}
>
${svgPath
? html``
: html``}
`;
}
protected render(): TemplateResult | void {
- if (!this._menuConfig) {
- return;
- }
- const style = this._menuConfig.style;
- if (style === 'none') {
+ const config = this._controller.getMenuConfig();
+ const style = config?.style;
+ if (!config || style === 'none') {
return;
}
+ const matchingButtons = this._controller.getButtons('matching');
+ const opposingButtons = this._controller.getButtons('opposing');
- // If the hidden menu isn't expanded, only show the Frigate button.
- const matchingButtons = (
- style !== 'hidden' || this.expanded
- ? this.buttons.filter(
- (button) => !button.alignment || button.alignment === 'matching',
- )
- : this.buttons.filter((button) => button.icon === FRIGATE_BUTTON_MENU_ICON)
- ).filter((button) => button.enabled !== false);
-
- const opposingButtons =
- style !== 'hidden' || this.expanded
- ? this.buttons.filter(
- (button) => button.alignment === 'opposing' && button.enabled !== false,
- )
- : [];
-
- const matchingStyle = {
- flex: String(matchingButtons.length),
- };
- const opposingStyle = {
- flex: String(opposingButtons.length),
- };
-
- return html`
+ return html`
${matchingButtons.map((button) => this._renderButton(button))}
-
+
${opposingButtons.map((button) => this._renderButton(button))}
`;
}
diff --git a/src/config/types.ts b/src/config/types.ts
index 514b90ff..0fd907a4 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -326,7 +326,7 @@ const actionsSchema = z.object({
});
const elementsBaseSchema = actionsBaseSchema.extend({
- style: z.object({}).passthrough().optional(),
+ style: z.record(z.string().nullable()).optional(),
title: z.string().nullable().optional(),
});
@@ -1255,7 +1255,7 @@ const hiddenButtonSchema = menuBaseSchema.extend({
priority: menuBaseSchema.shape.priority.default(hiddenButtonDefault.priority),
});
-const menuConfigSchema = z
+export const menuConfigSchema = z
.object({
style: z.enum(FRIGATE_MENU_STYLES).default(menuConfigDefault.style),
position: z.enum(FRIGATE_MENU_POSITIONS).default(menuConfigDefault.position),
diff --git a/src/utils/action.ts b/src/utils/action.ts
index 27bb17c6..01b39f78 100644
--- a/src/utils/action.ts
+++ b/src/utils/action.ts
@@ -115,15 +115,15 @@ export function getActionConfigGivenAction(
if (!interaction || !config) {
return null;
}
- if (interaction == 'tap' && config.tap_action) {
+ if (interaction === 'tap' && config.tap_action) {
return config.tap_action;
- } else if (interaction == 'hold' && config.hold_action) {
+ } else if (interaction === 'hold' && config.hold_action) {
return config.hold_action;
- } else if (interaction == 'double_tap' && config.double_tap_action) {
+ } else if (interaction === 'double_tap' && config.double_tap_action) {
return config.double_tap_action;
- } else if (interaction == 'end_tap' && config.end_tap_action) {
+ } else if (interaction === 'end_tap' && config.end_tap_action) {
return config.end_tap_action;
- } else if (interaction == 'start_tap' && config.start_tap_action) {
+ } else if (interaction === 'start_tap' && config.start_tap_action) {
return config.start_tap_action;
}
return null;
diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts
new file mode 100644
index 00000000..87f2c9ba
--- /dev/null
+++ b/tests/components-lib/menu-button-controller.test.ts
@@ -0,0 +1,1351 @@
+import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
+import isEqual from 'lodash-es/isEqual';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import { CameraManager } from '../../src/camera-manager/manager';
+import { CameraManagerCameraMetadata } from '../../src/camera-manager/types';
+import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
+import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
+import {
+ MenuButtonController,
+ MenuButtonControllerOptions,
+} from '../../src/components-lib/menu-button-controller';
+import { FrigateCardConfig, MenuItem, ViewDisplayMode } from '../../src/config/types';
+import { FrigateCardMediaPlayer } from '../../src/types';
+import { createFrigateCardSimpleAction } from '../../src/utils/action';
+import { ViewMedia } from '../../src/view/media';
+import { MediaQueriesResults } from '../../src/view/media-queries-results';
+import { View } from '../../src/view/view';
+import {
+ createAggregateCameraCapabilities,
+ createCameraCapabilities,
+ createCameraConfig,
+ createCameraManager,
+ createCardAPI,
+ createConfig,
+ createHASS,
+ createMediaCapabilities,
+ createMediaLoadedInfo,
+ createStateEntity,
+ createStore,
+ createView,
+} from '../test-utils';
+
+vi.mock('../../src/utils/media-player-controller.js');
+vi.mock('../../src/card-controller/microphone-manager.js');
+
+const calculateButtons = (
+ controller: MenuButtonController,
+ options?: MenuButtonControllerOptions & {
+ hass?: HomeAssistant;
+ config?: FrigateCardConfig;
+ cameraManager?: CameraManager;
+ view?: View;
+ },
+): MenuItem[] => {
+ let cameraManager: CameraManager | null = options?.cameraManager ?? null;
+ if (!cameraManager) {
+ cameraManager = createCameraManager();
+ }
+
+ return controller.calculateButtons(
+ options?.hass ?? createHASS(),
+ options?.config ?? createConfig(),
+ cameraManager,
+ options?.view ?? createView({ camera: 'camera-1' }),
+ options,
+ );
+};
+
+// @vitest-environment jsdom
+describe('MenuButtonController', () => {
+ let controller: MenuButtonController;
+ const dynamicButton: MenuItem = {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:alpha-a-circle',
+ title: 'Dynamic button',
+ };
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ controller = new MenuButtonController();
+ });
+
+ it('should have frigate menu button with hidden menu style', () => {
+ const buttons = calculateButtons(controller);
+ expect(buttons).toContainEqual({
+ icon: 'frigate',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Frigate menu / Default view',
+ tap_action: createFrigateCardSimpleAction('menu_toggle'),
+ hold_action: createFrigateCardSimpleAction('diagnostics'),
+ });
+ });
+
+ it('should have frigate menu button without hidden menu style', () => {
+ const buttons = calculateButtons(controller, {
+ config: createConfig({ menu: { style: 'overlay' } }),
+ });
+ expect(buttons).toContainEqual({
+ icon: 'frigate',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Frigate menu / Default view',
+ tap_action: createFrigateCardSimpleAction('default'),
+ hold_action: createFrigateCardSimpleAction('diagnostics'),
+ });
+ });
+
+ it('should have cameras menu', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getStore).mockReturnValue(
+ createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]),
+ );
+ vi.mocked(cameraManager).getCameraMetadata.mockReturnValue({
+ title: 'title',
+ icon: 'icon',
+ });
+
+ const buttons = calculateButtons(controller, { cameraManager: cameraManager });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:video-switch',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-submenu',
+ title: 'Cameras',
+ items: [
+ {
+ enabled: true,
+ icon: 'icon',
+ entity: undefined,
+ state_color: true,
+ title: 'title',
+ selected: true,
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'camera_select',
+ camera: 'camera-1',
+ },
+ },
+ {
+ enabled: true,
+ icon: 'icon',
+ entity: undefined,
+ state_color: true,
+ title: 'title',
+ selected: false,
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'camera_select',
+ camera: 'camera-2',
+ },
+ },
+ ],
+ });
+ });
+
+ it('should not have a cameras menu without a visible camera', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getStore).mockReturnValue(
+ createStore([
+ { cameraID: 'camera-1', config: createCameraConfig({ hide: true }) },
+ ]),
+ );
+
+ const buttons = calculateButtons(controller, { cameraManager: cameraManager });
+ expect(buttons).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ title: 'Cameras',
+ }),
+ ]),
+ );
+ });
+
+ it('should have substream button with single dependency', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getStore).mockReturnValue(
+ createStore([
+ {
+ cameraID: 'camera-1',
+ config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
+ },
+ { cameraID: 'camera-2' },
+ ]),
+ );
+
+ const buttons = calculateButtons(controller, { cameraManager: cameraManager });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:video-input-component',
+ style: {},
+ title: 'Substream(s)',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'live_substream_on',
+ },
+ });
+ });
+
+ it('should have substream button selected with single dependency', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getStore).mockReturnValue(
+ createStore([
+ {
+ cameraID: 'camera-1',
+ config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
+ },
+ { cameraID: 'camera-2' },
+ ]),
+ );
+
+ const view = createView({
+ camera: 'camera-1',
+ context: {
+ live: {
+ overrides: new Map([['camera-1', 'camera-2']]),
+ },
+ },
+ });
+
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ view: view,
+ });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:video-input-component',
+ style: { color: 'var(--primary-color, white)' },
+ title: 'Substream(s)',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'live_substream_off',
+ },
+ });
+ });
+
+ it('should have substream menu without substream on with multiple dependencies', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getStore).mockReturnValue(
+ createStore([
+ {
+ cameraID: 'camera-1',
+ config: createCameraConfig({
+ camera_entity: 'camera.1',
+ dependencies: { cameras: ['camera-2', 'camera-3'] },
+ }),
+ },
+ {
+ cameraID: 'camera-2',
+ config: createCameraConfig({
+ camera_entity: 'camera.2',
+ }),
+ },
+ {
+ cameraID: 'camera-3',
+ config: createCameraConfig({
+ camera_entity: 'camera.3',
+ }),
+ },
+ ]),
+ );
+
+ // Return different metadata depending on the camera to test multiple code
+ // paths.
+ mock
(cameraManager).getCameraMetadata.mockImplementation(
+ (cameraID: string): CameraManagerCameraMetadata | null => {
+ return cameraID === 'camera-1'
+ ? {
+ title: 'title',
+ icon: 'icon',
+ }
+ : null;
+ },
+ );
+
+ const buttons = calculateButtons(controller, { cameraManager: cameraManager });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:video-input-component',
+ title: 'Substream(s)',
+ style: {},
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-submenu',
+ items: [
+ {
+ enabled: true,
+ icon: 'icon',
+ entity: 'camera.1',
+ state_color: true,
+ title: 'title',
+ selected: true,
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'live_substream_select',
+ camera: 'camera-1',
+ },
+ },
+ {
+ enabled: true,
+ icon: undefined,
+ entity: 'camera.2',
+ state_color: true,
+ title: undefined,
+ selected: false,
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'live_substream_select',
+ camera: 'camera-2',
+ },
+ },
+ {
+ enabled: true,
+ icon: undefined,
+ entity: 'camera.3',
+ state_color: true,
+ title: undefined,
+ selected: false,
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'live_substream_select',
+ camera: 'camera-3',
+ },
+ },
+ ],
+ });
+ });
+
+ it('should have substream menu with substream on with multiple dependencies', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getStore).mockReturnValue(
+ createStore([
+ {
+ cameraID: 'camera-1',
+ config: createCameraConfig({
+ camera_entity: 'camera.1',
+ dependencies: { cameras: ['camera-2', 'camera-3'] },
+ }),
+ },
+ {
+ cameraID: 'camera-2',
+ config: createCameraConfig({
+ camera_entity: 'camera.2',
+ }),
+ },
+ {
+ cameraID: 'camera-3',
+ config: createCameraConfig({
+ camera_entity: 'camera.3',
+ }),
+ },
+ ]),
+ );
+
+ const view = createView({
+ camera: 'camera-1',
+ context: {
+ live: {
+ overrides: new Map([['camera-1', 'camera-2']]),
+ },
+ },
+ });
+
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ view: view,
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:video-input-component',
+ title: 'Substream(s)',
+ style: { color: 'var(--primary-color, white)' },
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-submenu',
+ items: [
+ {
+ enabled: true,
+ icon: undefined,
+ entity: 'camera.1',
+ state_color: true,
+ title: undefined,
+ selected: false,
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'live_substream_select',
+ camera: 'camera-1',
+ },
+ },
+ {
+ enabled: true,
+ icon: undefined,
+ entity: 'camera.2',
+ state_color: true,
+ title: undefined,
+ // camera-2 is selected in this test scenario because of the view
+ // override.
+ selected: true,
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'live_substream_select',
+ camera: 'camera-2',
+ },
+ },
+ {
+ enabled: true,
+ icon: undefined,
+ entity: 'camera.3',
+ state_color: true,
+ title: undefined,
+ selected: false,
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'live_substream_select',
+ camera: 'camera-3',
+ },
+ },
+ ],
+ });
+ });
+
+ it('should have styled live menu button in live view', () => {
+ const buttons = calculateButtons(controller);
+ expect(buttons).toContainEqual({
+ icon: 'mdi:cctv',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Live view',
+ style: { color: 'var(--primary-color, white)' },
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'live' },
+ });
+ });
+
+ it('should have unstyled live menu button in non-live views', () => {
+ const view = createView({ view: 'clips' });
+ const buttons = calculateButtons(controller, { view: view });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:cctv',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Live view',
+ style: {},
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'live' },
+ });
+ });
+
+ it('should have styled clips menu button in clips view', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
+ createAggregateCameraCapabilities({ supportsClips: true }),
+ );
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ view: createView({ view: 'clips' }),
+ });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:filmstrip',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Clips gallery',
+ style: { color: 'var(--primary-color, white)' },
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' },
+ hold_action: { action: 'fire-dom-event', frigate_card_action: 'clip' },
+ });
+ });
+
+ it('should have unstyled clips menu button in non-clips view', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
+ createAggregateCameraCapabilities({ supportsClips: true }),
+ );
+ const buttons = calculateButtons(controller, { cameraManager: cameraManager });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:filmstrip',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Clips gallery',
+ style: {},
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' },
+ hold_action: { action: 'fire-dom-event', frigate_card_action: 'clip' },
+ });
+ });
+
+ it('should have styled snapshots menu button in snapshots view', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
+ createAggregateCameraCapabilities({ supportsSnapshots: true }),
+ );
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ view: createView({ view: 'snapshots' }),
+ });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:camera',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Snapshots gallery',
+ style: { color: 'var(--primary-color, white)' },
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'snapshots' },
+ hold_action: { action: 'fire-dom-event', frigate_card_action: 'snapshot' },
+ });
+ });
+
+ it('should have unstyled snapshots menu button in non-snapshots view', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
+ createAggregateCameraCapabilities({ supportsSnapshots: true }),
+ );
+ const buttons = calculateButtons(controller, { cameraManager: cameraManager });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:camera',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Snapshots gallery',
+ style: {},
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'snapshots' },
+ hold_action: { action: 'fire-dom-event', frigate_card_action: 'snapshot' },
+ });
+ });
+
+ it('should have styled recordings menu button in recordings view', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
+ createAggregateCameraCapabilities({ supportsRecordings: true }),
+ );
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ view: createView({ view: 'recordings' }),
+ });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:album',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Recordings gallery',
+ style: { color: 'var(--primary-color, white)' },
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'recordings' },
+ hold_action: { action: 'fire-dom-event', frigate_card_action: 'recording' },
+ });
+ });
+
+ it('should have unstyled recordings menu button in non-recordings view', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
+ createAggregateCameraCapabilities({ supportsRecordings: true }),
+ );
+ const buttons = calculateButtons(controller, { cameraManager: cameraManager });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:album',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Recordings gallery',
+ style: {},
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'recordings' },
+ hold_action: { action: 'fire-dom-event', frigate_card_action: 'recording' },
+ });
+ });
+
+ it('should have styled image menu button in image view', () => {
+ const buttons = calculateButtons(controller, {
+ view: createView({ view: 'image' }),
+ });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:image',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Static image',
+ style: { color: 'var(--primary-color, white)' },
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'image' },
+ });
+ });
+
+ it('should have unstyled image menu button in non-image view', () => {
+ const buttons = calculateButtons(controller);
+ expect(buttons).toContainEqual({
+ icon: 'mdi:image',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Static image',
+ style: {},
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'image' },
+ });
+ });
+
+ it('should have styled timeline menu button in timeline view', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
+ createAggregateCameraCapabilities({ supportsTimeline: true }),
+ );
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ view: createView({ view: 'timeline' }),
+ });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:chart-gantt',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Timeline view',
+ style: { color: 'var(--primary-color, white)' },
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'timeline' },
+ });
+ });
+
+ it('should have unstyled timeline menu button in non-timeline view', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
+ createAggregateCameraCapabilities({ supportsTimeline: true }),
+ );
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:chart-gantt',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Timeline view',
+ style: {},
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'timeline' },
+ });
+ });
+
+ it('should have download menu button', () => {
+ vi.stubGlobal('navigator', { userAgent: 'foo' });
+
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue(
+ createMediaCapabilities({ canDownload: true }),
+ );
+ const view = createView({
+ queryResults: new MediaQueriesResults({
+ results: [new ViewMedia('clip', 'camera-1')],
+ selectedIndex: 0,
+ }),
+ });
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ view: view,
+ });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:download',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Download',
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'download' },
+ });
+ });
+
+ it('should not have download menu button when being casted', () => {
+ vi.stubGlobal('navigator', {
+ userAgent:
+ 'Mozilla/5.0 (Fuchsia) AppleWebKit/537.36 (KHTML, like Gecko) ' +
+ 'Chrome/114.0.0.0 Safari/537.36 CrKey/1.56.500000',
+ });
+
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue(
+ createMediaCapabilities({ canDownload: true }),
+ );
+ const view = createView({
+ queryResults: new MediaQueriesResults({
+ results: [new ViewMedia('clip', 'camera-1')],
+ selectedIndex: 0,
+ }),
+ });
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ view: view,
+ });
+ expect(buttons).not.toEqual(
+ expect.arrayContaining([expect.objectContaining({ title: 'Download' })]),
+ );
+ });
+
+ it('should have camera UI button', () => {
+ const buttons = calculateButtons(controller, {
+ showCameraUIButton: true,
+ });
+ expect(buttons).toContainEqual({
+ icon: 'mdi:web',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Camera user interface',
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'camera_ui' },
+ });
+ });
+
+ it('should have microphone button', () => {
+ const microphoneManager = new MicrophoneManager(createCardAPI());
+ const buttons = calculateButtons(controller, {
+ microphoneManager: microphoneManager,
+ currentMediaLoadedInfo: createMediaLoadedInfo({
+ capabilities: {
+ supports2WayAudio: true,
+ },
+ }),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:microphone',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Microphone',
+ style: {
+ animation: 'pulse 3s infinite',
+ color: 'var(--error-color, white)',
+ },
+ start_tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'microphone_unmute',
+ },
+ end_tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'microphone_mute',
+ },
+ });
+ });
+
+ it('should not have microphone button when media does not support it', () => {
+ const microphoneManager = new MicrophoneManager(createCardAPI());
+ const buttons = calculateButtons(controller, {
+ microphoneManager: microphoneManager,
+ currentMediaLoadedInfo: createMediaLoadedInfo({
+ capabilities: {
+ supports2WayAudio: false,
+ },
+ }),
+ });
+
+ expect(buttons).not.toEqual(
+ expect.arrayContaining([expect.objectContaining({ title: 'Microphone' })]),
+ );
+ });
+
+ it('should have microphone button when microphone forbidden', () => {
+ const microphoneManager = new MicrophoneManager(createCardAPI());
+ mock(microphoneManager).isForbidden.mockReturnValue(true);
+ const buttons = calculateButtons(controller, {
+ microphoneManager: microphoneManager,
+ currentMediaLoadedInfo: createMediaLoadedInfo({
+ capabilities: {
+ supports2WayAudio: true,
+ },
+ }),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:microphone-message-off',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Microphone',
+ style: {},
+ });
+ });
+
+ it('should have microphone button when microphone muted', () => {
+ const microphoneManager = new MicrophoneManager(createCardAPI());
+ mock(microphoneManager).isMuted.mockReturnValue(true);
+ const buttons = calculateButtons(controller, {
+ microphoneManager: microphoneManager,
+ currentMediaLoadedInfo: createMediaLoadedInfo({
+ capabilities: {
+ supports2WayAudio: true,
+ },
+ }),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:microphone-off',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Microphone',
+ style: {},
+ start_tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'microphone_unmute',
+ },
+ end_tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'microphone_mute',
+ },
+ });
+ });
+
+ it('should have microphone button when microphone muted with toggle type', () => {
+ const microphoneManager = new MicrophoneManager(createCardAPI());
+ mock(microphoneManager).isMuted.mockReturnValue(true);
+ const buttons = calculateButtons(controller, {
+ microphoneManager: microphoneManager,
+ currentMediaLoadedInfo: createMediaLoadedInfo({
+ capabilities: {
+ supports2WayAudio: true,
+ },
+ }),
+ config: createConfig({
+ menu: { buttons: { microphone: { type: 'toggle' } } },
+ }),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:microphone-off',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Microphone',
+ style: {},
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'microphone_unmute',
+ },
+ });
+ });
+
+ it('should have microphone button when microphone unmuted with toggle type', () => {
+ const microphoneManager = new MicrophoneManager(createCardAPI());
+ mock(microphoneManager).isMuted.mockReturnValue(false);
+ const buttons = calculateButtons(controller, {
+ microphoneManager: microphoneManager,
+ currentMediaLoadedInfo: createMediaLoadedInfo({
+ capabilities: {
+ supports2WayAudio: true,
+ },
+ }),
+ config: createConfig({
+ menu: { buttons: { microphone: { type: 'toggle' } } },
+ }),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:microphone',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Microphone',
+ style: {
+ animation: 'pulse 3s infinite',
+ color: 'var(--error-color, white)',
+ },
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'microphone_mute',
+ },
+ });
+ });
+
+ it('should have fullscreen button', () => {
+ // Need to write a readonly property.
+ vi.stubGlobal('navigator', { userAgent: 'foo' });
+ const buttons = calculateButtons(controller, { inFullscreenMode: false });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:fullscreen',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Fullscreen',
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' },
+ style: {},
+ });
+ });
+
+ it('should have unfullscreen', () => {
+ vi.stubGlobal('navigator', { userAgent: 'foo' });
+ const buttons = calculateButtons(controller, { inFullscreenMode: true });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:fullscreen-exit',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Fullscreen',
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' },
+ style: { color: 'var(--primary-color, white)' },
+ });
+ });
+
+ it('should have expand button', () => {
+ const buttons = calculateButtons(controller, { inExpandedMode: false });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:arrow-expand-all',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Expand',
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'expand' },
+ style: {},
+ });
+ });
+
+ it('should have unexpand button', () => {
+ const buttons = calculateButtons(controller, { inExpandedMode: true });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:arrow-collapse-all',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Expand',
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'expand' },
+ style: { color: 'var(--primary-color, white)' },
+ });
+ });
+
+ it('should have media players button', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getStore).mockReturnValue(
+ createStore([
+ {
+ cameraID: 'camera-1',
+ config: createCameraConfig({
+ camera_entity: 'camera.1',
+ }),
+ },
+ ]),
+ );
+
+ const mediaPlayerController = mock();
+ mediaPlayerController.hasMediaPlayers.mockReturnValue(true);
+ mediaPlayerController.getMediaPlayers.mockReturnValue(['media_player.tv']);
+
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ mediaPlayerController: mediaPlayerController,
+ hass: createHASS({
+ 'media_player.tv': createStateEntity({ entity_id: 'media_player.tv' }),
+ }),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:cast',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-submenu',
+ title: 'Send to media player',
+ items: [
+ {
+ enabled: true,
+ selected: false,
+ icon: 'mdi:cast',
+ entity: 'media_player.tv',
+ state_color: false,
+ title: 'media_player.tv',
+ disabled: false,
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'media_player',
+ media_player: 'media_player.tv',
+ media_player_action: 'play',
+ },
+ hold_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'media_player',
+ media_player: 'media_player.tv',
+ media_player_action: 'stop',
+ },
+ },
+ ],
+ });
+ });
+
+ it('should disable media players button when entity not found', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getStore).mockReturnValue(
+ createStore([
+ {
+ cameraID: 'camera-1',
+ config: createCameraConfig({
+ camera_entity: 'camera.1',
+ }),
+ },
+ ]),
+ );
+ const mediaPlayerController = mock();
+ mediaPlayerController.hasMediaPlayers.mockReturnValue(true);
+ mediaPlayerController.getMediaPlayers.mockReturnValue(['not_a_real_player']);
+
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ mediaPlayerController: mediaPlayerController,
+ hass: createHASS(),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:cast',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-submenu',
+ title: 'Send to media player',
+ items: [
+ {
+ enabled: true,
+ selected: false,
+ icon: 'mdi:bookmark',
+ entity: 'not_a_real_player',
+ state_color: false,
+ title: 'not_a_real_player',
+ disabled: true,
+ },
+ ],
+ });
+ });
+
+ it('should have pause button', () => {
+ const player = mock();
+ const buttons = calculateButtons(controller, {
+ currentMediaLoadedInfo: createMediaLoadedInfo({
+ capabilities: {
+ supportsPause: true,
+ },
+ player: player,
+ }),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:pause',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Play / Pause',
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'pause' },
+ });
+ });
+
+ it('should have play button', () => {
+ const player = mock();
+ player.isPaused.mockReturnValue(true);
+ const buttons = calculateButtons(controller, {
+ currentMediaLoadedInfo: createMediaLoadedInfo({
+ capabilities: {
+ supportsPause: true,
+ },
+ player: player,
+ }),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:play',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Play / Pause',
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'play' },
+ });
+ });
+
+ it('should have mute button', () => {
+ const player = mock();
+ const buttons = calculateButtons(controller, {
+ currentMediaLoadedInfo: createMediaLoadedInfo({
+ capabilities: {
+ hasAudio: true,
+ },
+ player: player,
+ }),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:volume-high',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Mute / Unmute',
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'mute' },
+ });
+ });
+
+ it('should have unmute button', () => {
+ const player = mock();
+ player.isMuted.mockReturnValue(true);
+ const buttons = calculateButtons(controller, {
+ currentMediaLoadedInfo: createMediaLoadedInfo({
+ capabilities: {
+ hasAudio: true,
+ },
+ player: player,
+ }),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:volume-off',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Mute / Unmute',
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'unmute' },
+ });
+ });
+
+ it('should have screenshot button', () => {
+ const buttons = calculateButtons(controller, {
+ currentMediaLoadedInfo: createMediaLoadedInfo({
+ player: mock(),
+ }),
+ });
+
+ expect(buttons).toContainEqual({
+ icon: 'mdi:monitor-screenshot',
+ enabled: false,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title: 'Screenshot',
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'screenshot' },
+ });
+ });
+
+ describe('should have grid button when display mode is', () => {
+ it.each([['single' as const], ['grid' as const]])(
+ '%s',
+ (displayMode: ViewDisplayMode) => {
+ const view = createView({ view: 'live', displayMode: displayMode });
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getStore).mockReturnValue(
+ createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]),
+ );
+ expect(
+ calculateButtons(controller, { cameraManager: cameraManager, view: view }),
+ ).toContainEqual({
+ icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off',
+ enabled: true,
+ priority: 50,
+ type: 'custom:frigate-card-menu-icon',
+ title:
+ displayMode === 'grid'
+ ? 'Show single media viewer'
+ : 'Show media viewer for each camera in a grid',
+ style: displayMode === 'grid' ? { color: 'var(--primary-color, white)' } : {},
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'display_mode_select',
+ display_mode: displayMode === 'single' ? 'grid' : 'single',
+ },
+ });
+ },
+ );
+ });
+
+ describe('should have show ptz button', () => {
+ it('when the selected camera is not PTZ enabled', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
+ createCameraCapabilities(),
+ );
+
+ const buttons = calculateButtons(controller, { cameraManager: cameraManager });
+ expect(buttons).not.toContainEqual({
+ enabled: false,
+ icon: 'mdi:pan',
+ priority: 50,
+ style: {
+ color: 'var(--primary-color, white)',
+ },
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'show_ptz',
+ show_ptz: false,
+ },
+ title: 'Show PTZ controls',
+ type: 'custom:frigate-card-menu-icon',
+ });
+ });
+
+ it('when the selected camera is PTZ enabled', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
+ createCameraCapabilities({ ptz: {} }),
+ );
+
+ const buttons = calculateButtons(controller, { cameraManager: cameraManager });
+ expect(buttons).toContainEqual({
+ enabled: false,
+ icon: 'mdi:pan',
+ priority: 50,
+ style: {
+ color: 'var(--primary-color, white)',
+ },
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'show_ptz',
+ show_ptz: false,
+ },
+ title: 'Show PTZ controls',
+ type: 'custom:frigate-card-menu-icon',
+ });
+ });
+
+ it('when the context has PTZ visiblity turned off', () => {
+ const cameraManager = createCameraManager();
+ vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
+ createCameraCapabilities({ ptz: {} }),
+ );
+ const view = createView({
+ camera: 'camera-1',
+ context: { live: { ptzVisible: false } },
+ });
+
+ const buttons = calculateButtons(controller, {
+ cameraManager: cameraManager,
+ view: view,
+ });
+ expect(buttons).toContainEqual({
+ enabled: false,
+ icon: 'mdi:pan',
+ priority: 50,
+ style: {},
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'show_ptz',
+ show_ptz: true,
+ },
+ title: 'Show PTZ controls',
+ type: 'custom:frigate-card-menu-icon',
+ });
+ });
+ });
+
+ it('should handle dynamic buttons', () => {
+ const button: MenuItem = {
+ ...dynamicButton,
+ style: {},
+ };
+ controller.addDynamicMenuButton(button);
+ expect(
+ calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton))
+ .length,
+ ).toBe(1);
+
+ // Adding it again will have no effect.
+ controller.addDynamicMenuButton(button);
+ expect(
+ calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton))
+ .length,
+ ).toBe(1);
+
+ controller.removeDynamicMenuButton(button);
+ expect(calculateButtons(controller)).not.toContainEqual(button);
+ });
+
+ it('should not set style for dynamic button with stock action', () => {
+ const button: MenuItem = {
+ ...dynamicButton,
+ tap_action: { action: 'navigate', navigation_path: 'foo' },
+ };
+ controller.addDynamicMenuButton(button);
+
+ expect(calculateButtons(controller)).toContainEqual({
+ ...button,
+ style: {},
+ });
+ });
+
+ it('should not set style for dynamic button with non-Frigate fire-dom-event action', () => {
+ const button: MenuItem = {
+ ...dynamicButton,
+ tap_action: { action: 'fire-dom-event' },
+ };
+ controller.addDynamicMenuButton(button);
+
+ controller.addDynamicMenuButton(dynamicButton);
+ expect(calculateButtons(controller)).toContainEqual({
+ ...button,
+ style: {},
+ });
+ });
+
+ it('should set style for dynamic button with Frigate view action', () => {
+ const button: MenuItem = {
+ ...dynamicButton,
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' },
+ };
+
+ const view = createView({ view: 'clips' });
+ controller.addDynamicMenuButton(button);
+ expect(calculateButtons(controller, { view: view })).toContainEqual({
+ ...button,
+ style: { color: 'var(--primary-color, white)' },
+ });
+ });
+
+ it('should set style for dynamic button with Frigate default action', () => {
+ const button: MenuItem = {
+ ...dynamicButton,
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'default' },
+ };
+
+ controller.addDynamicMenuButton(button);
+ expect(calculateButtons(controller)).toContainEqual({
+ ...button,
+ style: { color: 'var(--primary-color, white)' },
+ });
+ });
+
+ it('should set style for dynamic button with fullscreen action', () => {
+ const button: MenuItem = {
+ ...dynamicButton,
+ tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' },
+ };
+
+ controller.addDynamicMenuButton(button);
+ expect(calculateButtons(controller, { inFullscreenMode: true })).toContainEqual({
+ ...button,
+ style: { color: 'var(--primary-color, white)' },
+ });
+ });
+
+ it('should set style for dynamic button with camera_select action', () => {
+ const button: MenuItem = {
+ ...dynamicButton,
+ tap_action: {
+ action: 'fire-dom-event',
+ frigate_card_action: 'camera_select',
+ camera: 'foo',
+ },
+ };
+
+ const view = createView({ camera: 'foo' });
+ controller.addDynamicMenuButton(button);
+ expect(calculateButtons(controller, { view: view })).toContainEqual({
+ ...button,
+ style: { color: 'var(--primary-color, white)' },
+ });
+ });
+
+ it('should set style for dynamic button with array of actions', () => {
+ const button: MenuItem = {
+ ...dynamicButton,
+ tap_action: [
+ { action: 'fire-dom-event' },
+ { action: 'fire-dom-event', frigate_card_action: 'clips' },
+ ],
+ };
+
+ const view = createView({ camera: 'clips' });
+ controller.addDynamicMenuButton(button);
+ expect(calculateButtons(controller, { view: view })).toContainEqual({
+ ...button,
+ style: {},
+ });
+ });
+});
diff --git a/tests/components-lib/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts
index b5de796b..163e621b 100644
--- a/tests/components-lib/menu-controller.test.ts
+++ b/tests/components-lib/menu-controller.test.ts
@@ -1,1351 +1,499 @@
-import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
-import isEqual from 'lodash-es/isEqual';
+import {
+ HASSDomEvent,
+ handleActionConfig,
+} from '@dermotduffy/custom-card-helpers';
+import { LitElement } from 'lit';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { mock } from 'vitest-mock-extended';
-import { CameraManager } from '../../src/camera-manager/manager';
-import { CameraManagerCameraMetadata } from '../../src/camera-manager/types';
-import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
-import { MicrophoneManager } from '../../src/card-controller/microphone-manager';
-import {
- MenuButtonController,
- MenuButtonControllerOptions,
-} from '../../src/components-lib/menu-controller';
-import { FrigateCardConfig, MenuItem, ViewDisplayMode } from '../../src/config/types';
-import { FrigateCardMediaPlayer } from '../../src/types';
-import { createFrigateCardSimpleAction } from '../../src/utils/action';
-import { ViewMedia } from '../../src/view/media';
-import { MediaQueriesResults } from '../../src/view/media-queries-results';
-import { View } from '../../src/view/view';
-import {
- createAggregateCameraCapabilities,
- createCameraCapabilities,
- createCameraConfig,
- createCameraManager,
- createCardAPI,
- createConfig,
- createHASS,
- createMediaCapabilities,
- createMediaLoadedInfo,
- createStateEntity,
- createStore,
- createView,
-} from '../test-utils';
+import { FRIGATE_ICON_SVG_PATH } from '../../src/camera-manager/frigate/icon';
+import { MenuController } from '../../src/components-lib/menu-controller';
+import { ActionsConfig, MenuConfig, menuConfigSchema } from '../../src/config/types';
+import { StateParameters } from '../../src/types';
+import { refreshDynamicStateParameters } from '../../src/utils/ha';
+import { createHASS } from '../test-utils';
-vi.mock('../../src/utils/media-player-controller.js');
-vi.mock('../../src/card-controller/microphone-manager.js');
+vi.mock('@dermotduffy/custom-card-helpers');
+vi.mock('../../src/utils/ha');
-const calculateButtons = (
- controller: MenuButtonController,
- options?: MenuButtonControllerOptions & {
- hass?: HomeAssistant;
- config?: FrigateCardConfig;
- cameraManager?: CameraManager;
- view?: View;
- },
-): MenuItem[] => {
- let cameraManager: CameraManager | null = options?.cameraManager ?? null;
- if (!cameraManager) {
- cameraManager = createCameraManager();
- }
+const createHost = (): LitElement => {
+ const host = document.createElement('div') as unknown as LitElement;
+ host.requestUpdate = vi.fn();
+ return host;
+};
- return controller.calculateButtons(
- options?.hass ?? createHASS(),
- options?.config ?? createConfig(),
- cameraManager,
- options?.view ?? createView({ camera: 'camera-1' }),
- options,
- );
+const createMenuConfig = (config: unknown): MenuConfig => {
+ return menuConfigSchema.parse(config);
+};
+
+const createEvent = (
+ action: string,
+ config?: ActionsConfig,
+): HASSDomEvent<{ action: string; config?: ActionsConfig }> => {
+ return new CustomEvent<{ action: string; config?: ActionsConfig }>('@action', {
+ detail: {
+ action: action,
+ config: config,
+ },
+ });
};
// @vitest-environment jsdom
-describe('MenuButtonController', () => {
- let controller: MenuButtonController;
- const dynamicButton: MenuItem = {
- type: 'custom:frigate-card-menu-icon',
- icon: 'mdi:alpha-a-circle',
- title: 'Dynamic button',
+describe('MenuController', () => {
+ const action = {
+ action: 'fire-dom-event' as const,
+ };
+ const menuToggleAction = {
+ action: 'fire-dom-event' as const,
+ frigate_card_action: 'menu_toggle' as const,
+ };
+ const tapActionConfig = {
+ camera_entity: 'foo',
+ tap_action: action,
+ };
+ const tapActionConfigMulti = {
+ camera_entity: 'foo',
+ tap_action: [action, action, action],
};
beforeEach(() => {
- vi.resetAllMocks();
- controller = new MenuButtonController();
+ vi.clearAllMocks();
});
- it('should have frigate menu button with hidden menu style', () => {
- const buttons = calculateButtons(controller);
- expect(buttons).toContainEqual({
- icon: 'frigate',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Frigate menu / Default view',
- tap_action: createFrigateCardSimpleAction('menu_toggle'),
- hold_action: createFrigateCardSimpleAction('diagnostics'),
+ it('should set and get menu config', () => {
+ const host = createHost();
+ const controller = new MenuController(host);
+
+ const config = createMenuConfig({
+ button_size: 21,
+ style: 'hover',
+ position: 'left',
+ alignment: 'top',
});
+ controller.setMenuConfig(config);
+ expect(controller.getMenuConfig()).toBe(config);
+
+ expect(host.style.getPropertyValue('--frigate-card-menu-button-size')).toBe('21px');
+ expect(host.getAttribute('data-style')).toBe('hover');
+ expect(host.getAttribute('data-position')).toBe('left');
+ expect(host.getAttribute('data-alignment')).toBe('top');
});
- it('should have frigate menu button without hidden menu style', () => {
- const buttons = calculateButtons(controller, {
- config: createConfig({ menu: { style: 'overlay' } }),
- });
- expect(buttons).toContainEqual({
- icon: 'frigate',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Frigate menu / Default view',
- tap_action: createFrigateCardSimpleAction('default'),
- hold_action: createFrigateCardSimpleAction('diagnostics'),
- });
- });
-
- it('should have cameras menu', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getStore).mockReturnValue(
- createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]),
- );
- vi.mocked(cameraManager).getCameraMetadata.mockReturnValue({
- title: 'title',
- icon: 'icon',
- });
-
- const buttons = calculateButtons(controller, { cameraManager: cameraManager });
- expect(buttons).toContainEqual({
- icon: 'mdi:video-switch',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-submenu',
- title: 'Cameras',
- items: [
+ describe('should set and sort buttons', () => {
+ it('by priority', () => {
+ const controller = new MenuController(createHost());
+ controller.setButtons([
{
- enabled: true,
- icon: 'icon',
- entity: undefined,
- state_color: true,
- title: 'title',
- selected: true,
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'camera_select',
- camera: 'camera-1',
- },
- },
- {
- enabled: true,
- icon: 'icon',
- entity: undefined,
- state_color: true,
- title: 'title',
- selected: false,
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'camera_select',
- camera: 'camera-2',
- },
- },
- ],
- });
- });
-
- it('should not have a cameras menu without a visible camera', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getStore).mockReturnValue(
- createStore([
- { cameraID: 'camera-1', config: createCameraConfig({ hide: true }) },
- ]),
- );
-
- const buttons = calculateButtons(controller, { cameraManager: cameraManager });
- expect(buttons).not.toEqual(
- expect.arrayContaining([
- expect.objectContaining({
- title: 'Cameras',
- }),
- ]),
- );
- });
-
- it('should have substream button with single dependency', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getStore).mockReturnValue(
- createStore([
- {
- cameraID: 'camera-1',
- config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
- },
- { cameraID: 'camera-2' },
- ]),
- );
-
- const buttons = calculateButtons(controller, { cameraManager: cameraManager });
- expect(buttons).toContainEqual({
- icon: 'mdi:video-input-component',
- style: {},
- title: 'Substream(s)',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'live_substream_on',
- },
- });
- });
-
- it('should have substream button selected with single dependency', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getStore).mockReturnValue(
- createStore([
- {
- cameraID: 'camera-1',
- config: createCameraConfig({ dependencies: { cameras: ['camera-2'] } }),
- },
- { cameraID: 'camera-2' },
- ]),
- );
-
- const view = createView({
- camera: 'camera-1',
- context: {
- live: {
- overrides: new Map([['camera-1', 'camera-2']]),
- },
- },
- });
-
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- view: view,
- });
- expect(buttons).toContainEqual({
- icon: 'mdi:video-input-component',
- style: { color: 'var(--primary-color, white)' },
- title: 'Substream(s)',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'live_substream_off',
- },
- });
- });
-
- it('should have substream menu without substream on with multiple dependencies', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getStore).mockReturnValue(
- createStore([
- {
- cameraID: 'camera-1',
- config: createCameraConfig({
- camera_entity: 'camera.1',
- dependencies: { cameras: ['camera-2', 'camera-3'] },
- }),
- },
- {
- cameraID: 'camera-2',
- config: createCameraConfig({
- camera_entity: 'camera.2',
- }),
- },
- {
- cameraID: 'camera-3',
- config: createCameraConfig({
- camera_entity: 'camera.3',
- }),
- },
- ]),
- );
-
- // Return different metadata depending on the camera to test multiple code
- // paths.
- mock(cameraManager).getCameraMetadata.mockImplementation(
- (cameraID: string): CameraManagerCameraMetadata | null => {
- return cameraID === 'camera-1'
- ? {
- title: 'title',
- icon: 'icon',
- }
- : null;
- },
- );
-
- const buttons = calculateButtons(controller, { cameraManager: cameraManager });
- expect(buttons).toContainEqual({
- icon: 'mdi:video-input-component',
- title: 'Substream(s)',
- style: {},
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-submenu',
- items: [
- {
- enabled: true,
- icon: 'icon',
- entity: 'camera.1',
- state_color: true,
- title: 'title',
- selected: true,
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'live_substream_select',
- camera: 'camera-1',
- },
- },
- {
- enabled: true,
- icon: undefined,
- entity: 'camera.2',
- state_color: true,
- title: undefined,
- selected: false,
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'live_substream_select',
- camera: 'camera-2',
- },
- },
- {
- enabled: true,
- icon: undefined,
- entity: 'camera.3',
- state_color: true,
- title: undefined,
- selected: false,
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'live_substream_select',
- camera: 'camera-3',
- },
- },
- ],
- });
- });
-
- it('should have substream menu with substream on with multiple dependencies', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getStore).mockReturnValue(
- createStore([
- {
- cameraID: 'camera-1',
- config: createCameraConfig({
- camera_entity: 'camera.1',
- dependencies: { cameras: ['camera-2', 'camera-3'] },
- }),
- },
- {
- cameraID: 'camera-2',
- config: createCameraConfig({
- camera_entity: 'camera.2',
- }),
- },
- {
- cameraID: 'camera-3',
- config: createCameraConfig({
- camera_entity: 'camera.3',
- }),
- },
- ]),
- );
-
- const view = createView({
- camera: 'camera-1',
- context: {
- live: {
- overrides: new Map([['camera-1', 'camera-2']]),
- },
- },
- });
-
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- view: view,
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:video-input-component',
- title: 'Substream(s)',
- style: { color: 'var(--primary-color, white)' },
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-submenu',
- items: [
- {
- enabled: true,
- icon: undefined,
- entity: 'camera.1',
- state_color: true,
- title: undefined,
- selected: false,
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'live_substream_select',
- camera: 'camera-1',
- },
- },
- {
- enabled: true,
- icon: undefined,
- entity: 'camera.2',
- state_color: true,
- title: undefined,
- // camera-2 is selected in this test scenario because of the view
- // override.
- selected: true,
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'live_substream_select',
- camera: 'camera-2',
- },
- },
- {
- enabled: true,
- icon: undefined,
- entity: 'camera.3',
- state_color: true,
- title: undefined,
- selected: false,
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'live_substream_select',
- camera: 'camera-3',
- },
- },
- ],
- });
- });
-
- it('should have styled live menu button in live view', () => {
- const buttons = calculateButtons(controller);
- expect(buttons).toContainEqual({
- icon: 'mdi:cctv',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Live view',
- style: { color: 'var(--primary-color, white)' },
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'live' },
- });
- });
-
- it('should have unstyled live menu button in non-live views', () => {
- const view = createView({ view: 'clips' });
- const buttons = calculateButtons(controller, { view: view });
- expect(buttons).toContainEqual({
- icon: 'mdi:cctv',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Live view',
- style: {},
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'live' },
- });
- });
-
- it('should have styled clips menu button in clips view', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
- createAggregateCameraCapabilities({ supportsClips: true }),
- );
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- view: createView({ view: 'clips' }),
- });
- expect(buttons).toContainEqual({
- icon: 'mdi:filmstrip',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Clips gallery',
- style: { color: 'var(--primary-color, white)' },
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' },
- hold_action: { action: 'fire-dom-event', frigate_card_action: 'clip' },
- });
- });
-
- it('should have unstyled clips menu button in non-clips view', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
- createAggregateCameraCapabilities({ supportsClips: true }),
- );
- const buttons = calculateButtons(controller, { cameraManager: cameraManager });
- expect(buttons).toContainEqual({
- icon: 'mdi:filmstrip',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Clips gallery',
- style: {},
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' },
- hold_action: { action: 'fire-dom-event', frigate_card_action: 'clip' },
- });
- });
-
- it('should have styled snapshots menu button in snapshots view', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
- createAggregateCameraCapabilities({ supportsSnapshots: true }),
- );
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- view: createView({ view: 'snapshots' }),
- });
- expect(buttons).toContainEqual({
- icon: 'mdi:camera',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Snapshots gallery',
- style: { color: 'var(--primary-color, white)' },
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'snapshots' },
- hold_action: { action: 'fire-dom-event', frigate_card_action: 'snapshot' },
- });
- });
-
- it('should have unstyled snapshots menu button in non-snapshots view', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
- createAggregateCameraCapabilities({ supportsSnapshots: true }),
- );
- const buttons = calculateButtons(controller, { cameraManager: cameraManager });
- expect(buttons).toContainEqual({
- icon: 'mdi:camera',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Snapshots gallery',
- style: {},
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'snapshots' },
- hold_action: { action: 'fire-dom-event', frigate_card_action: 'snapshot' },
- });
- });
-
- it('should have styled recordings menu button in recordings view', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
- createAggregateCameraCapabilities({ supportsRecordings: true }),
- );
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- view: createView({ view: 'recordings' }),
- });
- expect(buttons).toContainEqual({
- icon: 'mdi:album',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Recordings gallery',
- style: { color: 'var(--primary-color, white)' },
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'recordings' },
- hold_action: { action: 'fire-dom-event', frigate_card_action: 'recording' },
- });
- });
-
- it('should have unstyled recordings menu button in non-recordings view', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
- createAggregateCameraCapabilities({ supportsRecordings: true }),
- );
- const buttons = calculateButtons(controller, { cameraManager: cameraManager });
- expect(buttons).toContainEqual({
- icon: 'mdi:album',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Recordings gallery',
- style: {},
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'recordings' },
- hold_action: { action: 'fire-dom-event', frigate_card_action: 'recording' },
- });
- });
-
- it('should have styled image menu button in image view', () => {
- const buttons = calculateButtons(controller, {
- view: createView({ view: 'image' }),
- });
- expect(buttons).toContainEqual({
- icon: 'mdi:image',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Static image',
- style: { color: 'var(--primary-color, white)' },
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'image' },
- });
- });
-
- it('should have unstyled image menu button in non-image view', () => {
- const buttons = calculateButtons(controller);
- expect(buttons).toContainEqual({
- icon: 'mdi:image',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Static image',
- style: {},
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'image' },
- });
- });
-
- it('should have styled timeline menu button in timeline view', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
- createAggregateCameraCapabilities({ supportsTimeline: true }),
- );
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- view: createView({ view: 'timeline' }),
- });
- expect(buttons).toContainEqual({
- icon: 'mdi:chart-gantt',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Timeline view',
- style: { color: 'var(--primary-color, white)' },
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'timeline' },
- });
- });
-
- it('should have unstyled timeline menu button in non-timeline view', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
- createAggregateCameraCapabilities({ supportsTimeline: true }),
- );
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- });
- expect(buttons).toContainEqual({
- icon: 'mdi:chart-gantt',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Timeline view',
- style: {},
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'timeline' },
- });
- });
-
- it('should have download menu button', () => {
- vi.stubGlobal('navigator', { userAgent: 'foo' });
-
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue(
- createMediaCapabilities({ canDownload: true }),
- );
- const view = createView({
- queryResults: new MediaQueriesResults({
- results: [new ViewMedia('clip', 'camera-1')],
- selectedIndex: 0,
- }),
- });
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- view: view,
- });
- expect(buttons).toContainEqual({
- icon: 'mdi:download',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Download',
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'download' },
- });
- });
-
- it('should not have download menu button when being casted', () => {
- vi.stubGlobal('navigator', {
- userAgent:
- 'Mozilla/5.0 (Fuchsia) AppleWebKit/537.36 (KHTML, like Gecko) ' +
- 'Chrome/114.0.0.0 Safari/537.36 CrKey/1.56.500000',
- });
-
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getMediaCapabilities).mockReturnValue(
- createMediaCapabilities({ canDownload: true }),
- );
- const view = createView({
- queryResults: new MediaQueriesResults({
- results: [new ViewMedia('clip', 'camera-1')],
- selectedIndex: 0,
- }),
- });
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- view: view,
- });
- expect(buttons).not.toEqual(
- expect.arrayContaining([expect.objectContaining({ title: 'Download' })]),
- );
- });
-
- it('should have camera UI button', () => {
- const buttons = calculateButtons(controller, {
- showCameraUIButton: true,
- });
- expect(buttons).toContainEqual({
- icon: 'mdi:web',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Camera user interface',
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'camera_ui' },
- });
- });
-
- it('should have microphone button', () => {
- const microphoneManager = new MicrophoneManager(createCardAPI());
- const buttons = calculateButtons(controller, {
- microphoneManager: microphoneManager,
- currentMediaLoadedInfo: createMediaLoadedInfo({
- capabilities: {
- supports2WayAudio: true,
- },
- }),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:microphone',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Microphone',
- style: {
- animation: 'pulse 3s infinite',
- color: 'var(--error-color, white)',
- },
- start_tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'microphone_unmute',
- },
- end_tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'microphone_mute',
- },
- });
- });
-
- it('should not have microphone button when media does not support it', () => {
- const microphoneManager = new MicrophoneManager(createCardAPI());
- const buttons = calculateButtons(controller, {
- microphoneManager: microphoneManager,
- currentMediaLoadedInfo: createMediaLoadedInfo({
- capabilities: {
- supports2WayAudio: false,
- },
- }),
- });
-
- expect(buttons).not.toEqual(
- expect.arrayContaining([expect.objectContaining({ title: 'Microphone' })]),
- );
- });
-
- it('should have microphone button when microphone forbidden', () => {
- const microphoneManager = new MicrophoneManager(createCardAPI());
- mock(microphoneManager).isForbidden.mockReturnValue(true);
- const buttons = calculateButtons(controller, {
- microphoneManager: microphoneManager,
- currentMediaLoadedInfo: createMediaLoadedInfo({
- capabilities: {
- supports2WayAudio: true,
- },
- }),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:microphone-message-off',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Microphone',
- style: {},
- });
- });
-
- it('should have microphone button when microphone muted', () => {
- const microphoneManager = new MicrophoneManager(createCardAPI());
- mock(microphoneManager).isMuted.mockReturnValue(true);
- const buttons = calculateButtons(controller, {
- microphoneManager: microphoneManager,
- currentMediaLoadedInfo: createMediaLoadedInfo({
- capabilities: {
- supports2WayAudio: true,
- },
- }),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:microphone-off',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Microphone',
- style: {},
- start_tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'microphone_unmute',
- },
- end_tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'microphone_mute',
- },
- });
- });
-
- it('should have microphone button when microphone muted with toggle type', () => {
- const microphoneManager = new MicrophoneManager(createCardAPI());
- mock(microphoneManager).isMuted.mockReturnValue(true);
- const buttons = calculateButtons(controller, {
- microphoneManager: microphoneManager,
- currentMediaLoadedInfo: createMediaLoadedInfo({
- capabilities: {
- supports2WayAudio: true,
- },
- }),
- config: createConfig({
- menu: { buttons: { microphone: { type: 'toggle' } } },
- }),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:microphone-off',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Microphone',
- style: {},
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'microphone_unmute',
- },
- });
- });
-
- it('should have microphone button when microphone unmuted with toggle type', () => {
- const microphoneManager = new MicrophoneManager(createCardAPI());
- mock(microphoneManager).isMuted.mockReturnValue(false);
- const buttons = calculateButtons(controller, {
- microphoneManager: microphoneManager,
- currentMediaLoadedInfo: createMediaLoadedInfo({
- capabilities: {
- supports2WayAudio: true,
- },
- }),
- config: createConfig({
- menu: { buttons: { microphone: { type: 'toggle' } } },
- }),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:microphone',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Microphone',
- style: {
- animation: 'pulse 3s infinite',
- color: 'var(--error-color, white)',
- },
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'microphone_mute',
- },
- });
- });
-
- it('should have fullscreen button', () => {
- // Need to write a readonly property.
- vi.stubGlobal('navigator', { userAgent: 'foo' });
- const buttons = calculateButtons(controller, { inFullscreenMode: false });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:fullscreen',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Fullscreen',
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' },
- style: {},
- });
- });
-
- it('should have unfullscreen', () => {
- vi.stubGlobal('navigator', { userAgent: 'foo' });
- const buttons = calculateButtons(controller, { inFullscreenMode: true });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:fullscreen-exit',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Fullscreen',
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' },
- style: { color: 'var(--primary-color, white)' },
- });
- });
-
- it('should have expand button', () => {
- const buttons = calculateButtons(controller, { inExpandedMode: false });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:arrow-expand-all',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Expand',
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'expand' },
- style: {},
- });
- });
-
- it('should have unexpand button', () => {
- const buttons = calculateButtons(controller, { inExpandedMode: true });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:arrow-collapse-all',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Expand',
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'expand' },
- style: { color: 'var(--primary-color, white)' },
- });
- });
-
- it('should have media players button', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getStore).mockReturnValue(
- createStore([
- {
- cameraID: 'camera-1',
- config: createCameraConfig({
- camera_entity: 'camera.1',
- }),
- },
- ]),
- );
-
- const mediaPlayerController = mock();
- mediaPlayerController.hasMediaPlayers.mockReturnValue(true);
- mediaPlayerController.getMediaPlayers.mockReturnValue(['media_player.tv']);
-
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- mediaPlayerController: mediaPlayerController,
- hass: createHASS({
- 'media_player.tv': createStateEntity({ entity_id: 'media_player.tv' }),
- }),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:cast',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-submenu',
- title: 'Send to media player',
- items: [
- {
- enabled: true,
- selected: false,
- icon: 'mdi:cast',
- entity: 'media_player.tv',
- state_color: false,
- title: 'media_player.tv',
- disabled: false,
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'media_player',
- media_player: 'media_player.tv',
- media_player_action: 'play',
- },
- hold_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'media_player',
- media_player: 'media_player.tv',
- media_player_action: 'stop',
- },
- },
- ],
- });
- });
-
- it('should disable media players button when entity not found', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getStore).mockReturnValue(
- createStore([
- {
- cameraID: 'camera-1',
- config: createCameraConfig({
- camera_entity: 'camera.1',
- }),
- },
- ]),
- );
- const mediaPlayerController = mock();
- mediaPlayerController.hasMediaPlayers.mockReturnValue(true);
- mediaPlayerController.getMediaPlayers.mockReturnValue(['not_a_real_player']);
-
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- mediaPlayerController: mediaPlayerController,
- hass: createHASS(),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:cast',
- enabled: true,
- priority: 50,
- type: 'custom:frigate-card-menu-submenu',
- title: 'Send to media player',
- items: [
- {
- enabled: true,
- selected: false,
- icon: 'mdi:bookmark',
- entity: 'not_a_real_player',
- state_color: false,
- title: 'not_a_real_player',
- disabled: true,
- },
- ],
- });
- });
-
- it('should have pause button', () => {
- const player = mock();
- const buttons = calculateButtons(controller, {
- currentMediaLoadedInfo: createMediaLoadedInfo({
- capabilities: {
- supportsPause: true,
- },
- player: player,
- }),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:pause',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Play / Pause',
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'pause' },
- });
- });
-
- it('should have play button', () => {
- const player = mock();
- player.isPaused.mockReturnValue(true);
- const buttons = calculateButtons(controller, {
- currentMediaLoadedInfo: createMediaLoadedInfo({
- capabilities: {
- supportsPause: true,
- },
- player: player,
- }),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:play',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Play / Pause',
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'play' },
- });
- });
-
- it('should have mute button', () => {
- const player = mock();
- const buttons = calculateButtons(controller, {
- currentMediaLoadedInfo: createMediaLoadedInfo({
- capabilities: {
- hasAudio: true,
- },
- player: player,
- }),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:volume-high',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Mute / Unmute',
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'mute' },
- });
- });
-
- it('should have unmute button', () => {
- const player = mock();
- player.isMuted.mockReturnValue(true);
- const buttons = calculateButtons(controller, {
- currentMediaLoadedInfo: createMediaLoadedInfo({
- capabilities: {
- hasAudio: true,
- },
- player: player,
- }),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:volume-off',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Mute / Unmute',
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'unmute' },
- });
- });
-
- it('should have screenshot button', () => {
- const buttons = calculateButtons(controller, {
- currentMediaLoadedInfo: createMediaLoadedInfo({
- player: mock(),
- }),
- });
-
- expect(buttons).toContainEqual({
- icon: 'mdi:monitor-screenshot',
- enabled: false,
- priority: 50,
- type: 'custom:frigate-card-menu-icon',
- title: 'Screenshot',
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'screenshot' },
- });
- });
-
- describe('should have grid button when display mode is', () => {
- it.each([['single' as const], ['grid' as const]])(
- '%s',
- (displayMode: ViewDisplayMode) => {
- const view = createView({ view: 'live', displayMode: displayMode });
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getStore).mockReturnValue(
- createStore([{ cameraID: 'camera-1' }, { cameraID: 'camera-2' }]),
- );
- expect(
- calculateButtons(controller, { cameraManager: cameraManager, view: view }),
- ).toContainEqual({
- icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off',
- enabled: true,
- priority: 50,
type: 'custom:frigate-card-menu-icon',
- title:
- displayMode === 'grid'
- ? 'Show single media viewer'
- : 'Show media viewer for each camera in a grid',
- style: displayMode === 'grid' ? { color: 'var(--primary-color, white)' } : {},
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'display_mode_select',
- display_mode: displayMode === 'single' ? 'grid' : 'single',
- },
+ icon: 'mdi:cow',
+ priority: 20,
+ alignment: 'matching',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:goat',
+ alignment: 'matching',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:chicken',
+ priority: 40,
+ alignment: 'matching',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:horse',
+ priority: 40,
+ alignment: 'matching',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:sheep',
+ priority: 30,
+ alignment: 'matching',
+ },
+ ]);
+
+ expect(controller.getButtons('matching')).toEqual([
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:chicken',
+ priority: 40,
+ alignment: 'matching',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:horse',
+ priority: 40,
+ alignment: 'matching',
+ },
+ {
+ alignment: 'matching',
+ icon: 'mdi:sheep',
+ priority: 30,
+ type: 'custom:frigate-card-menu-icon',
+ },
+ {
+ alignment: 'matching',
+ icon: 'mdi:cow',
+ priority: 20,
+ type: 'custom:frigate-card-menu-icon',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:goat',
+ alignment: 'matching',
+ },
+ ]);
+ });
+
+ it('with frigate button first', () => {
+ const controller = new MenuController(createHost());
+ controller.setMenuConfig(
+ createMenuConfig({
+ style: 'hidden',
+ }),
+ );
+ controller.setExpanded(true);
+ controller.setButtons([
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:cow',
+ priority: 100,
+ alignment: 'matching',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'frigate',
+ alignment: 'matching',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:sheep',
+ priority: 100,
+ alignment: 'matching',
+ },
+ ]);
+
+ expect(controller.getButtons('matching')).toEqual([
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'frigate',
+ alignment: 'matching',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:cow',
+ priority: 100,
+ alignment: 'matching',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:sheep',
+ priority: 100,
+ alignment: 'matching',
+ },
+ ]);
+ });
+ });
+
+ describe('should get buttons', () => {
+ it('with matching alignment', () => {
+ const controller = new MenuController(createHost());
+ controller.setButtons([
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:cow',
+ alignment: 'opposing',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:sheep',
+ alignment: 'matching',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:cow',
+ },
+ ]);
+
+ expect(controller.getButtons('matching')).toEqual([
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:sheep',
+ alignment: 'matching',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:cow',
+ },
+ ]);
+ });
+
+ it('with disabled buttons', () => {
+ const controller = new MenuController(createHost());
+ controller.setButtons([
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:cow',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:sheep',
+ enabled: false,
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:goat',
+ enabled: true,
+ },
+ ]);
+
+ expect(controller.getButtons('matching')).toEqual([
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:cow',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:goat',
+ enabled: true,
+ },
+ ]);
+ });
+
+ it('with hidden non-expanded menu', () => {
+ const controller = new MenuController(createHost());
+ controller.setMenuConfig(
+ createMenuConfig({
+ style: 'hidden',
+ }),
+ );
+
+ controller.setButtons([
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'frigate',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:sheep',
+ },
+ ]);
+
+ expect(controller.getButtons('matching')).toEqual([
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'frigate',
+ },
+ ]);
+
+ controller.toggleExpanded();
+
+ expect(controller.getButtons('matching')).toEqual([
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'frigate',
+ },
+ {
+ type: 'custom:frigate-card-menu-icon',
+ icon: 'mdi:sheep',
+ },
+ ]);
+ });
+ });
+
+ describe('should get fresh button state', () => {
+ it('on state icon', () => {
+ const controller = new MenuController(createHost());
+ const stateButton = {
+ type: 'custom:frigate-card-menu-state-icon' as const,
+ icon: 'mdi:sheep',
+ entity: 'switch.foo',
+ state_color: true,
+ };
+
+ const stateParameters: StateParameters = {};
+ vi.mocked(refreshDynamicStateParameters).mockReturnValue(stateParameters);
+
+ expect(controller.getFreshButtonState(createHASS(), stateButton)).toBe(
+ stateParameters,
+ );
+
+ expect(vi.mocked(refreshDynamicStateParameters)).toBeCalled();
+ });
+
+ it('on non state icon', () => {
+ const controller = new MenuController(createHost());
+ const button = {
+ type: 'custom:frigate-card-menu-icon' as const,
+ icon: 'mdi:sheep',
+ };
+
+ expect(controller.getFreshButtonState(createHASS(), button)).toEqual(button);
+ expect(vi.mocked(refreshDynamicStateParameters)).not.toBeCalled();
+ });
+ });
+
+ describe('should get svg path', () => {
+ it('frigate icon', () => {
+ const controller = new MenuController(createHost());
+ const button = {
+ type: 'custom:frigate-card-menu-icon' as const,
+ icon: 'frigate',
+ };
+ expect(controller.getSVGPath(button)).toEqual(FRIGATE_ICON_SVG_PATH);
+ });
+
+ it('non-frigate icon', () => {
+ const controller = new MenuController(createHost());
+ const button = {
+ type: 'custom:frigate-card-menu-icon' as const,
+ icon: 'mdi:cow',
+ };
+ expect(controller.getSVGPath(button)).toBeFalsy();
+ });
+ });
+
+ describe('should handle actions', () => {
+ it('should bail without config', () => {
+ const controller = new MenuController(createHost());
+ controller.actionHandler(createHASS(), createEvent('tap'));
+ expect(vi.mocked(handleActionConfig)).not.toBeCalled();
+ });
+
+ it('should execute simple action in non-hidden menu', () => {
+ const host = createHost();
+ const hass = createHASS();
+ const controller = new MenuController(host);
+
+ controller.actionHandler(hass, createEvent('tap'), tapActionConfig);
+ expect(vi.mocked(handleActionConfig)).toBeCalledWith(
+ host,
+ hass,
+ tapActionConfig,
+ action,
+ );
+
+ expect(controller.isExpanded()).toBeFalsy();
+ });
+
+ it('should execute simple action in with config in event', () => {
+ const host = createHost();
+ const hass = createHASS();
+ const controller = new MenuController(host);
+
+ controller.actionHandler(hass, createEvent('tap', tapActionConfig));
+ expect(vi.mocked(handleActionConfig)).toBeCalledWith(
+ host,
+ hass,
+ tapActionConfig,
+ action,
+ );
+ });
+
+ it('should execute simple array of actions in non-hidden menu', () => {
+ const host = createHost();
+ const hass = createHASS();
+ const controller = new MenuController(host);
+
+ controller.actionHandler(hass, createEvent('tap'), tapActionConfigMulti);
+ expect(vi.mocked(handleActionConfig)).toBeCalledTimes(3);
+ });
+
+ describe('should close menu', () => {
+ it('tap', () => {
+ const host = createHost();
+ const hass = createHASS();
+ const controller = new MenuController(host);
+ controller.setMenuConfig(
+ createMenuConfig({
+ style: 'hidden',
+ }),
+ );
+
+ controller.setExpanded(true);
+ expect(controller.isExpanded()).toBeTruthy();
+
+ controller.actionHandler(hass, createEvent('tap'), tapActionConfig);
+ expect(controller.isExpanded()).toBeFalsy();
+ });
+
+ it('end_tap', () => {
+ const host = createHost();
+ const hass = createHASS();
+ const controller = new MenuController(host);
+ controller.setMenuConfig(
+ createMenuConfig({
+ style: 'hidden',
+ }),
+ );
+
+ controller.setExpanded(true);
+ expect(controller.isExpanded()).toBeTruthy();
+
+ controller.actionHandler(hass, createEvent('end_tap'), {
+ end_tap_action: action,
});
- },
- );
- });
-
- describe('should have show ptz button', () => {
- it('when the selected camera is not PTZ enabled', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
- createCameraCapabilities(),
- );
-
- const buttons = calculateButtons(controller, { cameraManager: cameraManager });
- expect(buttons).not.toContainEqual({
- enabled: false,
- icon: 'mdi:pan',
- priority: 50,
- style: {
- color: 'var(--primary-color, white)',
- },
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'show_ptz',
- show_ptz: false,
- },
- title: 'Show PTZ controls',
- type: 'custom:frigate-card-menu-icon',
+ expect(controller.isExpanded()).toBeFalsy();
});
});
- it('when the selected camera is PTZ enabled', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
- createCameraCapabilities({ ptz: {} }),
- );
+ describe('should not close menu', () => {
+ it('start_tap with later action', () => {
+ const host = createHost();
+ const hass = createHASS();
+ const controller = new MenuController(host);
+ controller.setMenuConfig(
+ createMenuConfig({
+ style: 'hidden',
+ }),
+ );
- const buttons = calculateButtons(controller, { cameraManager: cameraManager });
- expect(buttons).toContainEqual({
- enabled: false,
- icon: 'mdi:pan',
- priority: 50,
- style: {
- color: 'var(--primary-color, white)',
- },
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'show_ptz',
- show_ptz: false,
- },
- title: 'Show PTZ controls',
- type: 'custom:frigate-card-menu-icon',
- });
- });
+ controller.setExpanded(true);
+ expect(controller.isExpanded()).toBeTruthy();
- it('when the context has PTZ visiblity turned off', () => {
- const cameraManager = createCameraManager();
- vi.mocked(cameraManager.getCameraCapabilities).mockReturnValue(
- createCameraCapabilities({ ptz: {} }),
- );
- const view = createView({
- camera: 'camera-1',
- context: { live: { ptzVisible: false } },
+ controller.actionHandler(hass, createEvent('start_tap'), {
+ start_tap_action: action,
+ end_tap_action: action,
+ });
+ expect(controller.isExpanded()).toBeTruthy();
});
- const buttons = calculateButtons(controller, {
- cameraManager: cameraManager,
- view: view,
+ it('with a menu toggle action', () => {
+ const host = createHost();
+ const hass = createHASS();
+ const controller = new MenuController(host);
+ controller.setMenuConfig(
+ createMenuConfig({
+ style: 'hidden',
+ }),
+ );
+
+ controller.setExpanded(false);
+ expect(controller.isExpanded()).toBeFalsy();
+
+ controller.actionHandler(hass, createEvent('tap'), {
+ camera_entity: 'foo',
+ tap_action: menuToggleAction,
+ });
+ expect(controller.isExpanded()).toBeTruthy();
});
- expect(buttons).toContainEqual({
- enabled: false,
- icon: 'mdi:pan',
- priority: 50,
- style: {},
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'show_ptz',
- show_ptz: true,
- },
- title: 'Show PTZ controls',
- type: 'custom:frigate-card-menu-icon',
+
+ it('when no action is actually taken', () => {
+ const host = createHost();
+ const hass = createHASS();
+ const controller = new MenuController(host);
+ controller.setMenuConfig(
+ createMenuConfig({
+ style: 'hidden',
+ }),
+ );
+
+ controller.setExpanded(true);
+ expect(controller.isExpanded()).toBeTruthy();
+
+ controller.actionHandler(hass, createEvent('end_tap'), tapActionConfig);
+ expect(controller.isExpanded()).toBeTruthy();
});
});
});
-
- it('should handle dynamic buttons', () => {
- const button: MenuItem = {
- ...dynamicButton,
- style: {},
- };
- controller.addDynamicMenuButton(button);
- expect(
- calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton))
- .length,
- ).toBe(1);
-
- // Adding it again will have no effect.
- controller.addDynamicMenuButton(button);
- expect(
- calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton))
- .length,
- ).toBe(1);
-
- controller.removeDynamicMenuButton(button);
- expect(calculateButtons(controller)).not.toContainEqual(button);
- });
-
- it('should not set style for dynamic button with stock action', () => {
- const button: MenuItem = {
- ...dynamicButton,
- tap_action: { action: 'navigate', navigation_path: 'foo' },
- };
- controller.addDynamicMenuButton(button);
-
- expect(calculateButtons(controller)).toContainEqual({
- ...button,
- style: {},
- });
- });
-
- it('should not set style for dynamic button with non-Frigate fire-dom-event action', () => {
- const button: MenuItem = {
- ...dynamicButton,
- tap_action: { action: 'fire-dom-event' },
- };
- controller.addDynamicMenuButton(button);
-
- controller.addDynamicMenuButton(dynamicButton);
- expect(calculateButtons(controller)).toContainEqual({
- ...button,
- style: {},
- });
- });
-
- it('should set style for dynamic button with Frigate view action', () => {
- const button: MenuItem = {
- ...dynamicButton,
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'clips' },
- };
-
- const view = createView({ view: 'clips' });
- controller.addDynamicMenuButton(button);
- expect(calculateButtons(controller, { view: view })).toContainEqual({
- ...button,
- style: { color: 'var(--primary-color, white)' },
- });
- });
-
- it('should set style for dynamic button with Frigate default action', () => {
- const button: MenuItem = {
- ...dynamicButton,
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'default' },
- };
-
- controller.addDynamicMenuButton(button);
- expect(calculateButtons(controller)).toContainEqual({
- ...button,
- style: { color: 'var(--primary-color, white)' },
- });
- });
-
- it('should set style for dynamic button with fullscreen action', () => {
- const button: MenuItem = {
- ...dynamicButton,
- tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' },
- };
-
- controller.addDynamicMenuButton(button);
- expect(calculateButtons(controller, { inFullscreenMode: true })).toContainEqual({
- ...button,
- style: { color: 'var(--primary-color, white)' },
- });
- });
-
- it('should set style for dynamic button with camera_select action', () => {
- const button: MenuItem = {
- ...dynamicButton,
- tap_action: {
- action: 'fire-dom-event',
- frigate_card_action: 'camera_select',
- camera: 'foo',
- },
- };
-
- const view = createView({ camera: 'foo' });
- controller.addDynamicMenuButton(button);
- expect(calculateButtons(controller, { view: view })).toContainEqual({
- ...button,
- style: { color: 'var(--primary-color, white)' },
- });
- });
-
- it('should set style for dynamic button with array of actions', () => {
- const button: MenuItem = {
- ...dynamicButton,
- tap_action: [
- { action: 'fire-dom-event' },
- { action: 'fire-dom-event', frigate_card_action: 'clips' },
- ],
- };
-
- const view = createView({ camera: 'clips' });
- controller.addDynamicMenuButton(button);
- expect(calculateButtons(controller, { view: view })).toContainEqual({
- ...button,
- style: {},
- });
- });
});
diff --git a/vite.config.ts b/vite.config.ts
index 5f1aa02a..197f27f1 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -10,10 +10,10 @@ export default defineConfig({
// Thresholds will automatically be updated as coverage improves to avoid
// back-sliding.
thresholdAutoUpdate: true,
- statements: 72.2,
- branches: 61.18,
- functions: 73.47,
- lines: 72.09,
+ statements: 72.66,
+ branches: 61.9,
+ functions: 73.96,
+ lines: 72.56,
},
},
});