diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7f57bbf..d1cff464 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: filename: frigate-hass-card.zip - name: Upload JS files to release - uses: svenstaro/upload-release-action@2.5.0 + uses: svenstaro/upload-release-action@2.6.1 with: repo_token: ${{ secrets.GITHUB_TOKEN }} @@ -41,7 +41,7 @@ jobs: overwrite: true - name: Upload Zip file to release - uses: svenstaro/upload-release-action@2.5.0 + uses: svenstaro/upload-release-action@2.6.1 with: repo_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index b7172b65..264efabd 100644 --- a/README.md +++ b/README.md @@ -433,6 +433,7 @@ menu: | `camera_ui` | :white_check_mark: | The `camera_ui` menu button: brings the user to a context-appropriate page on the UI of their camera engine (e.g. the Frigate camera homepage). Will only appear if the camera engine supports a camera UI (e.g. if `frigate.url` option is set for `frigate` engine users).| | `fullscreen` | :white_check_mark: | The `fullscreen` menu button: expand the card to consume the fullscreen. | | `expand` | :white_check_mark: | The `expand` menu button: expand the card into a popup/dialog. | +| `screenshot` | :white_check_mark: | The `screenshot` menu button: take a screenshot of the loaded media (e.g. a still from a video). | | `timeline` | :white_check_mark: | The `timeline` menu button: show the event timeline. | | `media_player` | :white_check_mark: | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. | | `microphone` | :white_check_mark: | The `microphone` button allows usage of 2-way audio in certain configurations. See [Using 2-way audio](#using-2-way-audio). | @@ -1291,7 +1292,7 @@ Parameters for the `custom:frigate-card-ptz` element: | Parameter | Description | | - | - | | `action` | Must be `custom:frigate-card-action`. | -| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_on`, `live_substream_off`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`, `mute`, `unmute`, `play`, `pause`| +| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_on`, `live_substream_off`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`, `mute`, `unmute`, `play`, `pause`, `screenshot`| @@ -1312,6 +1313,7 @@ Parameters for the `custom:frigate-card-ptz` element: |`microphone_mute`, `microphone_unmute`| Mute or unmute the microphone. See [Using 2-way audio](#using-2-way-audio). | |`mute`, `unmute`| Mute or unmute the loaded media. | |`play`, `pause`| Play or pause the loaded media. | +|`screenshot`| Take a screenshot of the loaded media (e.g. a still from a video). | @@ -1584,6 +1586,10 @@ Pan around a large camera view to only show part of the video feed in the card a Zoom Support +### Taking card actions via the URL + +Taking card actions via the URL + ## Examples ### Illustrative Expanded Configuration Reference @@ -2422,6 +2428,12 @@ elements: frigate_card_action: media_player media_player: media_player.nesthub media_player_action: stop + - type: custom:frigate-card-menu-icon + icon: mdi:alpha-o-circle + title: Screenshot + tap_action: + action: custom:frigate-card-action + frigate_card_action: screenshot ``` @@ -3745,6 +3757,47 @@ https://ha.mydomain.org/lovelace-test/0?frigate-card-action:main:clips ``` +
+ Expand: Choosing the camera from a separate picture elements card + +In this example, the card will select a given camera when the user navigates from a *separate* Picture Elements card: + +Taking card actions via the URL + +Frigate Card configuration: + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.living_room + - camera_entity: camera.landing +``` + +Picture Elements configuration (assumes the dashboard is `/lovelace-frigate/map`): + +```yaml +type: picture-elements +image: https://demo.home-assistant.io/stub_config/floorplan.png +elements: + - type: icon + icon: mdi:cctv + style: + top: 22% + left: 30% + tap_action: + action: navigate + navigation_path: /lovelace-frigate/map?frigate-card-action:camera_select=camera.living_room + - type: icon + icon: mdi:cctv + style: + top: 71% + left: 42% + tap_action: + action: navigate + navigation_path: /lovelace-frigate/map?frigate-card-action:camera_select=camera.landing +``` + +
### Automation actions @@ -3836,13 +3889,20 @@ view: It is possible to pass the Frigate card one or more actions from the URL (e.g. select a particular camera, open the live view in expanded mode, etc). -To send an action to *all* Frigate cards on a dashboard: +The Frigate card will execute these actions in the following circumstances: + +* On initial card load. +* On 'tab' change in a dashboard. +* When a `navigate` [action](https://www.home-assistant.io/dashboards/actions/) is called on the dashboard (e.g. a button click requests navigation). +* When the user uses the `back` / `forward` browser buttons whilst viewing a dashboard. + +To send an action to *all* Frigate cards: ``` [PATH_TO_YOUR_HA_DASHBOARD]?frigate-card-action:[ACTION]=[VALUE] ``` -To send an action to a named Frigate card on the dashboard: +To send an action to a named Frigate card: ``` [PATH_TO_YOUR_HA_DASHBOARD]?frigate-card-action:[CARD_ID]:[ACTION]=[VALUE] @@ -3854,6 +3914,10 @@ To send an action to a named Frigate card on the dashboard: | `CARD_ID` | When specified only cards that have a `card_id` parameter will act. | | `VALUE` | An optional value to use with the `camera_select` and `live_substream_select` actions. | +**Note**: If a dashboard has multiple Frigate cards on it, even if they are on +different 'tabs' within that dashboard, they will all respond to the actions +unless the action is targeted with a `CARD_ID` as shown above. + #### Actions | Action | Supported in query string | Explanation | @@ -3876,6 +3940,7 @@ To send an action to a named Frigate card on the dashboard: | `play`, `pause` | :heavy_multiplication_x: | | | `recording` | :white_check_mark: | | | `recordings` | :white_check_mark: | | +| `screenshot`| :heavy_multiplication_x: | Latest media information is not available on initial render. | | `snapshot` | :white_check_mark: | | | `snapshots` | :white_check_mark: | | @@ -3940,6 +4005,21 @@ live: mode: none ``` +### Title "Popups" are annoying / continually popping up + +Title popups can be disabled for live or media viewer views with this configuration: + +```yaml +live: + controls: + title: + mode: none +media_viewer: + controls: + title: + mode: none +``` + ### Microphone / 2-way audio doesn't work There are many requirements for 2-way audio to work. See [Using 2-way @@ -4126,3 +4206,8 @@ The Home Assistant container will get preconfigured during first initialization, 1. Use the same version number for the release title and tag. 1. Choose 'This is a pre-release' for a beta version. 1. Hit 'Publish release'. + +### Translations +[![translation badge](https://inlang.com/badge?url=github.com/dermotduffy/frigate-hass-card)](https://inlang.com/editor/github.com/dermotduffy/frigate-hass-card?ref=badge) + +To add translations, you can manually edit the JSON translation files in `src/localize/languages`, use the [inlang](https://inlang.com/) online editor, or run `yarn machine-translate` to add missing translations using AI from Inlang. diff --git a/images/navigate-picture-elements.gif b/images/navigate-picture-elements.gif new file mode 100644 index 00000000..61df1332 Binary files /dev/null and b/images/navigate-picture-elements.gif differ diff --git a/inlang.config.js b/inlang.config.js index b7a9c425..7ed0dc54 100644 --- a/inlang.config.js +++ b/inlang.config.js @@ -1,28 +1,17 @@ -// @ts-check - -/** - * @type { import("@inlang/core/config").DefineConfig } - */ export async function defineConfig(env) { - const plugin = await env.$import( - "https://cdn.jsdelivr.net/gh/samuelstroschein/inlang-plugin-json@1/dist/index.js" - ); - - const { standardLintRules } = await env.$import( - "https://cdn.jsdelivr.net/gh/inlang/standard-lint-rules@1/dist/index.js" - ); + const { default: pluginJson } = await env.$import( + "https://cdn.jsdelivr.net/gh/samuelstroschein/inlang-plugin-json@2/dist/index.js" + ); - const pluginConfig = { - pathPattern: "./src/localize/languages/{language}.json", - }; + const { default: standardLintRules } = await env.$import( + "https://cdn.jsdelivr.net/gh/inlang/standard-lint-rules@2/dist/index.js" + ); - return { - referenceLanguage: "en", - languages: await plugin.getLanguages({ ...env, pluginConfig }), - readResources: (args) => plugin.readResources({ ...args, ...env, pluginConfig }), - writeResources: (args) => plugin.writeResources({ ...args, ...env, pluginConfig }), - lint: { - rules: [standardLintRules()], - }, - }; + return { + referenceLanguage: 'en', + plugins: [pluginJson({ + pathPattern: "./src/localize/languages/{language}.json", + variableReferencePattern: ["{", "}"], + }), standardLintRules()] + }; } diff --git a/package.json b/package.json index d21d5c5d..260fa40f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "frigate-hass-card", - "version": "5.1.1", + "version": "5.2.0", "description": "Frigate Lovelace Card for Home Assistant", "keywords": [ "frigate", @@ -18,7 +18,7 @@ "@cycjimmy/jsmpeg-player": "^6.0.4", "@dermotduffy/panzoom": "^4.5.1", "@egjs/hammerjs": "^2.0.17", - "@graphiteds/core": "^1.9.6", + "@graphiteds/core": "^1.9.11", "@lit-labs/scoped-registry-mixin": "^1.0.1", "@lit-labs/task": "^1.1.3", "@types/bluebird": "^3.5.36", diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index d0323dd5..0dd34d12 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -11,6 +11,7 @@ import { DirectiveParameters, } from 'lit/directive.js'; import { stopEventFromActivatingCardWideActions } from './utils/action.js'; +import { Timer } from './utils/timer.js'; interface ActionHandler extends HTMLElement { holdTime: number; @@ -25,14 +26,13 @@ interface FrigateCardActionHandlerOptions extends ActionHandlerOptions { } class ActionHandler extends HTMLElement implements ActionHandler { - public holdTime = 400; + public holdTime = 0.4; - protected timer?: number; + protected holdTimer = new Timer(); + protected doubleClickTimer = new Timer(); protected held = false; - private dblClickTimeout?: number; - public connectedCallback(): void { [ 'touchcancel', @@ -46,10 +46,7 @@ class ActionHandler extends HTMLElement implements ActionHandler { document.addEventListener( ev, () => { - if (this.timer) { - clearTimeout(this.timer); - this.timer = undefined; - } + this.holdTimer.stop(); }, { passive: true }, ); @@ -79,9 +76,9 @@ class ActionHandler extends HTMLElement implements ActionHandler { const start = (): void => { this.held = false; - this.timer = window.setTimeout(() => { + this.holdTimer.start(this.holdTime, () => { this.held = true; - }, this.holdTime); + }); fireEvent(element, 'action', { action: 'start_tap' }); }; @@ -103,8 +100,7 @@ class ActionHandler extends HTMLElement implements ActionHandler { return; } - clearTimeout(this.timer); - this.timer = undefined; + this.holdTimer.stop(); fireEvent(element, 'action', { action: 'end_tap' }); @@ -113,15 +109,13 @@ class ActionHandler extends HTMLElement implements ActionHandler { } else if (options?.hasDoubleClick) { if ( (ev.type === 'click' && (ev as MouseEvent).detail < 2) || - !this.dblClickTimeout + !this.doubleClickTimer.isRunning() ) { - this.dblClickTimeout = window.setTimeout(() => { - this.dblClickTimeout = undefined; - fireEvent(element, 'action', { action: 'tap' }); - }, 250); + this.doubleClickTimer.start(0.25, () => + fireEvent(element, 'action', { action: 'tap' }), + ); } else { - clearTimeout(this.dblClickTimeout); - this.dblClickTimeout = undefined; + this.doubleClickTimer.stop(); fireEvent(element, 'action', { action: 'double_tap' }); } } else { diff --git a/src/cached-value-controller.ts b/src/cached-value-controller.ts index 504b31f2..f0ce59dd 100644 --- a/src/cached-value-controller.ts +++ b/src/cached-value-controller.ts @@ -1,4 +1,5 @@ import { ReactiveController, ReactiveControllerHost } from 'lit'; +import { Timer } from './utils/timer'; export class CachedValueController implements ReactiveController { protected _value?: T; @@ -7,7 +8,7 @@ export class CachedValueController implements ReactiveController { protected _callback: () => T; protected _timerStartCallback?: () => void; protected _timerStopCallback?: () => void; - protected _timerID?: number; + protected _timer = new Timer(); constructor( host: ReactiveControllerHost, @@ -56,11 +57,10 @@ export class CachedValueController implements ReactiveController { * Disable the timer. */ public stopTimer(): void { - if (this._timerID !== undefined) { - window.clearInterval(this._timerID); + if (this._timer.isRunning()) { + this._timer.stop(); this._timerStopCallback?.(); } - this._timerID = undefined; } /** @@ -71,15 +71,15 @@ export class CachedValueController implements ReactiveController { if (this._timerSeconds > 0) { this._timerStartCallback?.(); - this._timerID = window.setInterval(() => { + this._timer.startRepeated(this._timerSeconds, () => { this.updateValue(); this._host.requestUpdate(); - }, this._timerSeconds * 1000); + }); } } public hasTimer(): boolean { - return !!this._timerID; + return this._timer.isRunning(); } /** diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index d0cdaf99..e4c43492 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -325,7 +325,7 @@ export class FrigateCameraManagerEngine `/api/frigate/${cameraConfig.frigate.client_id}` + `/recording/${cameraConfig.frigate.camera_name}` + `/start/${Math.floor(media.getStartTime().getTime() / 1000)}` + - `/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` + + `/end/${Math.floor(media.getEndTime().getTime() / 1000)}` + `?download=true`, sign: true, }; diff --git a/src/camera-manager/frigate/types.ts b/src/camera-manager/frigate/types.ts index d9ef207d..8c923401 100644 --- a/src/camera-manager/frigate/types.ts +++ b/src/camera-manager/frigate/types.ts @@ -11,7 +11,7 @@ const dayStringToDate = (arg: unknown): Date | unknown => { return typeof arg === 'string' ? dayToDate(arg) : arg; }; -const eventSchema = z.object({ +export const eventSchema = z.object({ camera: z.string(), end_time: z.number().nullable(), false_positive: z.boolean().nullable(), diff --git a/src/card.ts b/src/card.ts index e467640c..b0ead46e 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'; @@ -18,7 +18,6 @@ import throttle from 'lodash-es/throttle'; import screenfull from 'screenfull'; import { ViewContext } from 'view'; import 'web-dialog'; -import { z } from 'zod'; import pkg from '../package.json'; import { actionHandler } from './action-handler-directive.js'; import { AutomationsController } from './automations'; @@ -27,7 +26,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'; @@ -49,13 +48,12 @@ import { CameraConfig, CardWideConfig, ExtendedHomeAssistant, - FRIGATE_CARD_VIEW_DEFAULT, - FRIGATE_CARD_VIEWS_USER_SPECIFIED, FrigateCardConfig, frigateCardConfigSchema, FrigateCardCustomAction, FrigateCardError, FrigateCardView, + FRIGATE_CARD_VIEW_DEFAULT, MediaLoadedInfo, MenuButton, Message, @@ -64,18 +62,15 @@ import { } from './types.js'; import { convertActionToFrigateCardCustomAction, - createFrigateCardCustomAction, frigateCardHandleActionConfig, frigateCardHasAction, getActionConfigGivenAction, + isViewAction, } 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 { downloadMedia, downloadURL } from './utils/download.js'; import { - getEntityIcon, - getEntityTitle, getHassDifferences, isCardInPanel, isHassDifferent, @@ -89,15 +84,19 @@ import { Entity } from './utils/ha/entity-registry/types.js'; import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; import { supportsFeature } from './utils/ha/update.js'; import { FrigateCardInitializer } from './utils/initializer.js'; +import { MediaLoadedInfoController } from './utils/media-info-controller'; 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 { generateScreenshotTitle } from './utils/screenshot'; import { createViewWithNextStream, createViewWithoutSubstream, createViewWithSelectedSubstream, - hasSubstream, } from './utils/substream'; +import { Timer } from './utils/timer'; +import { getParseErrorPaths } from './utils/zod.js'; import { View } from './view/view.js'; /** A note on media callbacks: @@ -185,30 +184,22 @@ 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 _mediaLoadedInfoController = new MediaLoadedInfoController(); protected _refMenu: Ref = createRef(); protected _refMain: Ref = createRef(); protected _refElements: Ref = createRef(); protected _refViews: Ref = createRef(); - // user interaction timer ("screensaver" functionality, return to default - // view after user interaction). - protected _interactionTimerID: number | null = null; - - // Automated refreshes of the default view. - protected _updateTimerID: number | null = null; - - // Information about loaded media items. - protected _currentMediaLoadedInfo: MediaLoadedInfo | null = null; - protected _lastValidMediaLoadedInfo: MediaLoadedInfo | null = null; - - // Array of dynamic menu buttons to be added to menu. - protected _dynamicMenuButtons: MenuButton[] = []; + protected _interactionTimer = new Timer(); + protected _updateTimer = new Timer(); + protected _untriggerTimer = new Timer(); // Error/info message to render. protected _message: Message | null = null; @@ -227,7 +218,6 @@ class FrigateCard extends LitElement { protected _boundFullscreenHandler = this._fullscreenHandler.bind(this); protected _triggers: Map = new Map(); - protected _untriggerTimerID: number | null = null; protected _mediaPlayers?: string[]; @@ -340,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. @@ -787,68 +341,6 @@ class FrigateCard extends LitElement { return this._cameraManager.getStore().getCameraConfig(this._view.camera); } - /** - * Get configuration parse errors. - * @param error The ZodError object from parsing. - * @returns An array of string error paths. - */ - protected _getParseErrorPaths(error: z.ZodError): Set | null { - /* Zod errors involving unions are complex, as Zod may not be able to tell - * where the 'real' error is vs simply a union option not matching. This - * function finds all ZodError "issues" that don't have an error with 'type' - * in that object ('type' is the union discriminator for picture elements, - * the major union in the schema). An array of user-readable error - * locations is returned, or an empty list if none is available. None being - * available suggests the configuration has an error, but we can't tell - * exactly why (or rather Zod simply says it doesn't match any of the - * available unions). This usually suggests the user specified an incorrect - * type name entirely. */ - const contenders = new Set(); - if (error && error.issues) { - for (let i = 0; i < error.issues.length; i++) { - const issue = error.issues[i]; - if (issue.code == 'invalid_union') { - const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors; - for (let j = 0; j < unionErrors.length; j++) { - const nestedErrors = this._getParseErrorPaths(unionErrors[j]); - if (nestedErrors && nestedErrors.size) { - nestedErrors.forEach(contenders.add, contenders); - } - } - } else if (issue.code == 'invalid_type') { - if (issue.path[issue.path.length - 1] == 'type') { - return null; - } - contenders.add(this._getParseErrorPathString(issue.path)); - } else if (issue.code != 'custom') { - contenders.add(this._getParseErrorPathString(issue.path)); - } - } - } - return contenders; - } - - /** - * Convert an array of strings and indices into a more user readable string, - * e.g. [a, 1, b, 2] => 'a[1] -> b[2]' - * @param path An array of strings and numbers. - * @returns A single string. - */ - protected _getParseErrorPathString(path: (string | number)[]): string { - let out = ''; - for (let i = 0; i < path.length; i++) { - const item = path[i]; - if (typeof item == 'number') { - out += '[' + item + ']'; - } else if (out) { - out += ' -> ' + item; - } else { - out = item; - } - } - return out; - } - /** * Set the card configuration. * @param inputConfig The card configuration. @@ -861,7 +353,7 @@ class FrigateCard extends LitElement { const parseResult = frigateCardConfigSchema.safeParse(inputConfig); if (!parseResult.success) { const configUpgradeable = isConfigUpgradeable(inputConfig); - const hint = this._getParseErrorPaths(parseResult.error); + const hint = getParseErrorPaths(parseResult.error); let upgradeMessage = ''; if (configUpgradeable && getLovelace().mode !== 'yaml') { upgradeMessage = `${localize('error.upgrade_available')}. `; @@ -921,7 +413,7 @@ class FrigateCard extends LitElement { this._conditionController?.hasHAStateConditions && { state: this._hass.states, }), - media_loaded: !!this._currentMediaLoadedInfo, + media_loaded: this._mediaLoadedInfoController.has(), }); } @@ -949,11 +441,20 @@ class FrigateCard extends LitElement { return this._overriddenConfig || this._config; } - protected _changeView(args?: { view?: View; resetMessage?: boolean }): void { - log(this._cardWideConfig, `Frigate Card view change: `, args?.view ?? '[default]'); + protected _changeView(args?: { + view?: View; + viewName?: FrigateCardView; + cameraID?: string; + resetMessage?: boolean; + }): void { + log( + this._cardWideConfig, + `Frigate Card view change: `, + args?.view ?? args?.viewName ?? '[default]', + ); const changeView = (view: View): void => { if (View.isMajorMediaChange(this._view, view)) { - this._currentMediaLoadedInfo = null; + this._mediaLoadedInfoController.clear(); } if (this._view?.view !== view.view) { this._resetMainScroll(); @@ -973,12 +474,13 @@ class FrigateCard extends LitElement { } if (!args?.view) { - // Load the default view. let cameraID: string | null = null; if (this._cameraManager) { const cameras = this._cameraManager.getStore().getVisibleCameras(); if (cameras) { - if (this._view?.camera && this._getConfig().view.update_cycle_camera) { + if (args?.cameraID && cameras.has(args.cameraID)) { + cameraID = args.cameraID; + } else if (this._view?.camera && this._getConfig().view.update_cycle_camera) { const keys = Array.from(cameras.keys()); const currentIndex = keys.indexOf(this._view.camera); const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1; @@ -993,7 +495,7 @@ class FrigateCard extends LitElement { if (cameraID) { changeView( new View({ - view: this._getConfig().view.default, + view: args?.viewName ?? this._getConfig().view.default, camera: cameraID, }), ); @@ -1014,7 +516,7 @@ class FrigateCard extends LitElement { const needDarkMode = this._getConfig().view.dark_mode === 'on' || (this._getConfig().view.dark_mode === 'auto' && - (!this._interactionTimerID || this._hass?.themes.darkMode)); + (!this._interactionTimer.isRunning() || this._hass?.themes.darkMode)); if (needDarkMode) { this.setAttribute('dark', ''); @@ -1152,7 +654,7 @@ class FrigateCard extends LitElement { * @returns */ protected _isTriggered(): boolean { - return !!this._triggers.size || !!this._untriggerTimerID; + return !!this._triggers.size || this._untriggerTimer.isRunning(); } /** @@ -1161,7 +663,7 @@ class FrigateCard extends LitElement { protected _untrigger(): void { const wasTriggered = this._isTriggered(); this._triggers.clear(); - this._clearUntriggerTimer(); + this._untriggerTimer.stop(); if (wasTriggered) { this.requestUpdate(); @@ -1172,9 +674,7 @@ class FrigateCard extends LitElement { * Start the untrigger timer. */ protected _startUntriggerTimer(): void { - this._clearUntriggerTimer(); - - this._untriggerTimerID = window.setTimeout(() => { + this._untriggerTimer.start(this._getConfig().view.scan.untrigger_seconds, () => { this._untrigger(); if ( this._isAutomatedViewUpdateAllowed() && @@ -1182,17 +682,7 @@ class FrigateCard extends LitElement { ) { this._changeView(); } - }, this._getConfig().view.scan.untrigger_seconds * 1000); - } - - /** - * Clear the user interaction ('screensaver') timer. - */ - protected _clearUntriggerTimer(): void { - if (this._untriggerTimerID) { - window.clearTimeout(this._untriggerTimerID); - this._untriggerTimerID = null; - } + }); } protected _handleThrownError(error: unknown) { @@ -1240,11 +730,26 @@ class FrigateCard extends LitElement { this._handleThrownError(e); } - // If there's no view set yet, set one. This will be the case on initial camera load. + // Set a view on initial load. However, if the query string contains an + // action that needs to render content (e.g. a view action or diagnostics), + // we don't set any view here and allow that content to be triggered by the + // firstUpdated() call. To do otherwise may cause a race condition between + // the default view and the querystring view, see: + // https://github.com/dermotduffy/frigate-hass-card/issues/1200 if (!this._view) { - // Don't reset the message which may be set to an error above. This sets the - // first view using the newly loaded cameras. - this._changeView({ resetMessage: false }); + const querystringActions = getActionsFromQueryString(); + if ( + !querystringActions.find( + (action) => + isViewAction(action) || action.frigate_card_action === 'diagnostics', + ) + ) { + this._changeView({ + // Don't reset the message which may be set to an error above. This sets the + // first view using the newly loaded cameras. + resetMessage: false, + }); + } } } @@ -1545,7 +1050,10 @@ class FrigateCard extends LitElement { } protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void { - if (!this._view || !this._cameraManager) { + // Note: This function needs to process (view-related) commands even when + // _view has not yet been initialized (since it may be used to set a view + // via the querystring). + if (!this._cameraManager) { return; } @@ -1573,10 +1081,8 @@ class FrigateCard extends LitElement { case 'snapshots': case 'timeline': this._changeView({ - view: new View({ - view: action, - camera: this._view.camera, - }), + viewName: action, + cameraID: this._view?.camera, }); break; case 'download': @@ -1618,21 +1124,27 @@ class FrigateCard extends LitElement { } break; case 'live_substream_select': { - const view = createViewWithSelectedSubstream( - this._view, - frigateCardAction.camera, - ); - view && this._changeView({ view: view }); + if (this._view) { + const view = createViewWithSelectedSubstream( + this._view, + frigateCardAction.camera, + ); + view && this._changeView({ view: view }); + } break; } case 'live_substream_off': { - const view = createViewWithoutSubstream(this._view); - view && this._changeView({ view: view }); + if (this._view) { + const view = createViewWithoutSubstream(this._view); + view && this._changeView({ view: view }); + } break; } case 'live_substream_on': { - const view = createViewWithNextStream(this._cameraManager, this._view); - view && this._changeView({ view: view }); + if (this._view) { + const view = createViewWithNextStream(this._cameraManager, this._view); + view && this._changeView({ view: view }); + } break; } case 'media_player': @@ -1670,16 +1182,26 @@ class FrigateCard extends LitElement { } break; case 'mute': - this._currentMediaLoadedInfo?.player?.mute(); + this._mediaLoadedInfoController.get()?.player?.mute(); break; case 'unmute': - this._currentMediaLoadedInfo?.player?.unmute(); + this._mediaLoadedInfoController.get()?.player?.unmute(); break; case 'play': - this._currentMediaLoadedInfo?.player?.play(); + this._mediaLoadedInfoController.get()?.player?.play(); break; case 'pause': - this._currentMediaLoadedInfo?.player?.pause(); + this._mediaLoadedInfoController.get()?.player?.pause(); + break; + case 'screenshot': + this._mediaLoadedInfoController + .get() + ?.player?.getScreenshotURL() + .then((url: string | null) => { + if (url) { + downloadURL(url, generateScreenshotTitle(this._view)); + } + }); break; default: console.warn(`Frigate card received unknown card action: ${action}`); @@ -1819,34 +1341,23 @@ class FrigateCard extends LitElement { this._startInteractionTimer(); } - /** - * Clear the user interaction ('screensaver') timer. - */ - protected _clearInteractionTimer(): void { - if (this._interactionTimerID) { - window.clearTimeout(this._interactionTimerID); - this._interactionTimerID = null; - } - } - /** * Start the user interaction ('screensaver') timer to reset the view to * default `view.timeout_seconds` after user interaction. */ protected _startInteractionTimer(): void { - this._clearInteractionTimer(); + this._interactionTimer.stop(); // Interactions reset the trigger state. this._untrigger(); if (this._getConfig().view.timeout_seconds) { - this._interactionTimerID = window.setTimeout(() => { - this._clearInteractionTimer(); + this._interactionTimer.start(this._getConfig().view.timeout_seconds, () => { if (this._isAutomatedViewUpdateAllowed()) { this._changeView(); this._setLightOrDarkMode(); } - }, this._getConfig().view.timeout_seconds * 1000); + }); } this._setLightOrDarkMode(); } @@ -1856,12 +1367,9 @@ class FrigateCard extends LitElement { * `view.update_seconds`. */ protected _startUpdateTimer(): void { - if (this._updateTimerID) { - window.clearTimeout(this._updateTimerID); - this._updateTimerID = null; - } + this._updateTimer.stop(); if (this._getConfig().view.update_seconds) { - this._updateTimerID = window.setTimeout(() => { + this._updateTimer.start(this._getConfig().view.update_seconds, () => { if (this._isAutomatedViewUpdateAllowed()) { this._changeView(); } else { @@ -1869,7 +1377,7 @@ class FrigateCard extends LitElement { // interval. this._startUpdateTimer(); } - }, this._getConfig().view.update_seconds * 1000); + }); } } @@ -1880,7 +1388,7 @@ class FrigateCard extends LitElement { protected _isAutomatedViewUpdateAllowed(ignoreTriggers?: boolean): boolean { return ( (ignoreTriggers || !this._isTriggered()) && - (this._getConfig().view.update_force || !this._interactionTimerID) + (this._getConfig().view.update_force || !this._interactionTimer.isRunning()) ); } @@ -1889,12 +1397,27 @@ class FrigateCard extends LitElement { * @returns A rendered template. */ protected _renderMenu(): TemplateResult | void { + if (!this._hass || !this._cameraManager || !this._view) { + return; + } return html` `; @@ -1956,12 +1479,12 @@ class FrigateCard extends LitElement { log(this._cardWideConfig, `Frigate Card media load: `, mediaLoadedInfo); - this._lastValidMediaLoadedInfo = this._currentMediaLoadedInfo = mediaLoadedInfo; + this._mediaLoadedInfoController.set(mediaLoadedInfo); this._setPropertiesForExpandedMode(); this._conditionController?.setState({ - media_loaded: !!this._currentMediaLoadedInfo, + media_loaded: this._mediaLoadedInfoController.has(), }); this.requestUpdate(); @@ -1971,10 +1494,11 @@ class FrigateCard extends LitElement { // When a new media loads, set the aspect ratio for when the card is // expanded/popped-up. This is based exclusively on last media content, // as dimension configuration does not apply in fullscreen or expanded mode. + const lastKnown = this._mediaLoadedInfoController.getLastKnown(); this.style.setProperty( '--frigate-card-expand-aspect-ratio', - this._view?.isAnyMediaView() && this._lastValidMediaLoadedInfo - ? `${this._lastValidMediaLoadedInfo.width} / ${this._lastValidMediaLoadedInfo.height}` + this._view?.isAnyMediaView() && lastKnown + ? `${lastKnown.width} / ${lastKnown.height}` : 'unset', ); // Non-media mays have no intrinsic dimensions and so we need to explicit @@ -1993,10 +1517,22 @@ class FrigateCard extends LitElement { * Unload a media item. */ protected _mediaUnloadedHandler(): void { - this._currentMediaLoadedInfo = null; + this._mediaLoadedInfoController.clear(); this._conditionController?.setState({ media_loaded: false }); } + protected _locationChangeHandler = (): void => { + // Only execute actions when the card has rendered at least once. + if (this.hasUpdated) { + getActionsFromQueryString().forEach((action) => this._cardActionHandler(action)); + } + }; + + protected firstUpdated(): void { + // Execute query string actions after first render is complete. + this._locationChangeHandler(); + } + /** * Component connected callback. */ @@ -2008,6 +1544,18 @@ class FrigateCard extends LitElement { this.addEventListener('mousemove', this._boundMouseHandler); this.addEventListener('ll-custom', this._boundCardActionEventHandler); this._panel = isCardInPanel(this); + + // Listen for HA `navigate` actions. + // See: https://github.com/home-assistant/frontend/blob/273992c8e9c3062c6e49481b6d7d688a07067232/src/common/navigate.ts#L43 + window.addEventListener('location-changed', this._locationChangeHandler); + + // Listen for history state changes (i.e. user using the browser + // back/forward controls). + window.addEventListener('popstate', this._locationChangeHandler); + + // Manually call the location change handler as the card will be + // disconnected/reconnected when dashboard 'tab' changes happen within HA. + this._locationChangeHandler(); } /** @@ -2022,15 +1570,11 @@ class FrigateCard extends LitElement { } this.removeEventListener('mousemove', this._boundMouseHandler); this.removeEventListener('ll-custom', this._boundCardActionEventHandler); - super.disconnectedCallback(); - } - /** - * Determine if the card is currently being casted. - * @returns - */ - protected _isBeingCasted(): boolean { - return !!navigator.userAgent.match(/CrKey\//); + window.removeEventListener('location-changed', this._locationChangeHandler); + window.removeEventListener('popstate', this._locationChangeHandler); + + super.disconnectedCallback(); } /** @@ -2068,8 +1612,9 @@ class FrigateCard extends LitElement { const aspectRatioMode = this._getConfig().dimensions.aspect_ratio_mode; - if (this._lastValidMediaLoadedInfo && aspectRatioMode === 'dynamic') { - return `${this._lastValidMediaLoadedInfo.width} / ${this._lastValidMediaLoadedInfo.height}`; + const lastKnown = this._mediaLoadedInfoController.getLastKnown(); + if (lastKnown && aspectRatioMode === 'dynamic') { + return `${lastKnown.width} / ${lastKnown.height}`; } const defaultAspectRatio = this._getConfig().dimensions.aspect_ratio; @@ -2248,11 +1793,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); @@ -2263,11 +1810,6 @@ class FrigateCard extends LitElement { `); } - protected firstUpdated(): void { - // Execute query string actions after first render is complete. - getActionsFromQueryString().forEach((action) => this._cardActionHandler(action)); - } - /** * Return compiled CSS styles (thus safe to use with unsafeCSS). */ @@ -2280,8 +1822,9 @@ class FrigateCard extends LitElement { * @returns The Lovelace card size in units of 50px. */ public getCardSize(): number { - if (this._lastValidMediaLoadedInfo) { - return this._lastValidMediaLoadedInfo.height / 50; + const lastKnown = this._mediaLoadedInfoController.getLastKnown(); + if (lastKnown) { + return lastKnown.height / 50; } return 6; } diff --git a/src/components/image.ts b/src/components/image.ts index 880a55f5..215dadad 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -95,6 +95,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay return !this._cachedValueController?.hasTimer() ?? true; } + public async getScreenshotURL(): Promise { + return this._cachedValueController?.value ?? null; + } + /** * Get the camera entity for the current camera configuration. * @returns The entity or undefined if no camera entity is available. diff --git a/src/components/live/go2rtc/video-rtc.js b/src/components/live/go2rtc/video-rtc.js index 3129b396..434171b2 100644 --- a/src/components/live/go2rtc/video-rtc.js +++ b/src/components/live/go2rtc/video-rtc.js @@ -2,6 +2,7 @@ import { mayHaveAudio } from '../../../utils/audio'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + setControlsOnVideo, } from '../../../utils/media'; import { dispatchMediaLoadedEvent, @@ -271,7 +272,7 @@ export class VideoRTC extends HTMLElement { */ oninit() { this.video = document.createElement('video'); - this.video.controls = this.controls; + setControlsOnVideo(this.video, this.controls); this.video.playsInline = true; this.video.preload = 'auto'; @@ -633,7 +634,7 @@ export class VideoRTC extends HTMLElement { let receivedFirstFrame = false; this.ondata = (data) => { - this.video.controls = false; + setControlsOnVideo(this.video, false); this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data); if (!receivedFirstFrame) { @@ -672,7 +673,7 @@ export class VideoRTC extends HTMLElement { context.drawImage(video2, 0, 0, canvas.width, canvas.height); - this.video.controls = false; + setControlsOnVideo(this.video, false); this.video.poster = canvas.toDataURL('image/jpeg'); }); diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index 8b7347c2..ce425716 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -17,6 +17,8 @@ import { MicrophoneConfig, } from '../../types.js'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; +import { setControlsOnVideo } from '../../utils/media.js'; +import { screenshotMedia } from '../../utils/screenshot.js'; import '../image.js'; import { dispatchErrorMessageEvent } from '../message'; import { VideoRTC } from './go2rtc/video-rtc'; @@ -84,7 +86,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla public async setControls(controls?: boolean): Promise { if (this._player?.video) { - this._player.video.controls = controls ?? this.controls; + setControlsOnVideo(this._player.video, controls ?? this.controls); } } @@ -92,6 +94,10 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla return this._player?.video.paused ?? true; } + public async getScreenshotURL(): Promise { + return this._player ? screenshotMedia(this._player.video) : null; + } + disconnectedCallback(): void { this._player = undefined; } diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts index 6ad1017a..8fd36b2e 100644 --- a/src/components/live/live-ha.ts +++ b/src/components/live/live-ha.ts @@ -54,6 +54,10 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla return this._playerRef.value?.isPaused() ?? true; } + public async getScreenshotURL(): Promise { + return await this._playerRef.value?.getScreenshotURL() ?? null; + } + protected render(): TemplateResult | void { if (!this.hass) { return; diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts index 8f872d49..58e4a03d 100644 --- a/src/components/live/live-image.ts +++ b/src/components/live/live-image.ts @@ -49,6 +49,10 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia return this._refImage.value?.isPaused() ?? true; } + public async getScreenshotURL(): Promise { + return await this._refImage.value?.getScreenshotURL() ?? null; + } + protected render(): TemplateResult | void { if (!this.hass || !this.cameraConfig) { return; diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts index c0f9b830..16e260dc 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/live-jsmpeg.ts @@ -19,6 +19,7 @@ import { dispatchMediaPlayEvent, } from '../../utils/media-info.js'; import { dispatchErrorMessageEvent } from '../message.js'; +import { Timer } from '../../utils/timer.js'; // Number of seconds a signed URL is valid for. const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; @@ -41,7 +42,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi protected _jsmpegCanvasElement?: HTMLCanvasElement; protected _jsmpegVideoPlayer?: JSMpeg.VideoElement; - protected _refreshPlayerTimerID?: number; + protected _refreshPlayerTimer = new Timer(); public async play(): Promise { return this._jsmpegVideoPlayer?.play(); @@ -83,6 +84,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi return this._jsmpegVideoPlayer?.player?.paused ?? true; } + public async getScreenshotURL(): Promise { + return this._jsmpegCanvasElement?.toDataURL('image/jpeg') ?? null; + } + /** * Create a JSMPEG player. * @param url The URL for the player to connect to. @@ -106,6 +111,9 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi audio: false, videoBufferSize: 1024 * 1024 * 4, + // Necessary for screenshots. + preserveDrawingBuffer: true, + // Override with user-specified options. ...this.cameraConfig?.jsmpeg?.options, @@ -145,10 +153,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi * Reset / destroy the player. */ protected _resetPlayer(): void { - if (this._refreshPlayerTimerID) { - window.clearTimeout(this._refreshPlayerTimerID); - this._refreshPlayerTimerID = undefined; - } + this._refreshPlayerTimer.stop(); if (this._jsmpegVideoPlayer) { try { this._jsmpegVideoPlayer.destroy(); @@ -213,9 +218,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi } await this._createJSMPEGPlayer(address); - this._refreshPlayerTimerID = window.setTimeout(() => { - this.requestUpdate(); - }, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); + this._refreshPlayerTimer.start( + JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS, + () => this.requestUpdate(), + ); } /** diff --git a/src/components/live/live-webrtc-card.ts b/src/components/live/live-webrtc-card.ts index 47d46c36..9ed4d42b 100644 --- a/src/components/live/live-webrtc-card.ts +++ b/src/components/live/live-webrtc-card.ts @@ -2,6 +2,7 @@ import { Task } from '@lit-labs/task'; import { HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import { CameraEndpoints } from '../../camera-manager/types.js'; import { localize } from '../../localize/localize.js'; import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss'; import { @@ -10,20 +11,21 @@ import { FrigateCardError, FrigateCardMediaPlayer, } from '../../types.js'; +import { mayHaveAudio } from '../../utils/audio.js'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, dispatchMediaVolumeChangeEvent, } from '../../utils/media-info.js'; -import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js'; -import { renderTask } from '../../utils/task.js'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + setControlsOnVideo, } from '../../utils/media.js'; -import { CameraEndpoints } from '../../camera-manager/types.js'; -import { mayHaveAudio } from '../../utils/audio.js'; +import { screenshotMedia } from '../../utils/screenshot.js'; +import { renderTask } from '../../utils/task.js'; +import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js'; // Create a wrapper for AlexxIT's WebRTC card // - https://github.com/AlexxIT/WebRTC @@ -85,7 +87,7 @@ export class FrigateCardLiveWebRTCCard public async setControls(controls?: boolean): Promise { const player = this._getPlayer(); if (player) { - player.controls = controls ?? this.controls; + setControlsOnVideo(player, controls ?? this.controls); } } @@ -93,6 +95,11 @@ export class FrigateCardLiveWebRTCCard return this._getPlayer()?.paused ?? true; } + public async getScreenshotURL(): Promise { + const video = this._getPlayer(); + return video ? screenshotMedia(video) : null; + } + connectedCallback(): void { super.connectedCallback(); @@ -191,6 +198,7 @@ export class FrigateCardLiveWebRTCCard this.updateComplete.then(() => { const video = this._getPlayer(); if (video) { + setControlsOnVideo(video, this.controls); video.onloadeddata = () => { if (this.controls) { hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); @@ -206,7 +214,6 @@ export class FrigateCardLiveWebRTCCard video.onplay = () => dispatchMediaPlayEvent(this); video.onpause = () => dispatchMediaPauseEvent(this); video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this); - video.controls = this.controls; } }); } diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 46f05e6a..23e34688 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -756,19 +756,19 @@ export class FrigateCardLiveProvider public async pause(): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; - this._refProvider.value?.pause(); + await this._refProvider.value?.pause(); } public async mute(): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; - this._refProvider.value?.mute(); + await this._refProvider.value?.mute(); } public async unmute(): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; - this._refProvider.value?.unmute(); + await this._refProvider.value?.unmute(); } public isMuted(): boolean { @@ -778,19 +778,25 @@ export class FrigateCardLiveProvider public async seek(seconds: number): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; - this._refProvider.value?.seek(seconds); + await this._refProvider.value?.seek(seconds); } public async setControls(controls?: boolean): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; - this._refProvider.value?.setControls(controls); + await this._refProvider.value?.setControls(controls); } public isPaused(): boolean { return this._refProvider.value?.isPaused() ?? true; } + public async getScreenshotURL(): Promise { + await this.updateComplete; + await this._refProvider.value?.updateComplete; + return await this._refProvider.value?.getScreenshotURL() ?? null; + } + /** * Get the fully resolved live provider. * @returns A live provider (that is not 'auto'). diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index f22806a1..5e9df428 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -22,6 +22,7 @@ import './carousel.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { FrigateCardTitleControl } from './title-control.js'; import debounce from 'lodash-es/debounce'; +import { Timer } from '../utils/timer'; interface CarouselMediaLoadedInfo { slide: number; @@ -126,7 +127,7 @@ export class FrigateCardMediaCarousel extends LitElement { protected _nextControlRef: Ref = createRef(); protected _previousControlRef: Ref = createRef(); protected _titleControlRef: Ref = createRef(); - protected _titleTimerID: number | null = null; + protected _titleTimer = new Timer(); protected _boundAutoPlayHandler = this.autoPlay.bind(this); protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); @@ -231,13 +232,10 @@ export class FrigateCardMediaCarousel extends LitElement { */ protected _titleHandler(): void { const show = () => { - this._titleTimerID = null; + this._titleTimer.stop(); this._titleControlRef.value?.show(); }; - if (this._titleTimerID) { - window.clearTimeout(this._titleTimerID); - } if (this._titleControlRef.value?.isVisible()) { // If it's already visible, update it immediately (but also update it // after the timer expires to ensure it re-positions if necessary, see @@ -248,7 +246,7 @@ export class FrigateCardMediaCarousel extends LitElement { // Allow a brief pause after the media loads, but before the title is // displayed. This allows for a pleasant appearance/disappear of the title, // and allows for the browser to finish rendering the carousel. - this._titleTimerID = window.setTimeout(show, 0.5 * 1000); + this._titleTimer.start(0.5, show); } /** 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/components/viewer.ts b/src/components/viewer.ts index 699ced25..37980d93 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -47,7 +47,9 @@ import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, playMediaMutingIfNecessary, + setControlsOnVideo, } from '../utils/media.js'; +import { screenshotMedia } from '../utils/screenshot.js'; import { ViewMediaClassifier } from '../view/media-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { MediaQueriesResults } from '../view/media-queries-results.js'; @@ -562,6 +564,7 @@ export class FrigateCardViewerProvider protected _refFrigateCardMediaPlayer: Ref = createRef(); protected _refVideoProvider: Ref = createRef(); + protected _refImageProvider: Ref = createRef(); public async play(): Promise { await playMediaMutingIfNecessary( @@ -612,7 +615,10 @@ export class FrigateCardViewerProvider if (this._refFrigateCardMediaPlayer.value) { return this._refFrigateCardMediaPlayer.value.setControls(controls); } else if (this._refVideoProvider.value) { - this._refVideoProvider.value.controls = controls ?? this.viewerConfig?.controls.builtin ?? true; + setControlsOnVideo( + this._refVideoProvider.value, + controls ?? this.viewerConfig?.controls.builtin ?? true, + ); } } @@ -625,6 +631,17 @@ export class FrigateCardViewerProvider return true; } + public async getScreenshotURL(): Promise { + if (this._refFrigateCardMediaPlayer.value) { + return await this._refFrigateCardMediaPlayer.value.getScreenshotURL(); + } else if (this._refVideoProvider.value) { + return screenshotMedia(this._refVideoProvider.value); + } else if (this._refImageProvider.value) { + return this._refImageProvider.value.src; + } + return null; + } + /** * Dispatch a clip view that matches the current (snapshot) query. */ @@ -731,6 +748,8 @@ export class FrigateCardViewerProvider }); } + // Note: crossorigin="anonymous" is required on ` : html` ` : ''} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 59b48fdb..a5b5b780 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -282,6 +282,7 @@ "play": "Play / Pause", "priority": "Priority", "recordings": "Recordings", + "screenshot": "Screenshot", "snapshots": "Snapshots", "substreams": "Substream(s)", "timeline": "Timeline", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index bc9653f7..2643abd5 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -279,6 +279,7 @@ "mute": "", "play": "", "priority": "Priorità", + "screenshot": "", "snapshots": "Istantanee", "substreams": "Flusso/i secondario/i", "timeline": "Timeline", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index eb978cce..c873ceb9 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -281,6 +281,7 @@ "play": "", "priority": "Prioridade", "recordings": "Gravações", + "screenshot": "", "snapshots": "Instantâneos", "substreams": "Substream(s)", "timeline": "Linha do tempo", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index ccb210ea..6f4cd0ef 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -272,6 +272,7 @@ "mute": "", "play": "", "priority": "Prioridade", + "screenshot": "", "snapshots": "Instantâneos", "substreams": "substreams", "timeline": "Linha do tempo", diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 97e2ca15..e1e742a1 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -81,6 +81,10 @@ customElements.whenDefined('ha-camera-stream').then(() => { return this._player?.isPaused() ?? true; } + public async getScreenshotURL(): Promise { + return this._player ? await this._player.getScreenshotURL() : null; + } + /** * Master render method. * @returns A rendered template. diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 776961b3..56f12e7f 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -9,9 +9,10 @@ // available as compilation time. // ==================================================================== -import { CSSResultGroup, TemplateResult, css, html, unsafeCSS } from 'lit'; +import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; +import { screenshotMedia } from '../utils/screenshot.js'; import { dispatchErrorMessageEvent } from '../components/message.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import { FrigateCardMediaPlayer } from '../types.js'; @@ -20,11 +21,10 @@ import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, - dispatchMediaVolumeChangeEvent, + dispatchMediaVolumeChangeEvent } from '../utils/media-info.js'; import { - MEDIA_LOAD_CONTROLS_HIDE_SECONDS, - hideMediaControlsTemporarily, + hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, setControlsOnVideo } from '../utils/media.js'; customElements.whenDefined('ha-hls-player').then(() => { @@ -75,7 +75,7 @@ customElements.whenDefined('ha-hls-player').then(() => { public async setControls(controls?: boolean): Promise { if (this._video) { - this._video.controls = controls ?? this.controls; + setControlsOnVideo(this._video, controls ?? this.controls); } } @@ -83,6 +83,10 @@ customElements.whenDefined('ha-hls-player').then(() => { return this._video?.paused ?? true; } + public async getScreenshotURL(): Promise { + return this._video ? screenshotMedia(this._video) : null; + } + // ===================================================================================== // Minor modifications from: // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index d3d40d5e..c9ceb764 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -12,6 +12,7 @@ import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; +import { screenshotMedia } from '../utils/screenshot.js'; import { dispatchErrorMessageEvent } from '../components/message.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import { FrigateCardMediaPlayer } from '../types.js'; @@ -20,11 +21,12 @@ import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, - dispatchMediaVolumeChangeEvent, + dispatchMediaVolumeChangeEvent } from '../utils/media-info.js'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + setControlsOnVideo } from '../utils/media.js'; customElements.whenDefined('ha-web-rtc-player').then(() => { @@ -74,7 +76,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { public async setControls(controls?: boolean): Promise { if (this._video) { - this._video.controls = controls ?? this.controls; + setControlsOnVideo(this._video, controls ?? this.controls); } } @@ -82,6 +84,10 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { return this._video?.paused ?? true; } + public async getScreenshotURL(): Promise { + return this._video ? screenshotMedia(this._video) : null; + } + // ===================================================================================== // Minor modifications from: // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts diff --git a/src/types.ts b/src/types.ts index dbb5628d..7f3e0f7d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -209,15 +209,11 @@ const frigateCardCustomActionsBaseSchema = customActionSchema.extend({ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'camera_ui', - 'clip', - 'clips', 'default', 'diagnostics', 'expand', 'download', 'fullscreen', - 'image', - 'live', 'menu_toggle', 'mute', 'live_substream_on', @@ -226,14 +222,11 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'microphone_unmute', 'play', 'pause', - 'recording', - 'recordings', - 'snapshot', - 'snapshots', - 'timeline', + 'screenshot', 'unmute', ] as const; const FRIGATE_CARD_ACTIONS = [ + ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, ...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select', 'live_substream_select', @@ -241,6 +234,11 @@ const FRIGATE_CARD_ACTIONS = [ ] as const; export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number]; +const frigateCardViewActionSchema = frigateCardCustomActionsBaseSchema.extend({ + frigate_card_action: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED), +}); +export type FrigateCardViewAction = z.infer; + const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS), }); @@ -260,6 +258,7 @@ const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.ex }); export const frigateCardCustomActionSchema = z.union([ + frigateCardViewActionSchema, frigateCardGeneralActionSchema, frigateCardCameraSelectActionSchema, frigateCardLiveDependencySelectActionSchema, @@ -1078,6 +1077,7 @@ const menuConfigDefault = { mute: hiddenButtonDefault, play: hiddenButtonDefault, recordings: hiddenButtonDefault, + screenshot: hiddenButtonDefault, }, button_size: 40, }; @@ -1123,6 +1123,7 @@ const menuConfigSchema = z recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute), play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), + screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot), }) .default(menuConfigDefault.buttons), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), @@ -1530,6 +1531,7 @@ export interface FrigateCardMediaPlayer { unmute(): Promise; isMuted(): boolean; seek(seconds: number): Promise; + getScreenshotURL(): Promise; // If no value for controls if specified, the player should use the default. setControls(controls?: boolean): Promise; isPaused(): boolean; diff --git a/src/utils/action.ts b/src/utils/action.ts index edc700d1..e065d7be 100644 --- a/src/utils/action.ts +++ b/src/utils/action.ts @@ -10,6 +10,7 @@ import { FrigateCardAction, FrigateCardCustomAction, frigateCardCustomActionSchema, + FrigateCardViewAction, } from '../types.js'; /** @@ -173,3 +174,21 @@ export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolea export const stopEventFromActivatingCardWideActions = (ev: Event): void => { ev.stopPropagation(); }; + +export const isViewAction = ( + action: FrigateCardCustomAction, +): action is FrigateCardViewAction => { + switch (action.frigate_card_action) { + case 'clip': + case 'clips': + case 'image': + case 'live': + case 'recording': + case 'recordings': + case 'snapshot': + case 'snapshots': + case 'timeline': + return true; + } + return false; +}; diff --git a/src/utils/download.ts b/src/utils/download.ts index 574a574e..fd213771 100644 --- a/src/utils/download.ts +++ b/src/utils/download.ts @@ -5,6 +5,35 @@ import { ViewMedia } from '../view/media'; import { errorToConsole } from './basic'; import { homeAssistantSignPath } from './ha'; +export const downloadURL = (url: string, filename = 'download'): void => { + // The download attribute only works on the same origin. + // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes + const isSameOrigin = new URL(url).origin === window.location.origin; + const dataURL = url.startsWith('data:'); + + if ( + navigator.userAgent.startsWith('Home Assistant/') || + navigator.userAgent.startsWith('HomeAssistant/') || + (!isSameOrigin && !dataURL) + ) { + // Home Assistant companion apps cannot download files without opening a + // new browser window. + // + // User-agents are specified here: + // - Android: https://github.com/home-assistant/android/blob/b285c9525dd4837a82db931c1b2321c0511494e6/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantApis.kt#L23 + // - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75 + window.open(url, '_blank'); + } else { + // Use the HTML5 download attribute to prevent a new window from + // temporarily opening. + const link = document.createElement('a'); + link.setAttribute('download', filename); + link.href = url; + link.click(); + link.remove(); + } +}; + export const downloadMedia = async ( hass: ExtendedHomeAssistant, cameraManager: CameraManager, @@ -30,29 +59,5 @@ export const downloadMedia = async ( finalURL = response; } - // The download attribute only works on the same origin. - // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes - const isSameOrigin = new URL(finalURL).origin === window.location.origin; - - if ( - !isSameOrigin || - navigator.userAgent.startsWith('Home Assistant/') || - navigator.userAgent.startsWith('HomeAssistant/') - ) { - // Home Assistant companion apps cannot download files without opening a - // new browser window. - // - // User-agents are specified here: - // - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107 - // - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75 - window.open(finalURL, '_blank'); - } else { - // Use the HTML5 download attribute to prevent a new window from - // temporarily opening. - const link = document.createElement('a'); - link.setAttribute('download', 'download'); - link.href = finalURL; - link.click(); - link.remove(); - } + downloadURL(finalURL); }; 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/media-info-controller.ts b/src/utils/media-info-controller.ts new file mode 100644 index 00000000..f35bc525 --- /dev/null +++ b/src/utils/media-info-controller.ts @@ -0,0 +1,27 @@ +import { MediaLoadedInfo } from '../types'; + +export class MediaLoadedInfoController { + protected _current: MediaLoadedInfo | null = null; + protected _lastKnown: MediaLoadedInfo | null = null; + + public set(current: MediaLoadedInfo): void { + this._current = current; + this._lastKnown = current; + } + + public get(): MediaLoadedInfo | null { + return this._current; + } + + public getLastKnown(): MediaLoadedInfo | null { + return this._lastKnown; + } + + public clear(): void { + this._current = null; + } + + public has(): boolean { + return !!this._current; + } +} diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts index f6e2b0ad..22637d7d 100644 --- a/src/utils/media-info.ts +++ b/src/utils/media-info.ts @@ -69,18 +69,6 @@ export function dispatchMediaLoadedEvent( } } -export function dispatchMediaVolumeChangeEvent(target: HTMLElement): void { - dispatchFrigateCardEvent(target, 'media:volumechange'); -} - -export function dispatchMediaPlayEvent(target: HTMLElement): void { - dispatchFrigateCardEvent(target, 'media:play'); -} - -export function dispatchMediaPauseEvent(target: HTMLElement): void { - dispatchFrigateCardEvent(target, 'media:pause'); -} - /** * Dispatch a pre-existing MediaLoadedInfo object as an event. * @param element The element to send the event. @@ -93,6 +81,26 @@ export function dispatchExistingMediaLoadedInfoAsEvent( dispatchFrigateCardEvent(target, 'media:loaded', MediaLoadedInfo); } +/** + * Dispatch a media unloaded event. + * @param element The element to send the event. + */ +export function dispatchMediaUnloadedEvent(element: HTMLElement): void { + dispatchFrigateCardEvent(element, 'media:unloaded'); +} + +export function dispatchMediaVolumeChangeEvent(target: HTMLElement): void { + dispatchFrigateCardEvent(target, 'media:volumechange'); +} + +export function dispatchMediaPlayEvent(target: HTMLElement): void { + dispatchFrigateCardEvent(target, 'media:play'); +} + +export function dispatchMediaPauseEvent(target: HTMLElement): void { + dispatchFrigateCardEvent(target, 'media:pause'); +} + /** * Determine if a MediaLoadedInfo object is valid/acceptable. * @param info The MediaLoadedInfo object. @@ -103,11 +111,3 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean { info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF ); } - -/** - * Dispatch a media unloaded event. - * @param element The element to send the event. - */ -export function dispatchMediaUnloadedEvent(element: HTMLElement): void { - dispatchFrigateCardEvent(element, 'media:unloaded'); -} diff --git a/src/utils/media.ts b/src/utils/media.ts index 9306695f..7f3704ed 100644 --- a/src/utils/media.ts +++ b/src/utils/media.ts @@ -1,4 +1,5 @@ import { FrigateCardMediaPlayer } from '../types'; +import { Timer } from './timer'; // The number of seconds to hide the video controls for after loading (in order // to give a cleaner UI appearance, see: @@ -6,6 +7,27 @@ import { FrigateCardMediaPlayer } from '../types'; export const MEDIA_LOAD_CONTROLS_HIDE_SECONDS = 2; const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1; +export type FrigateCardHTMLVideoElement = HTMLVideoElement & { + _controlsHideTimer?: Timer; +}; + +/** + * Sets the controls on a video and removes a timer that may have been added by + * hideMediaControlsTemporarily. + * @param video + * @param value + */ +export const setControlsOnVideo = ( + video: FrigateCardHTMLVideoElement, + value: boolean, +): void => { + if (video._controlsHideTimer) { + video._controlsHideTimer.stop(); + delete video._controlsHideTimer; + } + video.controls = value; +}; + /** * Temporarily hide media controls. * @param element Any HTMLElement that has a controls property (e.g. @@ -13,21 +35,15 @@ const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1; * @param seconds The number of seconds to hide the controls for. */ export const hideMediaControlsTemporarily = ( - element: HTMLElement & { - controls: boolean; - _controlsHideTimeoutID?: number; - }, + video: FrigateCardHTMLVideoElement, seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS, ): void => { - element.controls = false; - - if (element._controlsHideTimeoutID) { - window.clearTimeout(element._controlsHideTimeoutID); - } - element._controlsHideTimeoutID = window.setTimeout(() => { - element.controls = true; - delete element._controlsHideTimeoutID; - }, seconds * 1000); + const oldValue = video.controls; + setControlsOnVideo(video, false); + video._controlsHideTimer ??= new Timer(); + video._controlsHideTimer.start(seconds, () => { + setControlsOnVideo(video, oldValue); + }); }; /** @@ -42,8 +58,10 @@ export const playMediaMutingIfNecessary = async ( // and then try again. This works around some browsers that prevent // auto-play unless the video is muted. if (video?.play) { - video.play().catch(async (ev) => { - if (ev.name === 'NotAllowedError' && !player.isMuted()) { + try { + await video.play(); + } catch (err: unknown) { + if ((err as Error).name === 'NotAllowedError' && !player.isMuted()) { await player.mute(); try { await video.play(); @@ -51,6 +69,6 @@ export const playMediaMutingIfNecessary = async ( // Pass. } } - }); + } } }; diff --git a/src/utils/menu-controller.ts b/src/utils/menu-controller.ts new file mode 100644 index 00000000..18d9a841 --- /dev/null +++ b/src/utils/menu-controller.ts @@ -0,0 +1,477 @@ +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, + }); + } + } + + 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: createFrigateCardCustomAction('screenshot') 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/src/utils/microphone.ts b/src/utils/microphone.ts index 772b45f1..97d5f436 100644 --- a/src/utils/microphone.ts +++ b/src/utils/microphone.ts @@ -1,8 +1,9 @@ -import { errorToConsole } from "./basic"; +import { errorToConsole } from './basic'; +import { Timer } from './timer'; export class MicrophoneController { protected _stream?: MediaStream | null; - protected _timerID: number | null = null; + protected _timer = new Timer(); // We keep mute state separate from the stream state so that mute/unmute can // be expressed before the stream is created -- and when it's create it will @@ -68,20 +69,11 @@ export class MicrophoneController { return !this._stream || this._stream.getTracks().every((track) => !track.enabled); } - protected _clearTimer(): void { - if (this._timerID) { - window.clearTimeout(this._timerID); - this._timerID = null; - } - } - protected _startTimer(): void { if (this._disconnectSeconds) { - this._clearTimer(); - this._timerID = window.setTimeout(() => { - this._clearTimer(); + this._timer.start(this._disconnectSeconds, () => { this.disconnect(); - }, this._disconnectSeconds * 1000); + }); } } } diff --git a/src/utils/screenshot.ts b/src/utils/screenshot.ts new file mode 100644 index 00000000..ce19b830 --- /dev/null +++ b/src/utils/screenshot.ts @@ -0,0 +1,29 @@ +import format from 'date-fns/format'; +import { View } from '../view/view'; + +export const screenshotMedia = (video: HTMLVideoElement): string | null => { + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return null; + } + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL('image/jpeg'); +}; + +export const generateScreenshotTitle = (view?: View): string => { + if (view?.is('live') || view?.is('image')) { + return `${view.view}-${view.camera}-${format( + new Date(), + `yyyy-MM-dd-HH-mm-ss`, + )}.jpg`; + } else if (view?.isViewerView()) { + const media = view.queryResults?.getSelectedResult(); + const id = media?.getID() ?? null; + return `${view.view}-${view.camera}${id ? `-${id}` : ''}.jpg`; + } + return 'screenshot.jpg'; +}; diff --git a/src/utils/timer.ts b/src/utils/timer.ts new file mode 100644 index 00000000..96a3aaaa --- /dev/null +++ b/src/utils/timer.ts @@ -0,0 +1,29 @@ +export class Timer { + protected _timer: number | null = null; + + public stop(): void { + if (this._timer) { + window.clearTimeout(this._timer); + this._timer = null; + } + } + + public isRunning(): boolean { + return this._timer !== null; + } + + public start(seconds: number, func: () => void): void { + this.stop(); + this._timer = window.setTimeout(() => { + this._timer = null; + func(); + }, seconds * 1000); + } + + public startRepeated(seconds: number, func: () => void): void { + this.stop(); + this._timer = window.setInterval(() => { + func(); + }, seconds * 1000); + } +} diff --git a/src/utils/zod.ts b/src/utils/zod.ts index 2298797c..d3d53e8b 100644 --- a/src/utils/zod.ts +++ b/src/utils/zod.ts @@ -56,3 +56,65 @@ export function getParseErrorKeys(error: z.ZodError): string[] { const errors = error.format(); return Object.keys(errors).filter((v) => !v.startsWith('_')); } + +/** + * Get configuration parse errors. + * @param error The ZodError object from parsing. + * @returns An array of string error paths. + */ +export const getParseErrorPaths = (error: z.ZodError): Set | null => { + /* Zod errors involving unions are complex, as Zod may not be able to tell + * where the 'real' error is vs simply a union option not matching. This + * function finds all ZodError "issues" that don't have an error with 'type' + * in that object ('type' is the union discriminator for picture elements, + * the major union in the schema). An array of user-readable error + * locations is returned, or an empty list if none is available. None being + * available suggests the configuration has an error, but we can't tell + * exactly why (or rather Zod simply says it doesn't match any of the + * available unions). This usually suggests the user specified an incorrect + * type name entirely. */ + const contenders = new Set(); + if (error && error.issues) { + for (let i = 0; i < error.issues.length; i++) { + const issue = error.issues[i]; + if (issue.code == 'invalid_union') { + const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors; + for (let j = 0; j < unionErrors.length; j++) { + const nestedErrors = getParseErrorPaths(unionErrors[j]); + if (nestedErrors && nestedErrors.size) { + nestedErrors.forEach(contenders.add, contenders); + } + } + } else if (issue.code == 'invalid_type') { + if (issue.path[issue.path.length - 1] == 'type') { + return null; + } + contenders.add(getParseErrorPathString(issue.path)); + } else if (issue.code != 'custom') { + contenders.add(getParseErrorPathString(issue.path)); + } + } + } + return contenders; +}; + +/** + * Convert an array of strings and indices into a more user readable string, + * e.g. [a, 1, b, 2] => 'a[1] -> b[2]' + * @param path An array of strings and numbers. + * @returns A single string. + */ +const getParseErrorPathString = (path: (string | number)[]): string => { + let out = ''; + for (let i = 0; i < path.length; i++) { + const item = path[i]; + if (typeof item == 'number') { + out += '[' + item + ']'; + } else if (out) { + out += ' -> ' + item; + } else { + out = item; + } + } + return out; +}; diff --git a/src/utils/zoom/zoom.ts b/src/utils/zoom/zoom.ts index 8c0f68b9..b3fc9d0d 100644 --- a/src/utils/zoom/zoom.ts +++ b/src/utils/zoom/zoom.ts @@ -1,5 +1,4 @@ -import { PanzoomObject, PanzoomEventDetail } from '@dermotduffy/panzoom'; -import Panzoom from '@dermotduffy/panzoom'; +import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom'; import round from 'lodash-es/round'; import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic'; @@ -11,6 +10,7 @@ export class Zoom { protected _element: HTMLElement; protected _panzoom?: PanzoomObject; protected _zoomed = false; + protected _allowClick = true; protected _events = isHoverableDevice() ? { @@ -31,9 +31,23 @@ export class Zoom { // If we do not prevent default here, the media carousels scroll. ev.preventDefault(); + this._allowClick = false; + } else { + this._allowClick = true; } }; + protected _clickHandler = (ev: Event) => { + // When mouse clicking is used to pan, need to avoid that causing a click + // handler elsewhere in the card being called. Example: Viewing a snapshot, + // and panning within it should not cause a related clip to play (the click + // handler in the viewer). + if (!this._allowClick) { + ev.stopPropagation(); + } + this._allowClick = true; + }; + protected _moveHandler = (ev: Event) => { if (this._shouldZoomOrPan(ev)) { this._panzoom?.handleMove(ev as PointerEvent); @@ -64,11 +78,17 @@ export class Zoom { protected _shouldZoomOrPan(ev: Event): boolean { return ( !this._isScaleNormal(this._panzoom?.getScale()) || - (ev instanceof TouchEvent && ev.touches.length > 1) || + // TouchEvent does not exist on Firefox on non-touch events. + // See: https://github.com/dermotduffy/frigate-hass-card/issues/1174 + (window.TouchEvent && ev instanceof TouchEvent && ev.touches.length > 1) || (ev instanceof WheelEvent && ev.ctrlKey) ); } + protected _setTouchAction(touchEnabled: boolean): void { + this._element.style.touchAction = touchEnabled ? '' : 'none'; + } + public activate(): void { this._panzoom = Panzoom(this._element, { contain: 'outside', @@ -78,6 +98,11 @@ export class Zoom { // Do not force the cursor style (by default it will always show the // 'move' type cursor whether or not it is zoomed in). cursor: undefined, + + // Disable automatic touchAction setting from Panzoom() as otherwise it + // effectively disables dashboard scrolling. + // See: https://github.com/dermotduffy/frigate-hass-card/issues/1181 + touchAction: '', }); const registerListeners = ( @@ -94,17 +119,20 @@ export class Zoom { registerListeners(this._events['move'], this._moveHandler, { capture: true }); registerListeners(this._events['up'], this._upHandler, { capture: true }); registerListeners(['wheel'], this._wheelHandler); + registerListeners(['click'], this._clickHandler, { capture: true }); this._element.addEventListener('panzoomzoom', (ev: Event) => { // Take care here to only dispatch the zoomed/unzoomed events when the // absolute state changes (rather than on every single zoom adjustment). if (this._isScaleNormal((>ev).detail.scale)) { if (this._zoomed) { + this._setTouchAction(true); dispatchFrigateCardEvent(this._element, 'zoom:unzoomed'); } this._zoomed = false; } else { if (!this._zoomed) { + this._setTouchAction(false); dispatchFrigateCardEvent(this._element, 'zoom:zoomed'); } this._zoomed = true; diff --git a/tests/camera-manager/frigate/engine-frigate.test.ts b/tests/camera-manager/frigate/engine-frigate.test.ts new file mode 100644 index 00000000..171f38e1 --- /dev/null +++ b/tests/camera-manager/frigate/engine-frigate.test.ts @@ -0,0 +1,134 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { RecordingSegmentsCache, RequestCache } from '../../../src/camera-manager/cache'; +import { FrigateCameraManagerEngine } from '../../../src/camera-manager/frigate/engine-frigate'; +import { + FrigateEventViewMedia, + FrigateRecordingViewMedia, +} from '../../../src/camera-manager/frigate/media'; +import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/types.js'; +import { CameraConfig, RawFrigateCardConfig } from '../../../src/types'; +import { ViewMedia } from '../../../src/view/media'; +import { createCameraConfig, createHASS } from '../../test-utils'; + +const createEngine = (): FrigateCameraManagerEngine => { + return new FrigateCameraManagerEngine( + {}, + new RecordingSegmentsCache(), + new RequestCache(), + ); +}; + +const createRecordingMedia = (): FrigateRecordingViewMedia => { + return new FrigateRecordingViewMedia( + 'recording', + 'camera-1', + { + cameraID: 'camera-1', + startTime: new Date('2023-06-16T20:00:00Z'), + endTime: new Date('2023-06-16T20:59:59Z'), + events: 1, + }, + 'recording-id', + 'recording-content-id', + 'recording-title', + ); +}; + +const createEvent = (): FrigateEvent => { + return eventSchema.parse({ + camera: 'camera-1', + end_time: 1686974399, + false_positive: false, + has_clip: true, + has_snapshot: true, + id: 'event-id', + label: 'person', + sub_label: null, + start_time: 1686970800, + top_score: 0.8, + zones: [], + retain_indefinitely: true, + }); +}; + +const createClipMedia = (): FrigateEventViewMedia => { + return new FrigateEventViewMedia( + 'clip', + 'camera-1', + createEvent(), + 'event-clip-content-id', + 'event-clip-thumbnail', + ); +}; + +const createSnapshotMedia = (): FrigateEventViewMedia => { + return new FrigateEventViewMedia( + 'snapshot', + 'camera-1', + createEvent(), + 'event-snapshot-content-id', + 'event-snapshot-thumbnail', + ); +}; + +const createFrigateCameraConfig = (config?: RawFrigateCardConfig): CameraConfig => { + return createCameraConfig({ + frigate: { + camera_name: 'camera-1', + }, + ...config, + }); +}; + +describe('getMediaDownloadPath', () => { + afterEach(() => {}); + + it('should get event with clip download path', async () => { + const endpoint = await createEngine().getMediaDownloadPath( + createHASS(), + createFrigateCameraConfig(), + createClipMedia(), + ); + + expect(endpoint).toEqual({ + endpoint: '/api/frigate/frigate/notifications/event-id/clip.mp4?download=true', + sign: true, + }); + }); + + it('should get event with snapshot download path', async () => { + const endpoint = await createEngine().getMediaDownloadPath( + createHASS(), + createFrigateCameraConfig(), + createSnapshotMedia(), + ); + + expect(endpoint).toEqual({ + endpoint: '/api/frigate/frigate/notifications/event-id/snapshot.jpg?download=true', + sign: true, + }); + }); + + it('should get recording download path', async () => { + const endpoint = await createEngine().getMediaDownloadPath( + createHASS(), + createFrigateCameraConfig(), + createRecordingMedia(), + ); + + expect(endpoint).toEqual({ + endpoint: + '/api/frigate/frigate/recording/camera-1/start/1686945600/end/1686949199?download=true', + sign: true, + }); + }); + + it('should get no path for unknown type', async () => { + const endpoint = await createEngine().getMediaDownloadPath( + createHASS(), + createFrigateCameraConfig(), + new ViewMedia('clip', 'camera-1'), + ); + expect(endpoint).toBeNull(); + }); +}); diff --git a/tests/camera-manager/utils.test.ts b/tests/camera-manager/utils.test.ts index 96ba0731..0a870cc4 100644 --- a/tests/camera-manager/utils.test.ts +++ b/tests/camera-manager/utils.test.ts @@ -5,8 +5,9 @@ import { getCameraEntityFromConfig, sortMedia, } from '../../src/camera-manager/util.js'; -import { ViewMedia, ViewMediaType } from '../../src/view/media.js'; import { CameraConfig, cameraConfigSchema } from '../../src/types.js'; +import { ViewMedia, ViewMediaType } from '../../src/view/media.js'; +import { TestViewMedia } from '../test-utils.js'; describe('convertRangeToCacheFriendlyTimes', () => { it('should return cache friendly within hour range', () => { @@ -74,30 +75,6 @@ describe('capEndDate', () => { }); }); -// ViewMedia itself has no native way to set startTime and ID that aren't linked -// to an engine. -class TestViewMedia extends ViewMedia { - protected _ID: string | null; - protected _startTime: Date; - - constructor( - ID: string | null, - startTime: Date, - mediaType: ViewMediaType, - cameraID: string, - ) { - super(mediaType, cameraID); - this._ID = ID; - this._startTime = startTime; - } - public getID(): string | null { - return this._ID; - } - public getStartTime(): Date | null { - return this._startTime; - } -} - describe('sortMedia', () => { const media_1 = new TestViewMedia( 'id-1', diff --git a/tests/test-utils.ts b/tests/test-utils.ts index dcc71536..e7bda43d 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,19 +1,32 @@ -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, + ExtendedHomeAssistant, FrigateCardCondition, FrigateCardConfig, + MediaLoadedInfo, + RawFrigateCardConfig, cameraConfigSchema, frigateCardConditionSchema, frigateCardConfigSchema, } from '../src/types'; import { Entity } from '../src/utils/ha/entity-registry/types'; +import { ViewMedia, ViewMediaType } from '../src/view/media'; +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,12 +35,16 @@ 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 => { - const hass = mock(); +export const createHASS = (states?: HassEntities): ExtendedHomeAssistant => { + const hass = mock(); if (states) { hass.states = states; } @@ -89,3 +106,89 @@ 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, + }; +}; + +// ViewMedia itself has no native way to set startTime and ID that aren't linked +// to an engine. +export class TestViewMedia extends ViewMedia { + protected _id: string | null; + protected _startTime: Date; + + constructor( + id: string | null, + startTime: Date, + mediaType: ViewMediaType, + cameraID: string, + ) { + super(mediaType, cameraID); + this._id = id; + this._startTime = startTime; + } + public getID(): string | null { + return this._id; + } + public getStartTime(): Date | null { + return this._startTime; + } +} diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts index 264db819..d4ae8af8 100644 --- a/tests/utils/action.test.ts +++ b/tests/utils/action.test.ts @@ -1,14 +1,21 @@ import { handleActionConfig, hasAction } from 'custom-card-helpers'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; -import { actionSchema } from '../../src/types'; import { - convertActionToFrigateCardCustomAction, - createFrigateCardCustomAction, - frigateCardHandleActionConfig, - frigateCardHasAction, - getActionConfigGivenAction, - stopEventFromActivatingCardWideActions, + actionSchema, + FrigateCardAction, + FrigateCardCustomAction, + frigateCardCustomActionSchema, +} from '../../src/types'; +import { + convertActionToFrigateCardCustomAction, + createFrigateCardCustomAction, + frigateCardHandleAction, + frigateCardHandleActionConfig, + frigateCardHasAction, + getActionConfigGivenAction, + isViewAction, + stopEventFromActivatingCardWideActions, } from '../../src/utils/action'; import { createHASS } from '../test-utils'; @@ -173,6 +180,23 @@ describe('frigateCardHandleActionConfig', () => { }); }); +// @vitest-environment jsdom +describe('frigateCardHandleAction', () => { + const element = document.createElement('div'); + const action = actionSchema.parse({ + action: 'none', + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should call action handler', () => { + frigateCardHandleAction(element, createHASS(), {}, action); + expect(handleActionConfig).toBeCalled(); + }); +}); + describe('frigateCardHasAction', () => { const action = actionSchema.parse({ action: 'toggle', @@ -200,3 +224,45 @@ describe('stopEventFromActivatingCardWideActions', () => { expect(event.stopPropagation).toBeCalled(); }); }); + +describe('isViewAction', () => { + const createAction = (action: FrigateCardAction): FrigateCardCustomAction => { + return frigateCardCustomActionSchema.parse({ + action: 'fire-dom-event' as const, + frigate_card_action: action, + }); + }; + it('should return true for clip view ', () => { + expect(isViewAction(createAction('clip'))).toBeTruthy(); + }); + it('should return true for clips view ', () => { + expect(isViewAction(createAction('clips'))).toBeTruthy(); + }); + it('should return true for image view ', () => { + expect(isViewAction(createAction('image'))).toBeTruthy(); + }); + it('should return true for live view ', () => { + expect(isViewAction(createAction('live'))).toBeTruthy(); + }); + it('should return true for recording view ', () => { + expect(isViewAction(createAction('recording'))).toBeTruthy(); + }); + it('should return true for live view ', () => { + expect(isViewAction(createAction('live'))).toBeTruthy(); + }); + it('should return true for recordings view ', () => { + expect(isViewAction(createAction('recordings'))).toBeTruthy(); + }); + it('should return true for snapshot view ', () => { + expect(isViewAction(createAction('snapshot'))).toBeTruthy(); + }); + it('should return true for snapshots view ', () => { + expect(isViewAction(createAction('snapshots'))).toBeTruthy(); + }); + it('should return true for timeline view ', () => { + expect(isViewAction(createAction('timeline'))).toBeTruthy(); + }); + it('should return false for anything else', () => { + expect(isViewAction(createAction('diagnostics'))).toBeFalsy(); + }); +}); 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/debug.test.ts b/tests/utils/debug.test.ts index 52f678a3..2354fd02 100644 --- a/tests/utils/debug.test.ts +++ b/tests/utils/debug.test.ts @@ -4,7 +4,7 @@ import { log } from '../../src/utils/debug.js'; describe('log', () => { const spy = vi.spyOn(global.console, 'debug').mockReturnValue(undefined); afterAll(() => { - vi.resetAllMocks(); + vi.restoreAllMocks(); }); it('should do nothing without debug logging set', () => { log({}, 'foo'); diff --git a/tests/utils/download.test.ts b/tests/utils/download.test.ts new file mode 100644 index 00000000..850528f3 --- /dev/null +++ b/tests/utils/download.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CameraManager } from '../../src/camera-manager/manager.js'; +import { downloadMedia, downloadURL } from '../../src/utils/download'; +import { homeAssistantSignPath } from '../../src/utils/ha'; +import { ViewMedia } from '../../src/view/media'; +import { createCameraManager, createHASS } from '../test-utils'; + +vi.mock('../../src/camera-manager/manager.js'); +vi.mock('../../src/utils/ha'); + +const media = new ViewMedia('clip', 'camera-1'); + +// @vitest-environment jsdom +describe('downloadMedia', () => { + afterEach(() => { + vi.resetAllMocks(); + global.window.location = mock(); + }); + + it('should throw error when no media', async () => { + const cameraManager = createCameraManager(); + mock(cameraManager).getMediaDownloadPath.mockResolvedValue(null); + + expect(downloadMedia(createHASS(), cameraManager, media)).rejects.toThrow( + /No media to download/, + ); + }); + + it('should throw error when signing fails', () => { + vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const cameraManager = createCameraManager(); + mock(cameraManager).getMediaDownloadPath.mockResolvedValue({ + sign: true, + endpoint: 'foo', + }); + const signError = new Error('sign-error'); + vi.mocked(homeAssistantSignPath).mockRejectedValue(signError); + + expect(downloadMedia(createHASS(), cameraManager, media)).rejects.toThrow( + /Could not sign media URL for download/, + ); + }); + + it('should download media', async () => { + const cameraManager = createCameraManager(); + mock(cameraManager).getMediaDownloadPath.mockResolvedValue({ + sign: true, + endpoint: 'foo', + }); + vi.mocked(homeAssistantSignPath).mockResolvedValue('http://foo/signed-url'); + const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + await downloadMedia(createHASS(), cameraManager, media); + expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank'); + }); +}); + +describe('downloadURL', () => { + afterEach(() => { + vi.resetAllMocks(); + global.window.location = mock(); + }); + + it('should download same origin via link', async () => { + const location: Location & { origin: string } = mock(); + location.origin = 'http://foo'; + global.window.location = location; + + const link = document.createElement('a'); + link.click = vi.fn(); + link.setAttribute = vi.fn(); + vi.spyOn(document, 'createElement').mockReturnValue(link); + + downloadURL('http://foo/url.mp4'); + + expect(link.href).toBe('http://foo/url.mp4'); + expect(link.setAttribute).toBeCalledWith('download', 'download'); + expect(link.click).toBeCalled(); + }); + + it('should download in apps via window.open', async () => { + // Set the origin to the same. + const location: Location & { origin: string } = mock(); + location.origin = 'http://foo'; + global.window.location = location; + + vi.stubGlobal('navigator', { + userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)', + }); + + const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + downloadURL('http://foo/url.mp4'); + expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank'); + }); +}); diff --git a/tests/utils/media-info-controller.test.ts b/tests/utils/media-info-controller.test.ts new file mode 100644 index 00000000..2815efe7 --- /dev/null +++ b/tests/utils/media-info-controller.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { MediaLoadedInfoController } from '../../src/utils/media-info-controller'; +import { createMediaLoadedInfo } from '../test-utils.js'; + +describe('MediaLoadedInfoController', () => { + let controller: MediaLoadedInfoController; + beforeEach(() => { + controller = new MediaLoadedInfoController(); + }); + + it('should set', () => { + const info = createMediaLoadedInfo(); + controller.set(info); + expect(controller.has()); + expect(controller.get()).toBe(info); + }); + + it('should get last known', () => { + const info = createMediaLoadedInfo(); + controller.set(info); + expect(controller.has()).toBeTruthy(); + controller.clear(); + expect(controller.has()).toBeFalsy(); + expect(controller.getLastKnown()).toBe(info); + }); +}); diff --git a/tests/utils/media-info.test.ts b/tests/utils/media-info.test.ts new file mode 100644 index 00000000..59e11787 --- /dev/null +++ b/tests/utils/media-info.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { FrigateCardMediaPlayer, MediaLoadedCapabilities } from '../../src/types'; +import { + createMediaLoadedInfo, + dispatchExistingMediaLoadedInfoAsEvent, + dispatchMediaLoadedEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent, + dispatchMediaUnloadedEvent, + dispatchMediaVolumeChangeEvent, + isValidMediaLoadedInfo, +} from '../../src/utils/media-info'; +import { createMediaLoadedInfo as createTestMediaLoadedInfo } from '../test-utils.js'; + +const options = { + player: mock(), + capabilities: mock(), +}; + +// @vitest-environment jsdom +describe('createMediaLoadedInfo', () => { + it('should create from image', () => { + const img = document.createElement('img'); + + // Need to write readonly properties. + Object.defineProperty(img, 'naturalWidth', { value: 10 }); + Object.defineProperty(img, 'naturalHeight', { value: 20 }); + + expect(createMediaLoadedInfo(img, options)).toEqual({ + width: 10, + height: 20, + ...options, + }); + }); + + it('should create from video', () => { + const video = document.createElement('video'); + + // Need to write readonly properties. + Object.defineProperty(video, 'videoWidth', { value: 30 }); + Object.defineProperty(video, 'videoHeight', { value: 40 }); + + expect(createMediaLoadedInfo(video, options)).toEqual({ + width: 30, + height: 40, + ...options, + }); + }); + + it('should create from canvas', () => { + const canvas = document.createElement('canvas'); + canvas.width = 50; + canvas.height = 60; + + expect(createMediaLoadedInfo(canvas, options)).toEqual({ + width: 50, + height: 60, + ...options, + }); + }); + + it('should not create from unknown', () => { + const div = document.createElement('div'); + expect(createMediaLoadedInfo(div, options)).toBeNull(); + }); + + it('should create from event', () => { + const img = document.createElement('img'); + + // Need to write readonly properties. + Object.defineProperty(img, 'naturalWidth', { value: 70 }); + Object.defineProperty(img, 'naturalHeight', { value: 80 }); + + const event = new Event('foo'); + Object.defineProperty(event, 'composedPath', { value: () => [img] }); + + expect(createMediaLoadedInfo(event, options)).toEqual({ + width: 70, + height: 80, + ...options, + }); + }); +}); + +// @vitest-environment jsdom +describe('dispatchMediaLoadedEvent', () => { + const options = { + player: mock(), + capabilities: mock(), + }; + + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:loaded', handler); + + // Need to write readonly properties. + const img = document.createElement('img'); + Object.defineProperty(img, 'naturalWidth', { value: 10 }); + Object.defineProperty(img, 'naturalHeight', { value: 20 }); + + dispatchMediaLoadedEvent(div, img, options); + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: { + width: 10, + height: 20, + ...options, + }, + }), + ); + }); + + it('should not dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:loaded', handler); + + dispatchMediaLoadedEvent(div, div, options); + expect(handler).not.toBeCalled(); + }); +}); + +// @vitest-environment jsdom +describe('dispatchExistingMediaLoadedInfoAsEvent', () => { + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:loaded', handler); + const info = createTestMediaLoadedInfo(); + + dispatchExistingMediaLoadedInfoAsEvent(div, info); + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: info, + }), + ); + }); +}); + +// @vitest-environment jsdom +describe('dispatchMediaUnloadedEvent', () => { + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:unloaded', handler); + + dispatchMediaUnloadedEvent(div); + expect(handler).toBeCalled(); + }); +}); + +// @vitest-environment jsdom +describe('dispatchMediaVolumeChangeEvent', () => { + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:volumechange', handler); + + dispatchMediaVolumeChangeEvent(div); + expect(handler).toBeCalled(); + }); +}); + +// @vitest-environment jsdom +describe('dispatchMediaPlayEvent', () => { + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:play', handler); + + dispatchMediaPlayEvent(div); + expect(handler).toBeCalled(); + }); +}); + +// @vitest-environment jsdom +describe('dispatchMediaPauseEvent', () => { + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:pause', handler); + + dispatchMediaPauseEvent(div); + expect(handler).toBeCalled(); + }); +}); + +// @vitest-environment jsdom +describe('isValidMediaLoadedInfo', () => { + it('should be valid with correct dimensions', () => { + expect( + isValidMediaLoadedInfo(createTestMediaLoadedInfo({ width: 100, height: 100 })), + ).toBeTruthy(); + }); + it('should be invalid with unlikely dimensions', () => { + expect( + isValidMediaLoadedInfo(createTestMediaLoadedInfo({ width: 0, height: 0 })), + ).toBeFalsy(); + }); +}); diff --git a/tests/utils/media.test.ts b/tests/utils/media.test.ts new file mode 100644 index 00000000..3a9d6cd3 --- /dev/null +++ b/tests/utils/media.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { FrigateCardMediaPlayer } from '../../src/types.js'; +import { + FrigateCardHTMLVideoElement, + hideMediaControlsTemporarily, + playMediaMutingIfNecessary, + setControlsOnVideo, +} from '../../src/utils/media.js'; + +// @vitest-environment jsdom +describe('setControlsOnVideo', () => { + it('should set controls', () => { + const video = document.createElement('video'); + + setControlsOnVideo(video, false); + expect(video.controls).toBeFalsy(); + }); + + it('should stop timer', () => { + const video: FrigateCardHTMLVideoElement = document.createElement('video'); + hideMediaControlsTemporarily(video); + + expect(video._controlsHideTimer).toBeTruthy(); + expect(video._controlsHideTimer?.isRunning()).toBeTruthy(); + + setControlsOnVideo(video, false); + expect(video.controls).toBeFalsy(); + expect(video._controlsHideTimer).toBeFalsy(); + }); +}); + +describe('hideMediaControlsTemporarily', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should set controls', () => { + const video: FrigateCardHTMLVideoElement = document.createElement('video'); + video.controls = true; + hideMediaControlsTemporarily(video); + + expect(video.controls).toBeFalsy(); + vi.runOnlyPendingTimers(); + + expect(video.controls).toBeTruthy(); + expect(video._controlsHideTimer).toBeFalsy(); + }); +}); + +class NotAllowedError extends Error { + name = 'NotAllowedError'; +} + +describe('playMediaMutingIfNecessary', () => { + it('should play', async () => { + const player = mock(); + const video = mock(); + video.play.mockResolvedValue(); + await playMediaMutingIfNecessary(player, video); + expect(video.play).toBeCalled(); + }); + + it('should mute if not allowed to play and unmuted', async () => { + const player = mock(); + player.isMuted.mockReturnValue(false); + player.mute.mockResolvedValue(); + + const video = mock(); + video.play.mockRejectedValueOnce(new NotAllowedError()).mockResolvedValueOnce(); + + await playMediaMutingIfNecessary(player, video); + + expect(video.play).toBeCalledTimes(2); + expect(player.isMuted).toBeCalled(); + expect(player.mute).toBeCalled(); + }); + + it('should not mute if not allowed to play and already unmuted', async () => { + const player = mock(); + player.isMuted.mockReturnValue(true); + + const video = mock(); + video.play.mockRejectedValueOnce(new NotAllowedError()); + + await playMediaMutingIfNecessary(player, video); + + expect(video.play).toBeCalledTimes(1); + expect(player.isMuted).toBeCalled(); + expect(player.mute).not.toBeCalled(); + }); + + it('should ignore exception if subsequent play call throws', async () => { + const player = mock(); + player.isMuted.mockReturnValue(false); + + const video = mock(); + video.play.mockRejectedValue(new NotAllowedError()); + + await playMediaMutingIfNecessary(player, video); + + expect(video.play).toBeCalledTimes(2); + expect(player.isMuted).toBeCalled(); + expect(player.mute).toBeCalled(); + }); +}); diff --git a/tests/utils/menu-controller.test.ts b/tests/utils/menu-controller.test.ts new file mode 100644 index 00000000..ffff040a --- /dev/null +++ b/tests/utils/menu-controller.test.ts @@ -0,0 +1,1204 @@ +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 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' }, + }); + }); + + 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/screenshot.test.ts b/tests/utils/screenshot.test.ts new file mode 100644 index 00000000..f6fcdd37 --- /dev/null +++ b/tests/utils/screenshot.test.ts @@ -0,0 +1,95 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { generateScreenshotTitle, screenshotMedia } from '../../src/utils/screenshot'; +import { MediaQueriesResults } from '../../src/view/media-queries-results'; +import { View } from '../../src/view/view'; +import { TestViewMedia, createView } from '../test-utils'; + +// @vitest-environment jsdom +describe('screenshotMedia', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should not screenshot without context', () => { + const video = document.createElement('video'); + + const canvas = document.createElement('canvas'); + const getContext = vi.fn().mockReturnValue(null); + canvas.getContext = getContext; + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + + expect(screenshotMedia(video)).toBeNull(); + }); + + it('should screenshot', () => { + const video = document.createElement('video'); + + const canvas = document.createElement('canvas'); + const getContext = vi.fn().mockReturnValue(mock()); + canvas.getContext = getContext; + canvas.toDataURL = vi.fn().mockReturnValue('data:image/jpeg;base64'); + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + + expect(screenshotMedia(video)).toBe('data:image/jpeg;base64'); + }); +}); + +describe('generateScreenshotTitle', () => { + beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-06-13T21:54:01')); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should get title without view', () => { + expect(generateScreenshotTitle()).toBe('screenshot.jpg'); + }); + + it('should get title for live view', () => { + expect(generateScreenshotTitle(new View({ view: 'live', camera: 'camera-1' }))).toBe( + 'live-camera-1-2023-06-13-21-54-01.jpg', + ); + }); + + it('should get title for image view', () => { + expect( + generateScreenshotTitle(new View({ view: 'image', camera: 'camera-1' })), + ).toBe('image-camera-1-2023-06-13-21-54-01.jpg'); + }); + + it('should get title for media viewer view with id', () => { + const media = new TestViewMedia( + 'id1', + new Date('2023-06-16T18:52'), + 'clip', + 'camera-1', + ); + const view = createView({ + view: 'media', + camera: 'camera-1', + queryResults: new MediaQueriesResults([media], 0), + }); + + expect(generateScreenshotTitle(view)).toBe('media-camera-1-id1.jpg'); + }); + + it('should get title for media viewer view without id', () => { + const media = new TestViewMedia( + null, + new Date('2023-06-16T18:52'), + 'clip', + 'camera-1', + ); + const view = createView({ + view: 'media', + camera: 'camera-1', + queryResults: new MediaQueriesResults([media], 0), + }); + + expect(generateScreenshotTitle(view)).toBe('media-camera-1.jpg'); + }); +}); 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/utils/timer.test.ts b/tests/utils/timer.test.ts new file mode 100644 index 00000000..4bfe0983 --- /dev/null +++ b/tests/utils/timer.test.ts @@ -0,0 +1,67 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { Timer } from '../../src/utils/timer'; + +// @vitest-environment jsdom +describe('Timer', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should not be running on construct', () => { + const timer = new Timer(); + expect(timer.isRunning()).toBeFalsy(); + }); + + it('should fire when started', () => { + const timer = new Timer(); + const handler = vi.fn(); + timer.start(10, handler); + + expect(timer.isRunning()).toBeTruthy(); + expect(handler).not.toBeCalled(); + + vi.runOnlyPendingTimers(); + + expect(timer.isRunning()).toBeFalsy(); + expect(handler).toBeCalled(); + }); + + it('should fire repeatedly when started', () => { + const timer = new Timer(); + const handler = vi.fn(); + timer.startRepeated(10, handler); + + expect(timer.isRunning()).toBeTruthy(); + expect(handler).not.toBeCalled(); + + vi.runOnlyPendingTimers(); + + expect(timer.isRunning()).toBeTruthy(); + expect(handler).toBeCalledTimes(1); + + vi.runOnlyPendingTimers(); + + expect(timer.isRunning()).toBeTruthy(); + expect(handler).toBeCalledTimes(2); + }); + + it('should not fire when stopped', () => { + const timer = new Timer(); + const handler = vi.fn(); + timer.start(10, handler); + + expect(timer.isRunning()).toBeTruthy(); + expect(handler).not.toBeCalled(); + + timer.stop(); + + vi.runOnlyPendingTimers(); + + expect(timer.isRunning()).toBeFalsy(); + expect(handler).not.toBeCalled(); + }); +}); diff --git a/tests/utils/zod.test.ts b/tests/utils/zod.test.ts new file mode 100644 index 00000000..0d55f48e --- /dev/null +++ b/tests/utils/zod.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { + deepRemoveDefaults, + getParseErrorKeys, + getParseErrorPaths, +} from '../../src/utils/zod'; + +describe('deepRemoveDefaults', () => { + it('should remove string defaults', () => { + const schema = z.object({ + string: z.string().default('foo'), + }); + const result = deepRemoveDefaults(schema).parse({}); + expect(result.string).toBeUndefined(); + }); + it('should remove array defaults', () => { + const schema = z.object({ + array: z.string().array().default(['foo']), + }); + const result = deepRemoveDefaults(schema).parse({}); + expect(result.array).toBeUndefined(); + }); + it('should remove optional defaults', () => { + const schema = z.object({ + string: z.string().default('foo').optional(), + }); + const result = deepRemoveDefaults(schema).parse({}); + expect(result.string).toBeUndefined(); + }); + it('should remove null defaults', () => { + const schema = z.object({ + null: z.string().default('foo').nullable(), + }); + const result = deepRemoveDefaults(schema).parse({}); + expect(result.null).toBeUndefined(); + }); + it('should remove null defaults', () => { + const schema = z.object({ + tuple: z.tuple([z.string()]).default(['foo']), + }); + const result = deepRemoveDefaults(schema).parse({}); + expect(result.tuple).toBeUndefined(); + }); + it('should not interfere with parsing', () => { + const schema = z.object({ + string: z.string().default('foo'), + }); + const result = deepRemoveDefaults(schema).parse({ string: 'moo' }); + expect(result.string).toBe('moo'); + }); +}); + +describe('getParseErrorKeys', () => { + it('should get error keys', () => { + const result = z.object({ required: z.string() }).safeParse({}); + expect(result.success).toBeFalsy(); + if (result.success) { + return; + } + expect(getParseErrorKeys(result.error)).toEqual(['required']); + }); +}); + +describe('getParseErrorPaths', () => { + it('should get simple error paths', () => { + const result = z.object({ required: z.string() }).safeParse({}); + expect(result.success).toBeFalsy(); + if (result.success) { + return; + } + expect(getParseErrorPaths(result.error)).toEqual(new Set(['required'])); + }); + it('should get union error paths', () => { + const type_one = z.object({ type: z.string(), data: z.string() }); + const type_two = z.object({ type: z.literal('two'), data: z.string() }); + + const schema = z.object({ + array: type_one.or(type_two).array(), + }); + + const result = schema.safeParse({ array: [{}] }); + expect(result.success).toBeFalsy(); + if (result.success) { + return; + } + expect(getParseErrorPaths(result.error)).toEqual( + new Set(['array[0] -> type', 'array[0] -> data']), + ); + }); +}); diff --git a/tests/utils/zoom.test.ts b/tests/utils/zoom.test.ts index 4d9daf10..9c81d8fe 100644 --- a/tests/utils/zoom.test.ts +++ b/tests/utils/zoom.test.ts @@ -124,6 +124,48 @@ describe('Zoom', () => { expect(panzoom.handleUp).toBeCalledWith(ev_5); }); + it('should ignore click after pointerdown', () => { + const outer = document.createElement('div'); + const inner = document.createElement('div'); + outer.appendChild(inner); + const clickHandler = vi.fn(); + outer.addEventListener('click', clickHandler); + + const panzoom = createMockPanZoom(); + vi.mocked(Panzoom).mockReturnValueOnce(panzoom); + + createAndRegisterZoom(inner); + + // Simulate being zoomed in. + panzoom.getScale = vi.fn().mockReturnValue(1.2); + + // A click on its own will be fine. + const click_1 = new MouseEvent('click', { bubbles: true }); + inner.dispatchEvent(click_1); + expect(clickHandler).toBeCalledTimes(1); + + // A click after a pointerdown will be ignored. + const pointerdown_1 = new PointerEvent('pointerdown'); + inner.dispatchEvent(pointerdown_1); + + const click_2 = new MouseEvent('click', { bubbles: true }); + inner.dispatchEvent(click_2); + + // Click will have been ignored. + expect(clickHandler).toBeCalledTimes(1); + + // Simulate being zoomed out. + panzoom.getScale = vi.fn().mockReturnValue(1.0); + const pointerdown_2 = new PointerEvent('pointerdown'); + inner.dispatchEvent(pointerdown_2); + + const click_3 = new MouseEvent('click', { bubbles: true }); + inner.dispatchEvent(click_3); + + // Click will have been processed. + expect(clickHandler).toBeCalledTimes(2); + }); + it('deactivate should remove event handlers', () => { const element = document.createElement('div'); @@ -150,8 +192,7 @@ describe('Zoom', () => { element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc); element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc); - const panzoom = createMockPanZoom(); - vi.mocked(Panzoom).mockReturnValueOnce(panzoom); + vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); createAndRegisterZoom(element); @@ -180,4 +221,35 @@ describe('Zoom', () => { element.dispatchEvent(ev_2); expect(unzoomedFunc).toBeCalled(); }); + + it('should set touch action on zoom/unzoom', () => { + const element = document.createElement('div'); + vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom()); + + createAndRegisterZoom(element); + + const ev_1 = new CustomEvent('panzoomzoom', { + detail: { + x: 0, + y: 0, + scale: 1.2, + isSVG: false, + originalEvent: new PointerEvent('pointermove'), + }, + }); + element.dispatchEvent(ev_1); + expect(element.style.touchAction).toBe('none'); + + const ev_2 = new CustomEvent('panzoomzoom', { + detail: { + x: 0, + y: 0, + scale: 1, + isSVG: false, + originalEvent: new PointerEvent('pointermove'), + }, + }); + element.dispatchEvent(ev_2); + expect(element.style.touchAction).toBeFalsy(); + }); }); 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', () => { diff --git a/yarn.lock b/yarn.lock index 34f01e7f..8cf2f48d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -636,14 +636,14 @@ __metadata: languageName: node linkType: hard -"@graphiteds/core@npm:^1.9.6": - version: 1.9.7 - resolution: "@graphiteds/core@npm:1.9.7" +"@graphiteds/core@npm:^1.9.11": + version: 1.9.11 + resolution: "@graphiteds/core@npm:1.9.11" dependencies: "@duetds/date-picker": ^1.4.0 "@popperjs/core": ^2.11.5 "@stencil/core": ^2.20.0 - checksum: d9fa01a5e63fa4b26010893f85324ddfc1961c08ba1a934da5371941d648582e722a87f5af682084be24f28aa5b3aec3bda6a8ff8759b2f7246e5db13f528391 + checksum: de67030f51fb165563bd73dc55a1294063f40e6de1bd55f864ea9c627069da81fbdb171de7908ae34a8c3f8d0505fd9414762bd695a0e449304d441867bc59cc languageName: node linkType: hard @@ -3041,7 +3041,7 @@ __metadata: "@cycjimmy/jsmpeg-player": ^6.0.4 "@dermotduffy/panzoom": ^4.5.1 "@egjs/hammerjs": ^2.0.17 - "@graphiteds/core": ^1.9.6 + "@graphiteds/core": ^1.9.11 "@lit-labs/scoped-registry-mixin": ^1.0.1 "@lit-labs/task": ^1.1.3 "@rollup/plugin-babel": ^5.3.1