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, }, }, });