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
+### 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:
+
+
+
+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