diff --git a/src/card.ts b/src/card.ts index ec2459f3..05ce6225 100644 --- a/src/card.ts +++ b/src/card.ts @@ -10,7 +10,7 @@ import { import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; +import { styleMap } from 'lit/directives/style-map.js'; import cloneDeep from 'lodash-es/cloneDeep'; import isEqual from 'lodash-es/isEqual'; import merge from 'lodash-es/merge'; @@ -27,7 +27,7 @@ import { CameraManager } from './camera-manager/manager.js'; import './components/elements.js'; import { FrigateCardElements } from './components/elements.js'; import './components/menu.js'; -import { FRIGATE_BUTTON_MENU_ICON, FrigateCardMenu } from './components/menu.js'; +import { FrigateCardMenu } from './components/menu.js'; import './components/message.js'; import { renderMessage, renderProgressIndicator } from './components/message.js'; import './components/thumbnail-carousel.js'; @@ -50,7 +50,6 @@ import { CardWideConfig, ExtendedHomeAssistant, FRIGATE_CARD_VIEW_DEFAULT, - FRIGATE_CARD_VIEWS_USER_SPECIFIED, FrigateCardConfig, frigateCardConfigSchema, FrigateCardCustomAction, @@ -64,18 +63,14 @@ import { } from './types.js'; import { convertActionToFrigateCardCustomAction, - createFrigateCardCustomAction, frigateCardHandleActionConfig, frigateCardHasAction, getActionConfigGivenAction, } from './utils/action.js'; import { errorToConsole } from './utils/basic.js'; -import { getAllDependentCameras } from './utils/camera.js'; import { log } from './utils/debug.js'; import { downloadMedia } from './utils/download.js'; import { - getEntityIcon, - getEntityTitle, getHassDifferences, isCardInPanel, isHassDifferent, @@ -90,16 +85,16 @@ import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; import { supportsFeature } from './utils/ha/update.js'; import { FrigateCardInitializer } from './utils/initializer.js'; import { isValidMediaLoadedInfo } from './utils/media-info.js'; +import { MenuButtonController } from './utils/menu-controller'; import { MicrophoneController } from './utils/microphone'; import { getActionsFromQueryString } from './utils/querystring.js'; import { createViewWithNextStream, createViewWithoutSubstream, createViewWithSelectedSubstream, - hasSubstream, } from './utils/substream'; -import { View } from './view/view.js'; import { Timer } from './utils/timer'; +import { View } from './view/view.js'; /** A note on media callbacks: * @@ -186,11 +181,12 @@ class FrigateCard extends LitElement { protected _panel = false; @state() - protected _expand?: boolean = false; + protected _expand = false; protected _microphoneController?: MicrophoneController; protected _conditionController?: ConditionController; protected _automationsController?: AutomationsController; + protected _menuButtonController = new MenuButtonController(); protected _refMenu: Ref = createRef(); protected _refMain: Ref = createRef(); @@ -205,9 +201,6 @@ class FrigateCard extends LitElement { protected _currentMediaLoadedInfo: MediaLoadedInfo | null = null; protected _lastValidMediaLoadedInfo: MediaLoadedInfo | null = null; - // Array of dynamic menu buttons to be added to menu. - protected _dynamicMenuButtons: MenuButton[] = []; - // Error/info message to render. protected _message: Message | null = null; @@ -337,442 +330,6 @@ class FrigateCard extends LitElement { } } - /** - * 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(button: MenuButton): 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( - (view) => - view === frigateCardAction.frigate_card_action && - this._view?.is(frigateCardAction.frigate_card_action), - ) || - (frigateCardAction.frigate_card_action === 'default' && - this._view?.is(this._getConfig().view.default)) || - (frigateCardAction.frigate_card_action === 'fullscreen' && - screenfull.isEnabled && - screenfull.isFullscreen) || - (frigateCardAction.frigate_card_action === 'camera_select' && - this._view?.camera === frigateCardAction.camera) - ) { - return this._getEmphasizedStyle(); - } - } - } - return {}; - } - - /** - * Get the menu buttons to display. - * @returns An array of menu buttons. - */ - protected _getMenuButtons(): MenuButton[] { - const buttons: MenuButton[] = []; - - const visibleCameras = this._cameraManager?.getStore().getVisibleCameras(); - const selectedCameraID = this._view?.camera; - const selectedCameraConfig = this._getSelectedCameraConfig(); - const allSelectedCameraIDs = getAllDependentCameras( - this._cameraManager, - selectedCameraID, - ); - const selectedMedia = this._view?.queryResults?.getSelectedResult(); - - const cameraCapabilities = allSelectedCameraIDs - ? this._cameraManager?.getAggregateCameraCapabilities(allSelectedCameraIDs) - : null; - const mediaCapabilities = selectedMedia - ? this._cameraManager?.getMediaCapabilities(selectedMedia) - : null; - - buttons.push({ - // Use a magic icon value that the menu will use to render the custom - // Frigate icon. - icon: FRIGATE_BUTTON_MENU_ICON, - ...this._getConfig().menu.buttons.frigate, - type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.frigate'), - tap_action: FrigateCardMenu.isHidingMenu(this._getConfig().menu) - ? (createFrigateCardCustomAction('menu_toggle') as FrigateCardCustomAction) - : (createFrigateCardCustomAction('default') as FrigateCardCustomAction), - hold_action: createFrigateCardCustomAction( - 'diagnostics', - ) as FrigateCardCustomAction, - }); - - if (visibleCameras) { - const menuItems = Array.from(visibleCameras, ([cameraID, config]) => { - const action = createFrigateCardCustomAction('camera_select', { - camera: cameraID, - }); - const metadata = this._hass - ? this._cameraManager?.getCameraMetadata(this._hass, cameraID) ?? undefined - : undefined; - - return { - enabled: true, - icon: metadata?.icon, - entity: config.camera_entity, - state_color: true, - title: metadata?.title, - selected: this._view?.camera === cameraID, - ...(action && { tap_action: action }), - }; - }); - - buttons.push({ - icon: 'mdi:video-switch', - ...this._getConfig().menu.buttons.cameras, - type: 'custom:frigate-card-menu-submenu', - title: localize('config.menu.buttons.cameras'), - items: menuItems, - }); - } - - if (selectedCameraID && allSelectedCameraIDs && this._view?.is('live')) { - const dependencies = [...allSelectedCameraIDs]; - const override = this._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'), - ...this._getConfig().menu.buttons.substreams, - type: 'custom:frigate-card-menu-icon', - tap_action: createFrigateCardCustomAction( - hasSubstream(this._view) ? 'live_substream_off' : 'live_substream_on', - ) as FrigateCardCustomAction, - }); - } else if (dependencies.length > 2) { - const menuItems = Array.from(dependencies, (cameraID) => { - const action = createFrigateCardCustomAction('live_substream_select', { - camera: cameraID, - }); - const metadata = this._hass - ? this._cameraManager?.getCameraMetadata(this._hass, cameraID) ?? undefined - : undefined; - const cameraConfig = this._cameraManager?.getStore().getCameraConfig(cameraID); - return { - enabled: true, - icon: metadata?.icon, - entity: cameraConfig?.camera_entity, - state_color: true, - title: metadata?.title, - selected: - (this._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() : {}, - ...this._getConfig().menu.buttons.substreams, - type: 'custom:frigate-card-menu-submenu', - items: menuItems, - }); - } - } - - buttons.push({ - icon: 'mdi:cctv', - ...this._getConfig().menu.buttons.live, - type: 'custom:frigate-card-menu-icon', - title: localize('config.view.views.live'), - style: this._view?.is('live') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction('live') as FrigateCardCustomAction, - }); - - if (cameraCapabilities?.supportsClips) { - buttons.push({ - icon: 'mdi:filmstrip', - ...this._getConfig().menu.buttons.clips, - type: 'custom:frigate-card-menu-icon', - title: localize('config.view.views.clips'), - style: this._view?.is('clips') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction('clips') as FrigateCardCustomAction, - hold_action: createFrigateCardCustomAction('clip') as FrigateCardCustomAction, - }); - } - - if (cameraCapabilities?.supportsSnapshots) { - buttons.push({ - icon: 'mdi:camera', - ...this._getConfig().menu.buttons.snapshots, - type: 'custom:frigate-card-menu-icon', - title: localize('config.view.views.snapshots'), - style: this._view?.is('snapshots') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction( - 'snapshots', - ) as FrigateCardCustomAction, - hold_action: createFrigateCardCustomAction( - 'snapshot', - ) as FrigateCardCustomAction, - }); - } - - if (cameraCapabilities?.supportsRecordings) { - buttons.push({ - icon: 'mdi:album', - ...this._getConfig().menu.buttons.recordings, - type: 'custom:frigate-card-menu-icon', - title: localize('config.view.views.recordings'), - style: this._view?.is('recordings') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction( - 'recordings', - ) as FrigateCardCustomAction, - hold_action: createFrigateCardCustomAction( - 'recording', - ) as FrigateCardCustomAction, - }); - } - - buttons.push({ - icon: 'mdi:image', - ...this._getConfig().menu.buttons.image, - type: 'custom:frigate-card-menu-icon', - title: localize('config.view.views.image'), - style: this._view?.is('image') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction('image') as FrigateCardCustomAction, - }); - - // Don't show the timeline button unless there's at least one non-birdseye - // camera with a Frigate camera name. - if (cameraCapabilities?.supportsTimeline) { - buttons.push({ - icon: 'mdi:chart-gantt', - ...this._getConfig().menu.buttons.timeline, - type: 'custom:frigate-card-menu-icon', - title: localize('config.view.views.timeline'), - style: this._view?.is('timeline') ? this._getEmphasizedStyle() : {}, - tap_action: createFrigateCardCustomAction('timeline') as FrigateCardCustomAction, - }); - } - - if (mediaCapabilities?.canDownload && !this._isBeingCasted()) { - buttons.push({ - icon: 'mdi:download', - ...this._getConfig().menu.buttons.download, - type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.download'), - tap_action: createFrigateCardCustomAction('download') as FrigateCardCustomAction, - }); - } - - if (this._getCameraURLFromContext()) { - buttons.push({ - icon: 'mdi:web', - ...this._getConfig().menu.buttons.camera_ui, - type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.camera_ui'), - tap_action: createFrigateCardCustomAction( - 'camera_ui', - ) as FrigateCardCustomAction, - }); - } - - if ( - this._microphoneController && - this._currentMediaLoadedInfo?.capabilities?.supports2WayAudio - ) { - const muted = this._microphoneController.isMuted(); - const buttonType = this._getConfig().menu.buttons.microphone.type; - buttons.push({ - icon: this._microphoneController.isForbidden() - ? 'mdi:microphone-message-off' - : muted - ? 'mdi:microphone-off' - : 'mdi:microphone', - ...this._getConfig().menu.buttons.microphone, - type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.microphone'), - style: muted ? {} : this._getEmphasizedStyle(true), - ...(buttonType === 'momentary' && { - start_tap_action: createFrigateCardCustomAction( - 'microphone_unmute', - ) as FrigateCardCustomAction, - end_tap_action: createFrigateCardCustomAction( - 'microphone_mute', - ) as FrigateCardCustomAction, - }), - ...(buttonType === 'toggle' && { - tap_action: createFrigateCardCustomAction( - this._microphoneController.isMuted() - ? 'microphone_unmute' - : 'microphone_mute', - ) as FrigateCardCustomAction, - }), - }); - } - - if (screenfull.isEnabled && !this._isBeingCasted()) { - buttons.push({ - icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', - ...this._getConfig().menu.buttons.fullscreen, - type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.fullscreen'), - tap_action: createFrigateCardCustomAction( - 'fullscreen', - ) as FrigateCardCustomAction, - style: screenfull.isFullscreen ? this._getEmphasizedStyle() : {}, - }); - } - - buttons.push({ - icon: this._expand ? 'mdi:arrow-collapse-all' : 'mdi:arrow-expand-all', - ...this._getConfig().menu.buttons.expand, - type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.expand'), - tap_action: createFrigateCardCustomAction('expand') as FrigateCardCustomAction, - style: this._expand ? this._getEmphasizedStyle() : {}, - }); - - if ( - this._mediaPlayers?.length && - (this._view?.isViewerView() || - (this._view?.is('live') && selectedCameraConfig?.camera_entity)) - ) { - const mediaPlayerItems = this._mediaPlayers.map((playerEntityID) => { - const title = getEntityTitle(this._hass, playerEntityID) || playerEntityID; - const state = this._hass?.states[playerEntityID]; - const playAction = createFrigateCardCustomAction('media_player', { - media_player: playerEntityID, - media_player_action: 'play', - }); - const stopAction = createFrigateCardCustomAction('media_player', { - media_player: playerEntityID, - media_player_action: 'stop', - }); - - return { - enabled: true, - selected: false, - icon: getEntityIcon(this._hass, playerEntityID) || 'mdi:cast', - entity: playerEntityID, - state_color: false, - title: title, - disabled: !state || state.state === 'unavailable', - ...(playAction && { tap_action: playAction }), - ...(stopAction && { hold_action: stopAction }), - }; - }); - - buttons.push({ - icon: 'mdi:cast', - ...this._getConfig().menu.buttons.media_player, - type: 'custom:frigate-card-menu-submenu', - title: localize('config.menu.buttons.media_player'), - items: mediaPlayerItems, - }); - } - - if (this._currentMediaLoadedInfo && this._currentMediaLoadedInfo.player) { - if (this._currentMediaLoadedInfo.capabilities?.supportsPause) { - const paused = this._currentMediaLoadedInfo.player.isPaused(); - buttons.push({ - icon: paused ? 'mdi:play' : 'mdi:pause', - ...this._getConfig().menu.buttons.play, - type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.play'), - tap_action: createFrigateCardCustomAction( - paused ? 'play' : 'pause', - ) as FrigateCardCustomAction, - }); - } - - if (this._currentMediaLoadedInfo.capabilities?.hasAudio) { - const muted = this._currentMediaLoadedInfo.player.isMuted(); - buttons.push({ - icon: muted ? 'mdi:volume-off' : 'mdi:volume-high', - ...this._getConfig().menu.buttons.mute, - type: 'custom:frigate-card-menu-icon', - title: localize('config.menu.buttons.mute'), - tap_action: createFrigateCardCustomAction( - muted ? 'unmute' : 'mute', - ) as FrigateCardCustomAction, - }); - } - } - - const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({ - style: this._getStyleFromActions(button), - ...button, - })); - - return buttons.concat(styledDynamicButtons); - } - - /** - * Add a dynamic (elements) menu button. - * @param button The button to add. - */ - public _addDynamicMenuButton(button: MenuButton): void { - if (!this._dynamicMenuButtons.includes(button)) { - this._dynamicMenuButtons = [...this._dynamicMenuButtons, button]; - } - if (this._refMenu.value) { - this._refMenu.value.buttons = this._getMenuButtons(); - } - } - - /** - * Remove a dynamic (elements) menu button that was previously added. - * @param target The button to remove. - */ - public _removeDynamicMenuButton(target: MenuButton): void { - this._dynamicMenuButtons = this._dynamicMenuButtons.filter( - (button) => button != target, - ); - if (this._refMenu.value) { - this._refMenu.value.buttons = this._getMenuButtons(); - } - } - /** * Get the camera configuration for the selected camera. * @returns The CameraConfig object or null if not found. @@ -1860,12 +1417,27 @@ class FrigateCard extends LitElement { * @returns A rendered template. */ protected _renderMenu(): TemplateResult | void { + if (!this._hass || !this._cameraManager || !this._view) { + return; + } return html` `; @@ -1996,14 +1568,6 @@ class FrigateCard extends LitElement { super.disconnectedCallback(); } - /** - * Determine if the card is currently being casted. - * @returns - */ - protected _isBeingCasted(): boolean { - return !!navigator.userAgent.match(/CrKey\//); - } - /** * Determine if the aspect ratio should be enforced given the current view and * context. @@ -2219,11 +1783,13 @@ class FrigateCard extends LitElement { .hass=${this._hass} .elements=${this._getConfig().elements} .conditionControllerEpoch=${this._conditionController?.getEpoch()} - @frigate-card:menu-add=${(e) => { - this._addDynamicMenuButton(e.detail); + @frigate-card:menu-add=${(ev: CustomEvent) => { + this._menuButtonController.addDynamicMenuButton(ev.detail); + this.requestUpdate(); }} - @frigate-card:menu-remove=${(e) => { - this._removeDynamicMenuButton(e.detail); + @frigate-card:menu-remove=${(ev: CustomEvent) => { + this._menuButtonController.removeDynamicMenuButton(ev.detail); + this.requestUpdate(); }} @frigate-card:condition:evaluate=${(ev: ConditionEvaluateRequestEvent) => { ev.evaluation = this._conditionController?.evaluateCondition(ev.condition); diff --git a/src/components/menu.ts b/src/components/menu.ts index 1ad8eae2..eca5fbe3 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -31,8 +31,7 @@ import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js'; import { refreshDynamicStateParameters } from '../utils/ha'; import './submenu.js'; import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js'; - -export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; +import { FRIGATE_BUTTON_MENU_ICON } from '../const.js'; /** * A menu for the FrigateCard. diff --git a/src/const.ts b/src/const.ts index 4fa722e6..b8d2b626 100644 --- a/src/const.ts +++ b/src/const.ts @@ -274,3 +274,5 @@ export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; // improved rendering performance. export const MEDIA_CHUNK_SIZE_DEFAULT = 50; export const MEDIA_CHUNK_SIZE_MAX = 1000; + +export const FRIGATE_BUTTON_MENU_ICON = 'frigate'; \ No newline at end of file diff --git a/src/utils/icons/domain-icon.ts b/src/utils/icons/domain-icon.ts index af11268d..e79bbf54 100644 --- a/src/utils/icons/domain-icon.ts +++ b/src/utils/icons/domain-icon.ts @@ -195,6 +195,6 @@ export function domainIcon(domain: string, entity?: HassEntity, state?: string): return FIXED_DOMAIN_ICONS[domain]; } - console.warn(`Unable to find icon for domain ${domain}`); + console.warn(`Unable to find icon for domain: ${domain}`); return DEFAULT_DOMAIN_ICON; } diff --git a/src/utils/menu-controller.ts b/src/utils/menu-controller.ts new file mode 100644 index 00000000..ba7fcaca --- /dev/null +++ b/src/utils/menu-controller.ts @@ -0,0 +1,467 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { StyleInfo } from 'lit/directives/style-map'; +import screenfull from 'screenfull'; +import { CameraManager } from '../camera-manager/manager'; +import { FRIGATE_BUTTON_MENU_ICON } from '../const'; +import { localize } from '../localize/localize.js'; +import { + FRIGATE_CARD_VIEWS_USER_SPECIFIED, + FrigateCardConfig, + FrigateCardCustomAction, + MediaLoadedInfo, + MenuButton, +} from '../types'; +import { View } from '../view/view'; +import { createFrigateCardCustomAction } from './action'; +import { getAllDependentCameras } from './camera'; +import { getEntityIcon, getEntityTitle } from './ha'; +import { MicrophoneController } from './microphone'; +import { hasSubstream } from './substream'; + +export class MenuButtonController { + // Array of dynamic menu buttons to be added to menu. + protected _dynamicMenuButtons: MenuButton[] = []; + + public addDynamicMenuButton(button: MenuButton): void { + if (!this._dynamicMenuButtons.includes(button)) { + this._dynamicMenuButtons.push(button); + } + } + + public removeDynamicMenuButton(button: MenuButton): 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, + expanded: boolean, + options?: { + currentMediaLoadedInfo?: MediaLoadedInfo | null; + mediaPlayers?: string[]; + cameraURL?: string | null; + microphoneController?: MicrophoneController; + }, + ): MenuButton[] { + const visibleCameras = cameraManager.getStore().getVisibleCameras(); + const selectedCameraID = view.camera; + const selectedCameraConfig = cameraManager + .getStore() + .getCameraConfig(selectedCameraID); + const allSelectedCameraIDs = getAllDependentCameras(cameraManager, selectedCameraID); + const selectedMedia = view.queryResults?.getSelectedResult(); + + const cameraCapabilities = + cameraManager.getAggregateCameraCapabilities(allSelectedCameraIDs); + const mediaCapabilities = selectedMedia + ? cameraManager?.getMediaCapabilities(selectedMedia) + : null; + + const buttons: MenuButton[] = []; + 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' + ? (createFrigateCardCustomAction('menu_toggle') as FrigateCardCustomAction) + : (createFrigateCardCustomAction('default') as FrigateCardCustomAction), + hold_action: createFrigateCardCustomAction( + 'diagnostics', + ) as FrigateCardCustomAction, + }); + + if (visibleCameras) { + const menuItems = Array.from(visibleCameras, ([cameraID, config]) => { + const action = createFrigateCardCustomAction('camera_select', { + camera: cameraID, + }); + const metadata = cameraManager.getCameraMetadata(hass, cameraID) ?? undefined; + + 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: createFrigateCardCustomAction( + hasSubstream(view) ? 'live_substream_off' : 'live_substream_on', + ) as FrigateCardCustomAction, + }); + } else if (dependencies.length > 2) { + const menuItems = Array.from(dependencies, (cameraID) => { + const action = createFrigateCardCustomAction('live_substream_select', { + camera: cameraID, + }); + const metadata = cameraManager.getCameraMetadata(hass, 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: createFrigateCardCustomAction('live') as FrigateCardCustomAction, + }); + + if (cameraCapabilities?.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: createFrigateCardCustomAction('clips') as FrigateCardCustomAction, + hold_action: createFrigateCardCustomAction('clip') as FrigateCardCustomAction, + }); + } + + if (cameraCapabilities?.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: createFrigateCardCustomAction( + 'snapshots', + ) as FrigateCardCustomAction, + hold_action: createFrigateCardCustomAction( + 'snapshot', + ) as FrigateCardCustomAction, + }); + } + + if (cameraCapabilities?.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: createFrigateCardCustomAction( + 'recordings', + ) as FrigateCardCustomAction, + hold_action: createFrigateCardCustomAction( + '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: createFrigateCardCustomAction('image') as FrigateCardCustomAction, + }); + + // Don't show the timeline button unless there's at least one non-birdseye + // camera with a Frigate camera name. + if (cameraCapabilities?.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: createFrigateCardCustomAction('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: createFrigateCardCustomAction('download') as FrigateCardCustomAction, + }); + } + + if (options?.cameraURL) { + 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: createFrigateCardCustomAction( + 'camera_ui', + ) as FrigateCardCustomAction, + }); + } + + if ( + options?.microphoneController && + options?.currentMediaLoadedInfo?.capabilities?.supports2WayAudio + ) { + const forbidden = options.microphoneController.isForbidden(); + const muted = options.microphoneController.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: createFrigateCardCustomAction( + 'microphone_unmute', + ) as FrigateCardCustomAction, + end_tap_action: createFrigateCardCustomAction( + 'microphone_mute', + ) as FrigateCardCustomAction, + }), + ...(!forbidden && + buttonType === 'toggle' && { + tap_action: createFrigateCardCustomAction( + options.microphoneController.isMuted() + ? 'microphone_unmute' + : 'microphone_mute', + ) as FrigateCardCustomAction, + }), + }); + } + + if (screenfull.isEnabled && !this._isBeingCasted()) { + buttons.push({ + icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', + ...config.menu.buttons.fullscreen, + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.fullscreen'), + tap_action: createFrigateCardCustomAction( + 'fullscreen', + ) as FrigateCardCustomAction, + style: screenfull.isFullscreen ? this._getEmphasizedStyle() : {}, + }); + } + + buttons.push({ + icon: expanded ? '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: createFrigateCardCustomAction('expand') as FrigateCardCustomAction, + style: expanded ? this._getEmphasizedStyle() : {}, + }); + + if ( + options?.mediaPlayers?.length && + (view?.isViewerView() || (view.is('live') && selectedCameraConfig?.camera_entity)) + ) { + const mediaPlayerItems = options.mediaPlayers.map((playerEntityID) => { + const title = getEntityTitle(hass, playerEntityID) || playerEntityID; + const state = hass.states[playerEntityID]; + const playAction = createFrigateCardCustomAction('media_player', { + media_player: playerEntityID, + media_player_action: 'play', + }); + const stopAction = createFrigateCardCustomAction('media_player', { + media_player: playerEntityID, + media_player_action: '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: createFrigateCardCustomAction( + 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: createFrigateCardCustomAction( + muted ? 'unmute' : 'mute', + ) as FrigateCardCustomAction, + }); + } + } + + const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({ + style: this._getStyleFromActions(config, view, button), + ...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: MenuButton, + ): 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' && + screenfull.isEnabled && + screenfull.isFullscreen) || + (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/tests/test-utils.ts b/tests/test-utils.ts index dcc71536..a8bd84c3 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,19 +1,31 @@ import { HomeAssistant } from 'custom-card-helpers'; import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; +import { vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import { CameraManagerEngineFactory } from '../src/camera-manager/engine-factory'; import { FrigateEvent, FrigateRecording } from '../src/camera-manager/frigate/types'; +import { CameraManager } from '../src/camera-manager/manager'; +import { CameraManagerStore } from '../src/camera-manager/store'; +import { + CameraConfigs, + CameraManagerCameraCapabilities, + CameraManagerMediaCapabilities, +} from '../src/camera-manager/types'; import { CameraConfig, FrigateCardCondition, FrigateCardConfig, + MediaLoadedInfo, + RawFrigateCardConfig, cameraConfigSchema, frigateCardConditionSchema, frigateCardConfigSchema, } from '../src/types'; import { Entity } from '../src/utils/ha/entity-registry/types'; +import { View, ViewParameters } from '../src/view/view'; -export const createCameraConfig = (config: unknown): CameraConfig => { - return cameraConfigSchema.parse(config); +export const createCameraConfig = (config?: unknown): CameraConfig => { + return cameraConfigSchema.parse(config ?? {}); }; export const createCondition = ( @@ -22,8 +34,12 @@ export const createCondition = ( return frigateCardConditionSchema.parse(condition ?? {}); }; -export const createConfig = (config?: Partial): FrigateCardConfig => { - return frigateCardConfigSchema.parse(config); +export const createConfig = (config?: RawFrigateCardConfig): FrigateCardConfig => { + return frigateCardConfigSchema.parse({ + type: 'frigate-hass-card', + cameras: [], + ...config, + }); }; export const createHASS = (states?: HassEntities): HomeAssistant => { @@ -89,3 +105,65 @@ export const createFrigateRecording = (recording?: Partial) => ...recording, }; }; + +export const createView = (options?: Partial): View => { + return new View({ + view: 'live', + camera: 'camera', + ...options, + }); +}; + +export const createCameraManager = (options?: { + store?: CameraManagerStore; + configs?: CameraConfigs; +}): CameraManager => { + const cameraManager = new CameraManager(mock(), {}); + let store: CameraManagerStore | undefined = options?.store; + if (!store) { + store = mock(); + const configs = options?.configs ?? new Map([['camera', createCameraConfig()]]); + vi.mocked(store.getCameras).mockReturnValue(configs); + vi.mocked(store.getVisibleCameras).mockReturnValue(configs); + vi.mocked(store.getCameraConfig).mockImplementation((cameraID): CameraConfig => { + return configs.get(cameraID) ?? createCameraConfig(); + }); + } + vi.mocked(cameraManager.getStore).mockReturnValue(store); + return cameraManager; +}; + +export const createCameraCapabilities = ( + options?: Partial, +): CameraManagerCameraCapabilities => { + return { + canFavoriteEvents: false, + canFavoriteRecordings: false, + canSeek: false, + supportsClips: false, + supportsRecordings: false, + supportsSnapshots: false, + supportsTimeline: false, + ...options, + }; +}; + +export const createMediaCapabilities = ( + options?: Partial, +): CameraManagerMediaCapabilities => { + return { + canFavorite: false, + canDownload: false, + ...options, + }; +}; + +export const createMediaLoadedInfo = ( + options?: Partial, +): MediaLoadedInfo => { + return { + width: 100, + height: 100, + ...options, + }; +}; diff --git a/tests/utils/camera.test.ts b/tests/utils/camera.test.ts index 1b7c9689..5b885861 100644 --- a/tests/utils/camera.test.ts +++ b/tests/utils/camera.test.ts @@ -1,11 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js'; import { CameraManager } from '../../src/camera-manager/manager.js'; -import { CameraManagerStore } from '../../src/camera-manager/store.js'; import { CameraConfigs } from '../../src/camera-manager/types.js'; import { getAllDependentCameras, getCameraID } from '../../src/utils/camera.js'; -import { createCameraConfig } from '../test-utils.js'; +import { createCameraConfig, createCameraManager } from '../test-utils.js'; vi.mock('../../src/camera-manager/manager.js'); @@ -54,11 +52,7 @@ describe('getAllDependentCameras', () => { ['two', createCameraConfig({})], ]); - const cameraManager = new CameraManager(mock(), {}); - const store = mock(); - vi.mocked(cameraManager.getStore).mockReturnValue(store); - store.getCameras.mockReturnValue(cameraConfigs); - + const cameraManager = createCameraManager({ configs: cameraConfigs }); expect(getAllDependentCameras(cameraManager, 'one')).toEqual( new Set(['one', 'two']), ); @@ -76,11 +70,7 @@ describe('getAllDependentCameras', () => { ['two', createCameraConfig({})], ]); - const cameraManager = new CameraManager(mock(), {}); - const store = mock(); - vi.mocked(cameraManager.getStore).mockReturnValue(store); - store.getCameras.mockReturnValue(cameraConfigs); - + const cameraManager = createCameraManager({ configs: cameraConfigs }); expect(getAllDependentCameras(cameraManager, 'one')).toEqual( new Set(['one', 'two']), ); diff --git a/tests/utils/menu-controller.test.ts b/tests/utils/menu-controller.test.ts new file mode 100644 index 00000000..e8e35657 --- /dev/null +++ b/tests/utils/menu-controller.test.ts @@ -0,0 +1,1187 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import screenfull from 'screenfull'; +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 { + FrigateCardConfig, + FrigateCardMediaPlayer, + MediaLoadedInfo, + MenuButton, +} from '../../src/types'; +import { createFrigateCardCustomAction } from '../../src/utils/action'; +import { MenuButtonController } from '../../src/utils/menu-controller'; +import { MicrophoneController } from '../../src/utils/microphone'; +import { ViewMedia } from '../../src/view/media'; +import { MediaQueriesResults } from '../../src/view/media-queries-results'; +import { View } from '../../src/view/view'; +import { + createCameraCapabilities, + createCameraConfig, + createCameraManager, + createConfig, + createHASS, + createMediaCapabilities, + createMediaLoadedInfo, + createStateEntity, + createView, +} from '../test-utils'; + +vi.mock('../../src/camera-manager/manager.js'); +vi.mock('../../src/utils/microphone'); +vi.mock('screenfull'); + +const calculateButtons = ( + controller: MenuButtonController, + options?: { + hass?: HomeAssistant; + config?: FrigateCardConfig; + cameraManager?: CameraManager; + view?: View; + expanded?: boolean; + currentMediaLoadedInfo?: MediaLoadedInfo | null; + mediaPlayers?: string[]; + cameraURL?: string | null; + microphoneController?: MicrophoneController; + }, +): MenuButton[] => { + return controller.calculateButtons( + options?.hass ?? createHASS(), + options?.config ?? createConfig(), + options?.cameraManager ?? + createCameraManager({ + configs: new Map([ + ['camera-1', createCameraConfig()], + ['camera-2', createCameraConfig()], + ]), + }), + options?.view ?? createView({ camera: 'camera-1' }), + options?.expanded ?? false, + { + currentMediaLoadedInfo: options?.currentMediaLoadedInfo, + mediaPlayers: options?.mediaPlayers, + cameraURL: options?.cameraURL, + microphoneController: options?.microphoneController, + }, + ); +}; + +describe('MenuButtonController', () => { + let controller: MenuButtonController; + const dynamicButton: MenuButton = { + 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: createFrigateCardCustomAction('menu_toggle'), + hold_action: createFrigateCardCustomAction('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: createFrigateCardCustomAction('default'), + hold_action: createFrigateCardCustomAction('diagnostics'), + }); + }); + + it('should have cameras menu', () => { + const cameraManager = createCameraManager({ + configs: new Map([ + ['camera-1', createCameraConfig()], + ['camera-2', createCameraConfig()], + ]), + }); + mock(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 have substream button with single dependency', () => { + const cameraManager = createCameraManager({ + configs: new Map([ + ['camera-1', createCameraConfig({ dependencies: { cameras: ['camera-2'] } })], + ['camera-2', createCameraConfig()], + ]), + }); + + 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({ + configs: new Map([ + ['camera-1', createCameraConfig({ dependencies: { cameras: ['camera-2'] } })], + ['camera-2', createCameraConfig()], + ]), + }); + 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({ + configs: new Map([ + [ + 'camera-1', + createCameraConfig({ + camera_entity: 'camera.1', + dependencies: { cameras: ['camera-2', 'camera-3'] }, + }), + ], + [ + 'camera-2', + createCameraConfig({ + camera_entity: 'camera.2', + }), + ], + [ + 'camera-3', + createCameraConfig({ + camera_entity: 'camera.3', + }), + ], + ]), + }); + // Return different metadata depending on the camera to test multiple code + // paths. + mock(cameraManager).getCameraMetadata.mockImplementation( + (_hass: unknown, 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({ + configs: new Map([ + [ + 'camera-1', + createCameraConfig({ + camera_entity: 'camera.1', + dependencies: { cameras: ['camera-2', 'camera-3'] }, + }), + ], + [ + 'camera-2', + createCameraConfig({ + camera_entity: 'camera.2', + }), + ], + [ + 'camera-3', + 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(); + mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( + createCameraCapabilities({ 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(); + mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( + createCameraCapabilities({ 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(); + mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( + createCameraCapabilities({ 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(); + mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( + createCameraCapabilities({ 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(); + mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( + createCameraCapabilities({ 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(); + mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( + createCameraCapabilities({ 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(); + mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( + createCameraCapabilities({ 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(); + mock(cameraManager).getAggregateCameraCapabilities.mockReturnValue( + createCameraCapabilities({ 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(); + const view = createView({ + queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0), + }); + mock(cameraManager).getMediaCapabilities.mockReturnValue( + createMediaCapabilities({ canDownload: true }), + ); + 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(); + const view = createView({ + queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0), + }); + mock(cameraManager).getMediaCapabilities.mockReturnValue( + createMediaCapabilities({ canDownload: true }), + ); + 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, { + cameraURL: 'http://frigate.domain', + }); + 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 microphoneController = new MicrophoneController(); + const buttons = calculateButtons(controller, { + microphoneController: microphoneController, + 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 microphoneController = new MicrophoneController(); + const buttons = calculateButtons(controller, { + microphoneController: microphoneController, + currentMediaLoadedInfo: createMediaLoadedInfo({ + capabilities: { + supports2WayAudio: false, + }, + }), + }); + + expect(buttons).not.toEqual( + expect.arrayContaining([expect.objectContaining({ title: 'Microphone' })]), + ); + }); + + it('should have microphone button when microphone forbidden', () => { + const microphoneController = new MicrophoneController(); + mock(microphoneController).isForbidden.mockReturnValue(true); + const buttons = calculateButtons(controller, { + microphoneController: microphoneController, + 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 microphoneController = new MicrophoneController(); + mock(microphoneController).isMuted.mockReturnValue(true); + const buttons = calculateButtons(controller, { + microphoneController: microphoneController, + 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 microphoneController = new MicrophoneController(); + mock(microphoneController).isMuted.mockReturnValue(true); + const buttons = calculateButtons(controller, { + microphoneController: microphoneController, + 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 microphoneController = new MicrophoneController(); + mock(microphoneController).isMuted.mockReturnValue(false); + const buttons = calculateButtons(controller, { + microphoneController: microphoneController, + 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. + Object.defineProperty(screenfull, 'isEnabled', { value: true }); + vi.stubGlobal('navigator', { userAgent: 'foo' }); + const buttons = calculateButtons(controller); + + 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', () => { + // Need to write a readonly property. + Object.defineProperty(screenfull, 'isEnabled', { value: true }); + Object.defineProperty(screenfull, 'isFullscreen', { value: true }); + vi.stubGlobal('navigator', { userAgent: 'foo' }); + const buttons = calculateButtons(controller); + + 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); + + 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, { expanded: 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({ + configs: new Map([ + [ + 'camera-1', + createCameraConfig({ + camera_entity: 'camera.1', + }), + ], + ]), + }); + + const buttons = calculateButtons(controller, { + cameraManager: cameraManager, + mediaPlayers: ['media_player.tv'], + 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({ + configs: new Map([ + [ + 'camera-1', + createCameraConfig({ + camera_entity: 'camera.1', + }), + ], + ]), + }); + const buttons = calculateButtons(controller, { + cameraManager: cameraManager, + mediaPlayers: ['player'], + 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: 'player', + state_color: false, + title: '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 handle dynamic buttons', () => { + const button: MenuButton = { + ...dynamicButton, + style: {}, + }; + controller.addDynamicMenuButton(button); + expect(calculateButtons(controller)).toContainEqual(button); + + controller.removeDynamicMenuButton(button); + expect(calculateButtons(controller)).not.toContainEqual(button); + }); + + it('should not set style for dynamic button with stock action', () => { + const button: MenuButton = { + ...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: MenuButton = { + ...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: MenuButton = { + ...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: MenuButton = { + ...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', () => { + // Need to write a readonly property. + Object.defineProperty(screenfull, 'isEnabled', { value: true }); + Object.defineProperty(screenfull, 'isFullscreen', { value: true }); + + const button: MenuButton = { + ...dynamicButton, + tap_action: { action: 'fire-dom-event', frigate_card_action: 'fullscreen' }, + }; + + controller.addDynamicMenuButton(button); + expect(calculateButtons(controller)).toContainEqual({ + ...button, + style: { color: 'var(--primary-color, white)' }, + }); + }); + + it('should set style for dynamic button with camera_select action', () => { + const button: MenuButton = { + ...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: MenuButton = { + ...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/utils/substream.test.ts b/tests/utils/substream.test.ts index 9132c764..5a4dd1bd 100644 --- a/tests/utils/substream.test.ts +++ b/tests/utils/substream.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it, vi } from 'vitest'; -import { mock } from 'vitest-mock-extended'; -import { CameraManager } from '../../src/camera-manager/manager'; import { getAllDependentCameras } from '../../src/utils/camera'; import { createViewWithNextStream, @@ -9,7 +7,9 @@ import { hasSubstream, } from '../../src/utils/substream'; import { View } from '../../src/view/view'; +import { createCameraManager } from '../test-utils'; +vi.mock('../../src/camera-manager/manager.js'); vi.mock('../../src/utils/camera'); describe('createViewWithSelectedSubstream', () => { @@ -95,7 +95,7 @@ describe('createViewWithNextStream', () => { camera: 'camera', }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera'])); - const cameraManager = mock(); + const cameraManager = createCameraManager() const newView = createViewWithNextStream(cameraManager, view); expect(newView.camera).toBe(view.camera); expect(newView.view).toBe(view.view); @@ -107,7 +107,7 @@ describe('createViewWithNextStream', () => { camera: 'camera', }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); - const cameraManager = mock(); + const cameraManager = createCameraManager() const newView = createViewWithNextStream(cameraManager, view); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']])); }); @@ -122,7 +122,7 @@ describe('createViewWithNextStream', () => { }, }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); - const cameraManager = mock(); + const cameraManager = createCameraManager() const newView = createViewWithNextStream(cameraManager, view); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']])); }); @@ -137,7 +137,7 @@ describe('createViewWithNextStream', () => { }, }); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); - const cameraManager = mock(); + const cameraManager = createCameraManager() const newView = createViewWithNextStream(cameraManager, view); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']])); }); diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts index bb4cb261..1d97f060 100644 --- a/tests/view/view.test.ts +++ b/tests/view/view.test.ts @@ -1,22 +1,10 @@ -import { describe, it, expect, vi, test } from 'vitest'; +import { describe, expect, it, test, vi } from 'vitest'; import { QueryType } from '../../src/camera-manager/types'; import { ViewMedia } from '../../src/view/media'; import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; -import { - View, - ViewParameters, - dispatchViewContextChangeEvent, -} from '../../src/view/view'; -import { ViewContext } from 'view'; - -const createView = (options?: Partial): View => { - return new View({ - ...options, - view: options?.view ?? 'live', - camera: options?.camera ?? 'camera', - }); -}; +import { View, dispatchViewContextChangeEvent } from '../../src/view/view'; +import { createView } from '../test-utils'; // @vitest-environment jsdom describe('View Basics', () => {