Merge pull request #1212 from dermotduffy/merge-5.2-dev-to-main

Merge v5.2.0 release back into main
This commit is contained in:
Dermot Duffy
2023-06-23 09:41:45 -06:00
committed by GitHub
61 changed files with 3484 additions and 885 deletions
+2 -2
View File
@@ -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 }}
+88 -3
View File
@@ -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`|
<a name="custom-actions"></a>
@@ -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). |
<a name="views"></a>
@@ -1584,6 +1586,10 @@ Pan around a large camera view to only show part of the video feed in the card a
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/zoom.gif" alt="Zoom Support" width="400px">
### Taking card actions via the URL
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/navigate-picture-elements.gif" alt="Taking card actions via the URL" width="400px">
## 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
```
</details>
@@ -3745,6 +3757,47 @@ https://ha.mydomain.org/lovelace-test/0?frigate-card-action:main:clips
```
</details>
<details>
<summary>Expand: Choosing the camera from a separate picture elements card</summary>
In this example, the card will select a given camera when the user navigates from a *separate* Picture Elements card:
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/navigate-picture-elements.gif" alt="Taking card actions via the URL" width="400px">
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
```
</details>
### 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

+9 -20
View File
@@ -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 { default: pluginJson } = await env.$import(
"https://cdn.jsdelivr.net/gh/samuelstroschein/inlang-plugin-json@2/dist/index.js"
);
const { standardLintRules } = await env.$import(
"https://cdn.jsdelivr.net/gh/inlang/standard-lint-rules@1/dist/index.js"
const { default: standardLintRules } = await env.$import(
"https://cdn.jsdelivr.net/gh/inlang/standard-lint-rules@2/dist/index.js"
);
const pluginConfig = {
pathPattern: "./src/localize/languages/{language}.json",
};
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()],
},
referenceLanguage: 'en',
plugins: [pluginJson({
pathPattern: "./src/localize/languages/{language}.json",
variableReferencePattern: ["{", "}"],
}), standardLintRules()]
};
}
+2 -2
View File
@@ -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",
+13 -19
View File
@@ -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 {
+7 -7
View File
@@ -1,4 +1,5 @@
import { ReactiveController, ReactiveControllerHost } from 'lit';
import { Timer } from './utils/timer';
export class CachedValueController<T> implements ReactiveController {
protected _value?: T;
@@ -7,7 +8,7 @@ export class CachedValueController<T> 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<T> 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<T> 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();
}
/**
+1 -1
View File
@@ -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,
};
+1 -1
View File
@@ -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(),
+151 -608
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -95,6 +95,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
return !this._cachedValueController?.hasTimer() ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
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.
+4 -3
View File
@@ -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');
});
+7 -1
View File
@@ -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<void> {
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<string | null> {
return this._player ? screenshotMedia(this._player.video) : null;
}
disconnectedCallback(): void {
this._player = undefined;
}
+4
View File
@@ -54,6 +54,10 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
return this._playerRef.value?.isPaused() ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
return await this._playerRef.value?.getScreenshotURL() ?? null;
}
protected render(): TemplateResult | void {
if (!this.hass) {
return;
+4
View File
@@ -49,6 +49,10 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
return this._refImage.value?.isPaused() ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
return await this._refImage.value?.getScreenshotURL() ?? null;
}
protected render(): TemplateResult | void {
if (!this.hass || !this.cameraConfig) {
return;
+14 -8
View File
@@ -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<void> {
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<string | null> {
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(),
);
}
/**
+13 -6
View File
@@ -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<void> {
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<string | null> {
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;
}
});
}
+11 -5
View File
@@ -756,19 +756,19 @@ export class FrigateCardLiveProvider
public async pause(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.pause();
await this._refProvider.value?.pause();
}
public async mute(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.mute();
await this._refProvider.value?.mute();
}
public async unmute(): Promise<void> {
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<void> {
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<void> {
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<string | null> {
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').
+4 -6
View File
@@ -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<FrigateCardNextPreviousControl> = createRef();
protected _previousControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
protected _titleControlRef: Ref<FrigateCardTitleControl> = 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);
}
/**
+1 -2
View File
@@ -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.
+22 -1
View File
@@ -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<Element & FrigateCardMediaPlayer> =
createRef();
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
protected _refImageProvider: Ref<HTMLImageElement> = createRef();
public async play(): Promise<void> {
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<string | null> {
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 <video> below in order to
// allow screenshot of motionEye videos which currently go cross-origin.
return this._useZoomIfRequired(html`
${ViewMediaClassifier.isVideo(this.media)
? this.media.getVideoContentType() === VideoContentType.HLS
@@ -755,6 +774,7 @@ export class FrigateCardViewerProvider
title="${this.media.getTitle() ?? ''}"
muted
playsinline
crossorigin="anonymous"
?autoplay=${false}
?controls=${this.viewerConfig.controls.builtin}
@loadedmetadata=${(ev: Event) => {
@@ -785,6 +805,7 @@ export class FrigateCardViewerProvider
</video>
`
: html`<img
${ref(this._refImageProvider)}
aria-label="${this.media.getTitle() ?? ''}"
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
title="${this.media.getTitle() ?? ''}"
+2
View File
@@ -274,3 +274,5 @@ export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
// improved rendering performance.
export const MEDIA_CHUNK_SIZE_DEFAULT = 50;
export const MEDIA_CHUNK_SIZE_MAX = 1000;
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
+1
View File
@@ -1786,6 +1786,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
)}
${this._renderMenuButton('play') /* */}
${this._renderMenuButton('mute')}
${this._renderMenuButton('screenshot')}
</div>
`
: ''}
+1
View File
@@ -282,6 +282,7 @@
"play": "Play / Pause",
"priority": "Priority",
"recordings": "Recordings",
"screenshot": "Screenshot",
"snapshots": "Snapshots",
"substreams": "Substream(s)",
"timeline": "Timeline",
+1
View File
@@ -279,6 +279,7 @@
"mute": "",
"play": "",
"priority": "Priorità",
"screenshot": "",
"snapshots": "Istantanee",
"substreams": "Flusso/i secondario/i",
"timeline": "Timeline",
+1
View File
@@ -281,6 +281,7 @@
"play": "",
"priority": "Prioridade",
"recordings": "Gravações",
"screenshot": "",
"snapshots": "Instantâneos",
"substreams": "Substream(s)",
"timeline": "Linha do tempo",
+1
View File
@@ -272,6 +272,7 @@
"mute": "",
"play": "",
"priority": "Prioridade",
"screenshot": "",
"snapshots": "Instantâneos",
"substreams": "substreams",
"timeline": "Linha do tempo",
+4
View File
@@ -81,6 +81,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
return this._player?.isPaused() ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
return this._player ? await this._player.getScreenshotURL() : null;
}
/**
* Master render method.
* @returns A rendered template.
+9 -5
View File
@@ -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<void> {
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<string | null> {
return this._video ? screenshotMedia(this._video) : null;
}
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
+8 -2
View File
@@ -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<void> {
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<string | null> {
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
+11 -9
View File
@@ -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<typeof frigateCardViewActionSchema>;
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<void>;
isMuted(): boolean;
seek(seconds: number): Promise<void>;
getScreenshotURL(): Promise<string | null>;
// If no value for controls if specified, the player should use the default.
setControls(controls?: boolean): Promise<void>;
isPaused(): boolean;
+19
View File
@@ -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;
};
+30 -25
View File
@@ -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);
};
+1 -1
View File
@@ -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;
}
+27
View File
@@ -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;
}
}
+20 -20
View File
@@ -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<MediaLoadedInfo>(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');
}
+34 -16
View File
@@ -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.
}
}
});
}
}
};
+477
View File
@@ -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\//);
}
}
+5 -13
View File
@@ -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);
});
}
}
}
+29
View File
@@ -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';
};
+29
View File
@@ -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);
}
}
+62
View File
@@ -56,3 +56,65 @@ export function getParseErrorKeys<T>(error: z.ZodError<T>): 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 = <T>(error: z.ZodError<T>): Set<string> | 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<string>();
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;
};
+31 -3
View File
@@ -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((<CustomEvent<PanzoomEventDetail>>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;
@@ -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();
});
});
+2 -25
View File
@@ -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',
+110 -7
View File
@@ -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>): 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<HomeAssistant>();
export const createHASS = (states?: HassEntities): ExtendedHomeAssistant => {
const hass = mock<ExtendedHomeAssistant>();
if (states) {
hass.states = states;
}
@@ -89,3 +106,89 @@ export const createFrigateRecording = (recording?: Partial<FrigateRecording>) =>
...recording,
};
};
export const createView = (options?: Partial<ViewParameters>): View => {
return new View({
view: 'live',
camera: 'camera',
...options,
});
};
export const createCameraManager = (options?: {
store?: CameraManagerStore;
configs?: CameraConfigs;
}): CameraManager => {
const cameraManager = new CameraManager(mock<CameraManagerEngineFactory>(), {});
let store: CameraManagerStore | undefined = options?.store;
if (!store) {
store = mock<CameraManagerStore>();
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>,
): CameraManagerCameraCapabilities => {
return {
canFavoriteEvents: false,
canFavoriteRecordings: false,
canSeek: false,
supportsClips: false,
supportsRecordings: false,
supportsSnapshots: false,
supportsTimeline: false,
...options,
};
};
export const createMediaCapabilities = (
options?: Partial<CameraManagerMediaCapabilities>,
): CameraManagerMediaCapabilities => {
return {
canFavorite: false,
canDownload: false,
...options,
};
};
export const createMediaLoadedInfo = (
options?: Partial<MediaLoadedInfo>,
): 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;
}
}
+67 -1
View File
@@ -1,13 +1,20 @@
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 {
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();
});
});
+3 -13
View File
@@ -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<CameraManagerEngineFactory>(), {});
const store = mock<CameraManagerStore>();
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<CameraManagerEngineFactory>(), {});
const store = mock<CameraManagerStore>();
vi.mocked(cameraManager.getStore).mockReturnValue(store);
store.getCameras.mockReturnValue(cameraConfigs);
const cameraManager = createCameraManager({ configs: cameraConfigs });
expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
new Set(['one', 'two']),
);
+1 -1
View File
@@ -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');
+98
View File
@@ -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<Location>();
});
it('should throw error when no media', async () => {
const cameraManager = createCameraManager();
mock<CameraManager>(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>(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>(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<Location>();
});
it('should download same origin via link', async () => {
const location: Location & { origin: string } = mock<Location>();
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>();
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');
});
});
+26
View File
@@ -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);
});
});
+202
View File
@@ -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<FrigateCardMediaPlayer>(),
capabilities: mock<MediaLoadedCapabilities>(),
};
// @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<FrigateCardMediaPlayer>(),
capabilities: mock<MediaLoadedCapabilities>(),
};
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();
});
});
+110
View File
@@ -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<FrigateCardMediaPlayer>();
const video = mock<HTMLVideoElement>();
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<FrigateCardMediaPlayer>();
player.isMuted.mockReturnValue(false);
player.mute.mockResolvedValue();
const video = mock<HTMLVideoElement>();
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<FrigateCardMediaPlayer>();
player.isMuted.mockReturnValue(true);
const video = mock<HTMLVideoElement>();
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<FrigateCardMediaPlayer>();
player.isMuted.mockReturnValue(false);
const video = mock<HTMLVideoElement>();
video.play.mockRejectedValue(new NotAllowedError());
await playMediaMutingIfNecessary(player, video);
expect(video.play).toBeCalledTimes(2);
expect(player.isMuted).toBeCalled();
expect(player.mute).toBeCalled();
});
});
File diff suppressed because it is too large Load Diff
+95
View File
@@ -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<CanvasRenderingContext2D>());
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');
});
});
+6 -6
View File
@@ -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<CameraManager>();
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<CameraManager>();
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<CameraManager>();
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<CameraManager>();
const cameraManager = createCameraManager()
const newView = createViewWithNextStream(cameraManager, view);
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
});
+67
View File
@@ -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();
});
});
+91
View File
@@ -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']),
);
});
});
+74 -2
View File
@@ -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<PanzoomEventDetail>('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<PanzoomEventDetail>('panzoomzoom', {
detail: {
x: 0,
y: 0,
scale: 1,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_2);
expect(element.style.touchAction).toBeFalsy();
});
});
+3 -15
View File
@@ -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<ViewParameters>): 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', () => {
+5 -5
View File
@@ -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