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 filename: frigate-hass-card.zip
- name: Upload JS files to release - name: Upload JS files to release
uses: svenstaro/upload-release-action@2.5.0 uses: svenstaro/upload-release-action@2.6.1
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
@@ -41,7 +41,7 @@ jobs:
overwrite: true overwrite: true
- name: Upload Zip file to release - name: Upload Zip file to release
uses: svenstaro/upload-release-action@2.5.0 uses: svenstaro/upload-release-action@2.6.1
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} 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).| | `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. | | `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. | | `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. | | `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. | | `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). | | `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 | | Parameter | Description |
| - | - | | - | - |
| `action` | Must be `custom:frigate-card-action`. | | `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> <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). | |`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. | |`mute`, `unmute`| Mute or unmute the loaded media. |
|`play`, `pause`| Play or pause 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> <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"> <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 ## Examples
### Illustrative Expanded Configuration Reference ### Illustrative Expanded Configuration Reference
@@ -2422,6 +2428,12 @@ elements:
frigate_card_action: media_player frigate_card_action: media_player
media_player: media_player.nesthub media_player: media_player.nesthub
media_player_action: stop 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> </details>
@@ -3745,6 +3757,47 @@ https://ha.mydomain.org/lovelace-test/0?frigate-card-action:main:clips
``` ```
</details> </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 ### 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). 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] [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] [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. | | `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. | | `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 #### Actions
| Action | Supported in query string | Explanation | | 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: | | | `play`, `pause` | :heavy_multiplication_x: | |
| `recording` | :white_check_mark: | | | `recording` | :white_check_mark: | |
| `recordings` | :white_check_mark: | | | `recordings` | :white_check_mark: | |
| `screenshot`| :heavy_multiplication_x: | Latest media information is not available on initial render. |
| `snapshot` | :white_check_mark: | | | `snapshot` | :white_check_mark: | |
| `snapshots` | :white_check_mark: | | | `snapshots` | :white_check_mark: | |
@@ -3940,6 +4005,21 @@ live:
mode: none 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 ### Microphone / 2-way audio doesn't work
There are many requirements for 2-way audio to work. See [Using 2-way 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. Use the same version number for the release title and tag.
1. Choose 'This is a pre-release' for a beta version. 1. Choose 'This is a pre-release' for a beta version.
1. Hit 'Publish release'. 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) { export async function defineConfig(env) {
const plugin = await env.$import( const { default: pluginJson } = await env.$import(
"https://cdn.jsdelivr.net/gh/samuelstroschein/inlang-plugin-json@1/dist/index.js" "https://cdn.jsdelivr.net/gh/samuelstroschein/inlang-plugin-json@2/dist/index.js"
); );
const { standardLintRules } = await env.$import( const { default: standardLintRules } = await env.$import(
"https://cdn.jsdelivr.net/gh/inlang/standard-lint-rules@1/dist/index.js" "https://cdn.jsdelivr.net/gh/inlang/standard-lint-rules@2/dist/index.js"
); );
const pluginConfig = {
pathPattern: "./src/localize/languages/{language}.json",
};
return { return {
referenceLanguage: "en", referenceLanguage: 'en',
languages: await plugin.getLanguages({ ...env, pluginConfig }), plugins: [pluginJson({
readResources: (args) => plugin.readResources({ ...args, ...env, pluginConfig }), pathPattern: "./src/localize/languages/{language}.json",
writeResources: (args) => plugin.writeResources({ ...args, ...env, pluginConfig }), variableReferencePattern: ["{", "}"],
lint: { }), standardLintRules()]
rules: [standardLintRules()],
},
}; };
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "frigate-hass-card", "name": "frigate-hass-card",
"version": "5.1.1", "version": "5.2.0",
"description": "Frigate Lovelace Card for Home Assistant", "description": "Frigate Lovelace Card for Home Assistant",
"keywords": [ "keywords": [
"frigate", "frigate",
@@ -18,7 +18,7 @@
"@cycjimmy/jsmpeg-player": "^6.0.4", "@cycjimmy/jsmpeg-player": "^6.0.4",
"@dermotduffy/panzoom": "^4.5.1", "@dermotduffy/panzoom": "^4.5.1",
"@egjs/hammerjs": "^2.0.17", "@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/scoped-registry-mixin": "^1.0.1",
"@lit-labs/task": "^1.1.3", "@lit-labs/task": "^1.1.3",
"@types/bluebird": "^3.5.36", "@types/bluebird": "^3.5.36",
+13 -19
View File
@@ -11,6 +11,7 @@ import {
DirectiveParameters, DirectiveParameters,
} from 'lit/directive.js'; } from 'lit/directive.js';
import { stopEventFromActivatingCardWideActions } from './utils/action.js'; import { stopEventFromActivatingCardWideActions } from './utils/action.js';
import { Timer } from './utils/timer.js';
interface ActionHandler extends HTMLElement { interface ActionHandler extends HTMLElement {
holdTime: number; holdTime: number;
@@ -25,14 +26,13 @@ interface FrigateCardActionHandlerOptions extends ActionHandlerOptions {
} }
class ActionHandler extends HTMLElement implements ActionHandler { 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; protected held = false;
private dblClickTimeout?: number;
public connectedCallback(): void { public connectedCallback(): void {
[ [
'touchcancel', 'touchcancel',
@@ -46,10 +46,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
document.addEventListener( document.addEventListener(
ev, ev,
() => { () => {
if (this.timer) { this.holdTimer.stop();
clearTimeout(this.timer);
this.timer = undefined;
}
}, },
{ passive: true }, { passive: true },
); );
@@ -79,9 +76,9 @@ class ActionHandler extends HTMLElement implements ActionHandler {
const start = (): void => { const start = (): void => {
this.held = false; this.held = false;
this.timer = window.setTimeout(() => { this.holdTimer.start(this.holdTime, () => {
this.held = true; this.held = true;
}, this.holdTime); });
fireEvent(element, 'action', { action: 'start_tap' }); fireEvent(element, 'action', { action: 'start_tap' });
}; };
@@ -103,8 +100,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
return; return;
} }
clearTimeout(this.timer); this.holdTimer.stop();
this.timer = undefined;
fireEvent(element, 'action', { action: 'end_tap' }); fireEvent(element, 'action', { action: 'end_tap' });
@@ -113,15 +109,13 @@ class ActionHandler extends HTMLElement implements ActionHandler {
} else if (options?.hasDoubleClick) { } else if (options?.hasDoubleClick) {
if ( if (
(ev.type === 'click' && (ev as MouseEvent).detail < 2) || (ev.type === 'click' && (ev as MouseEvent).detail < 2) ||
!this.dblClickTimeout !this.doubleClickTimer.isRunning()
) { ) {
this.dblClickTimeout = window.setTimeout(() => { this.doubleClickTimer.start(0.25, () =>
this.dblClickTimeout = undefined; fireEvent(element, 'action', { action: 'tap' }),
fireEvent(element, 'action', { action: 'tap' }); );
}, 250);
} else { } else {
clearTimeout(this.dblClickTimeout); this.doubleClickTimer.stop();
this.dblClickTimeout = undefined;
fireEvent(element, 'action', { action: 'double_tap' }); fireEvent(element, 'action', { action: 'double_tap' });
} }
} else { } else {
+7 -7
View File
@@ -1,4 +1,5 @@
import { ReactiveController, ReactiveControllerHost } from 'lit'; import { ReactiveController, ReactiveControllerHost } from 'lit';
import { Timer } from './utils/timer';
export class CachedValueController<T> implements ReactiveController { export class CachedValueController<T> implements ReactiveController {
protected _value?: T; protected _value?: T;
@@ -7,7 +8,7 @@ export class CachedValueController<T> implements ReactiveController {
protected _callback: () => T; protected _callback: () => T;
protected _timerStartCallback?: () => void; protected _timerStartCallback?: () => void;
protected _timerStopCallback?: () => void; protected _timerStopCallback?: () => void;
protected _timerID?: number; protected _timer = new Timer();
constructor( constructor(
host: ReactiveControllerHost, host: ReactiveControllerHost,
@@ -56,11 +57,10 @@ export class CachedValueController<T> implements ReactiveController {
* Disable the timer. * Disable the timer.
*/ */
public stopTimer(): void { public stopTimer(): void {
if (this._timerID !== undefined) { if (this._timer.isRunning()) {
window.clearInterval(this._timerID); this._timer.stop();
this._timerStopCallback?.(); this._timerStopCallback?.();
} }
this._timerID = undefined;
} }
/** /**
@@ -71,15 +71,15 @@ export class CachedValueController<T> implements ReactiveController {
if (this._timerSeconds > 0) { if (this._timerSeconds > 0) {
this._timerStartCallback?.(); this._timerStartCallback?.();
this._timerID = window.setInterval(() => { this._timer.startRepeated(this._timerSeconds, () => {
this.updateValue(); this.updateValue();
this._host.requestUpdate(); this._host.requestUpdate();
}, this._timerSeconds * 1000); });
} }
} }
public hasTimer(): boolean { 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}` + `/api/frigate/${cameraConfig.frigate.client_id}` +
`/recording/${cameraConfig.frigate.camera_name}` + `/recording/${cameraConfig.frigate.camera_name}` +
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` + `/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` + `/end/${Math.floor(media.getEndTime().getTime() / 1000)}` +
`?download=true`, `?download=true`,
sign: true, sign: true,
}; };
+1 -1
View File
@@ -11,7 +11,7 @@ const dayStringToDate = (arg: unknown): Date | unknown => {
return typeof arg === 'string' ? dayToDate(arg) : arg; return typeof arg === 'string' ? dayToDate(arg) : arg;
}; };
const eventSchema = z.object({ export const eventSchema = z.object({
camera: z.string(), camera: z.string(),
end_time: z.number().nullable(), end_time: z.number().nullable(),
false_positive: z.boolean().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; 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. * Get the camera entity for the current camera configuration.
* @returns The entity or undefined if no camera entity is available. * @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 { import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../../../utils/media'; } from '../../../utils/media';
import { import {
dispatchMediaLoadedEvent, dispatchMediaLoadedEvent,
@@ -271,7 +272,7 @@ export class VideoRTC extends HTMLElement {
*/ */
oninit() { oninit() {
this.video = document.createElement('video'); this.video = document.createElement('video');
this.video.controls = this.controls; setControlsOnVideo(this.video, this.controls);
this.video.playsInline = true; this.video.playsInline = true;
this.video.preload = 'auto'; this.video.preload = 'auto';
@@ -633,7 +634,7 @@ export class VideoRTC extends HTMLElement {
let receivedFirstFrame = false; let receivedFirstFrame = false;
this.ondata = (data) => { this.ondata = (data) => {
this.video.controls = false; setControlsOnVideo(this.video, false);
this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data); this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
if (!receivedFirstFrame) { if (!receivedFirstFrame) {
@@ -672,7 +673,7 @@ export class VideoRTC extends HTMLElement {
context.drawImage(video2, 0, 0, canvas.width, canvas.height); context.drawImage(video2, 0, 0, canvas.width, canvas.height);
this.video.controls = false; setControlsOnVideo(this.video, false);
this.video.poster = canvas.toDataURL('image/jpeg'); this.video.poster = canvas.toDataURL('image/jpeg');
}); });
+7 -1
View File
@@ -17,6 +17,8 @@ import {
MicrophoneConfig, MicrophoneConfig,
} from '../../types.js'; } from '../../types.js';
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint';
import { setControlsOnVideo } from '../../utils/media.js';
import { screenshotMedia } from '../../utils/screenshot.js';
import '../image.js'; import '../image.js';
import { dispatchErrorMessageEvent } from '../message'; import { dispatchErrorMessageEvent } from '../message';
import { VideoRTC } from './go2rtc/video-rtc'; import { VideoRTC } from './go2rtc/video-rtc';
@@ -84,7 +86,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
public async setControls(controls?: boolean): Promise<void> { public async setControls(controls?: boolean): Promise<void> {
if (this._player?.video) { 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; return this._player?.video.paused ?? true;
} }
public async getScreenshotURL(): Promise<string | null> {
return this._player ? screenshotMedia(this._player.video) : null;
}
disconnectedCallback(): void { disconnectedCallback(): void {
this._player = undefined; this._player = undefined;
} }
+4
View File
@@ -54,6 +54,10 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
return this._playerRef.value?.isPaused() ?? true; return this._playerRef.value?.isPaused() ?? true;
} }
public async getScreenshotURL(): Promise<string | null> {
return await this._playerRef.value?.getScreenshotURL() ?? null;
}
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass) { if (!this.hass) {
return; return;
+4
View File
@@ -49,6 +49,10 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
return this._refImage.value?.isPaused() ?? true; return this._refImage.value?.isPaused() ?? true;
} }
public async getScreenshotURL(): Promise<string | null> {
return await this._refImage.value?.getScreenshotURL() ?? null;
}
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.cameraConfig) { if (!this.hass || !this.cameraConfig) {
return; return;
+14 -8
View File
@@ -19,6 +19,7 @@ import {
dispatchMediaPlayEvent, dispatchMediaPlayEvent,
} from '../../utils/media-info.js'; } from '../../utils/media-info.js';
import { dispatchErrorMessageEvent } from '../message.js'; import { dispatchErrorMessageEvent } from '../message.js';
import { Timer } from '../../utils/timer.js';
// Number of seconds a signed URL is valid for. // Number of seconds a signed URL is valid for.
const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
@@ -41,7 +42,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
protected _jsmpegCanvasElement?: HTMLCanvasElement; protected _jsmpegCanvasElement?: HTMLCanvasElement;
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement; protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
protected _refreshPlayerTimerID?: number; protected _refreshPlayerTimer = new Timer();
public async play(): Promise<void> { public async play(): Promise<void> {
return this._jsmpegVideoPlayer?.play(); return this._jsmpegVideoPlayer?.play();
@@ -83,6 +84,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
return this._jsmpegVideoPlayer?.player?.paused ?? true; return this._jsmpegVideoPlayer?.player?.paused ?? true;
} }
public async getScreenshotURL(): Promise<string | null> {
return this._jsmpegCanvasElement?.toDataURL('image/jpeg') ?? null;
}
/** /**
* Create a JSMPEG player. * Create a JSMPEG player.
* @param url The URL for the player to connect to. * @param url The URL for the player to connect to.
@@ -106,6 +111,9 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
audio: false, audio: false,
videoBufferSize: 1024 * 1024 * 4, videoBufferSize: 1024 * 1024 * 4,
// Necessary for screenshots.
preserveDrawingBuffer: true,
// Override with user-specified options. // Override with user-specified options.
...this.cameraConfig?.jsmpeg?.options, ...this.cameraConfig?.jsmpeg?.options,
@@ -145,10 +153,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
* Reset / destroy the player. * Reset / destroy the player.
*/ */
protected _resetPlayer(): void { protected _resetPlayer(): void {
if (this._refreshPlayerTimerID) { this._refreshPlayerTimer.stop();
window.clearTimeout(this._refreshPlayerTimerID);
this._refreshPlayerTimerID = undefined;
}
if (this._jsmpegVideoPlayer) { if (this._jsmpegVideoPlayer) {
try { try {
this._jsmpegVideoPlayer.destroy(); this._jsmpegVideoPlayer.destroy();
@@ -213,9 +218,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
} }
await this._createJSMPEGPlayer(address); await this._createJSMPEGPlayer(address);
this._refreshPlayerTimerID = window.setTimeout(() => { this._refreshPlayerTimer.start(
this.requestUpdate(); JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS,
}, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); () => this.requestUpdate(),
);
} }
/** /**
+13 -6
View File
@@ -2,6 +2,7 @@ import { Task } from '@lit-labs/task';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { CameraEndpoints } from '../../camera-manager/types.js';
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss'; import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss';
import { import {
@@ -10,20 +11,21 @@ import {
FrigateCardError, FrigateCardError,
FrigateCardMediaPlayer, FrigateCardMediaPlayer,
} from '../../types.js'; } from '../../types.js';
import { mayHaveAudio } from '../../utils/audio.js';
import { import {
dispatchMediaLoadedEvent, dispatchMediaLoadedEvent,
dispatchMediaPauseEvent, dispatchMediaPauseEvent,
dispatchMediaPlayEvent, dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent, dispatchMediaVolumeChangeEvent,
} from '../../utils/media-info.js'; } from '../../utils/media-info.js';
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
import { renderTask } from '../../utils/task.js';
import { import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../../utils/media.js'; } from '../../utils/media.js';
import { CameraEndpoints } from '../../camera-manager/types.js'; import { screenshotMedia } from '../../utils/screenshot.js';
import { mayHaveAudio } from '../../utils/audio.js'; import { renderTask } from '../../utils/task.js';
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
// Create a wrapper for AlexxIT's WebRTC card // Create a wrapper for AlexxIT's WebRTC card
// - https://github.com/AlexxIT/WebRTC // - https://github.com/AlexxIT/WebRTC
@@ -85,7 +87,7 @@ export class FrigateCardLiveWebRTCCard
public async setControls(controls?: boolean): Promise<void> { public async setControls(controls?: boolean): Promise<void> {
const player = this._getPlayer(); const player = this._getPlayer();
if (player) { if (player) {
player.controls = controls ?? this.controls; setControlsOnVideo(player, controls ?? this.controls);
} }
} }
@@ -93,6 +95,11 @@ export class FrigateCardLiveWebRTCCard
return this._getPlayer()?.paused ?? true; return this._getPlayer()?.paused ?? true;
} }
public async getScreenshotURL(): Promise<string | null> {
const video = this._getPlayer();
return video ? screenshotMedia(video) : null;
}
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
@@ -191,6 +198,7 @@ export class FrigateCardLiveWebRTCCard
this.updateComplete.then(() => { this.updateComplete.then(() => {
const video = this._getPlayer(); const video = this._getPlayer();
if (video) { if (video) {
setControlsOnVideo(video, this.controls);
video.onloadeddata = () => { video.onloadeddata = () => {
if (this.controls) { if (this.controls) {
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
@@ -206,7 +214,6 @@ export class FrigateCardLiveWebRTCCard
video.onplay = () => dispatchMediaPlayEvent(this); video.onplay = () => dispatchMediaPlayEvent(this);
video.onpause = () => dispatchMediaPauseEvent(this); video.onpause = () => dispatchMediaPauseEvent(this);
video.onvolumechange = () => dispatchMediaVolumeChangeEvent(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> { public async pause(): Promise<void> {
await this.updateComplete; await this.updateComplete;
await this._refProvider.value?.updateComplete; await this._refProvider.value?.updateComplete;
this._refProvider.value?.pause(); await this._refProvider.value?.pause();
} }
public async mute(): Promise<void> { public async mute(): Promise<void> {
await this.updateComplete; await this.updateComplete;
await this._refProvider.value?.updateComplete; await this._refProvider.value?.updateComplete;
this._refProvider.value?.mute(); await this._refProvider.value?.mute();
} }
public async unmute(): Promise<void> { public async unmute(): Promise<void> {
await this.updateComplete; await this.updateComplete;
await this._refProvider.value?.updateComplete; await this._refProvider.value?.updateComplete;
this._refProvider.value?.unmute(); await this._refProvider.value?.unmute();
} }
public isMuted(): boolean { public isMuted(): boolean {
@@ -778,19 +778,25 @@ export class FrigateCardLiveProvider
public async seek(seconds: number): Promise<void> { public async seek(seconds: number): Promise<void> {
await this.updateComplete; await this.updateComplete;
await this._refProvider.value?.updateComplete; await this._refProvider.value?.updateComplete;
this._refProvider.value?.seek(seconds); await this._refProvider.value?.seek(seconds);
} }
public async setControls(controls?: boolean): Promise<void> { public async setControls(controls?: boolean): Promise<void> {
await this.updateComplete; await this.updateComplete;
await this._refProvider.value?.updateComplete; await this._refProvider.value?.updateComplete;
this._refProvider.value?.setControls(controls); await this._refProvider.value?.setControls(controls);
} }
public isPaused(): boolean { public isPaused(): boolean {
return this._refProvider.value?.isPaused() ?? true; 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. * Get the fully resolved live provider.
* @returns A live provider (that is not 'auto'). * @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 { FrigateCardNextPreviousControl } from './next-prev-control.js';
import { FrigateCardTitleControl } from './title-control.js'; import { FrigateCardTitleControl } from './title-control.js';
import debounce from 'lodash-es/debounce'; import debounce from 'lodash-es/debounce';
import { Timer } from '../utils/timer';
interface CarouselMediaLoadedInfo { interface CarouselMediaLoadedInfo {
slide: number; slide: number;
@@ -126,7 +127,7 @@ export class FrigateCardMediaCarousel extends LitElement {
protected _nextControlRef: Ref<FrigateCardNextPreviousControl> = createRef(); protected _nextControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
protected _previousControlRef: Ref<FrigateCardNextPreviousControl> = createRef(); protected _previousControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
protected _titleControlRef: Ref<FrigateCardTitleControl> = createRef(); protected _titleControlRef: Ref<FrigateCardTitleControl> = createRef();
protected _titleTimerID: number | null = null; protected _titleTimer = new Timer();
protected _boundAutoPlayHandler = this.autoPlay.bind(this); protected _boundAutoPlayHandler = this.autoPlay.bind(this);
protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this); protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this);
@@ -231,13 +232,10 @@ export class FrigateCardMediaCarousel extends LitElement {
*/ */
protected _titleHandler(): void { protected _titleHandler(): void {
const show = () => { const show = () => {
this._titleTimerID = null; this._titleTimer.stop();
this._titleControlRef.value?.show(); this._titleControlRef.value?.show();
}; };
if (this._titleTimerID) {
window.clearTimeout(this._titleTimerID);
}
if (this._titleControlRef.value?.isVisible()) { if (this._titleControlRef.value?.isVisible()) {
// If it's already visible, update it immediately (but also update it // If it's already visible, update it immediately (but also update it
// after the timer expires to ensure it re-positions if necessary, see // 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 // Allow a brief pause after the media loads, but before the title is
// displayed. This allows for a pleasant appearance/disappear of the title, // displayed. This allows for a pleasant appearance/disappear of the title,
// and allows for the browser to finish rendering the carousel. // 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 { refreshDynamicStateParameters } from '../utils/ha';
import './submenu.js'; import './submenu.js';
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js'; import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
import { FRIGATE_BUTTON_MENU_ICON } from '../const.js';
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
/** /**
* A menu for the FrigateCard. * A menu for the FrigateCard.
+22 -1
View File
@@ -47,7 +47,9 @@ import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
playMediaMutingIfNecessary, playMediaMutingIfNecessary,
setControlsOnVideo,
} from '../utils/media.js'; } from '../utils/media.js';
import { screenshotMedia } from '../utils/screenshot.js';
import { ViewMediaClassifier } from '../view/media-classifier'; import { ViewMediaClassifier } from '../view/media-classifier';
import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results.js'; import { MediaQueriesResults } from '../view/media-queries-results.js';
@@ -562,6 +564,7 @@ export class FrigateCardViewerProvider
protected _refFrigateCardMediaPlayer: Ref<Element & FrigateCardMediaPlayer> = protected _refFrigateCardMediaPlayer: Ref<Element & FrigateCardMediaPlayer> =
createRef(); createRef();
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef(); protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
protected _refImageProvider: Ref<HTMLImageElement> = createRef();
public async play(): Promise<void> { public async play(): Promise<void> {
await playMediaMutingIfNecessary( await playMediaMutingIfNecessary(
@@ -612,7 +615,10 @@ export class FrigateCardViewerProvider
if (this._refFrigateCardMediaPlayer.value) { if (this._refFrigateCardMediaPlayer.value) {
return this._refFrigateCardMediaPlayer.value.setControls(controls); return this._refFrigateCardMediaPlayer.value.setControls(controls);
} else if (this._refVideoProvider.value) { } 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; 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. * 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` return this._useZoomIfRequired(html`
${ViewMediaClassifier.isVideo(this.media) ${ViewMediaClassifier.isVideo(this.media)
? this.media.getVideoContentType() === VideoContentType.HLS ? this.media.getVideoContentType() === VideoContentType.HLS
@@ -755,6 +774,7 @@ export class FrigateCardViewerProvider
title="${this.media.getTitle() ?? ''}" title="${this.media.getTitle() ?? ''}"
muted muted
playsinline playsinline
crossorigin="anonymous"
?autoplay=${false} ?autoplay=${false}
?controls=${this.viewerConfig.controls.builtin} ?controls=${this.viewerConfig.controls.builtin}
@loadedmetadata=${(ev: Event) => { @loadedmetadata=${(ev: Event) => {
@@ -785,6 +805,7 @@ export class FrigateCardViewerProvider
</video> </video>
` `
: html`<img : html`<img
${ref(this._refImageProvider)}
aria-label="${this.media.getTitle() ?? ''}" aria-label="${this.media.getTitle() ?? ''}"
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}" src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
title="${this.media.getTitle() ?? ''}" title="${this.media.getTitle() ?? ''}"
+2
View File
@@ -274,3 +274,5 @@ export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
// improved rendering performance. // improved rendering performance.
export const MEDIA_CHUNK_SIZE_DEFAULT = 50; export const MEDIA_CHUNK_SIZE_DEFAULT = 50;
export const MEDIA_CHUNK_SIZE_MAX = 1000; 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('play') /* */}
${this._renderMenuButton('mute')} ${this._renderMenuButton('mute')}
${this._renderMenuButton('screenshot')}
</div> </div>
` `
: ''} : ''}
+1
View File
@@ -282,6 +282,7 @@
"play": "Play / Pause", "play": "Play / Pause",
"priority": "Priority", "priority": "Priority",
"recordings": "Recordings", "recordings": "Recordings",
"screenshot": "Screenshot",
"snapshots": "Snapshots", "snapshots": "Snapshots",
"substreams": "Substream(s)", "substreams": "Substream(s)",
"timeline": "Timeline", "timeline": "Timeline",
+1
View File
@@ -279,6 +279,7 @@
"mute": "", "mute": "",
"play": "", "play": "",
"priority": "Priorità", "priority": "Priorità",
"screenshot": "",
"snapshots": "Istantanee", "snapshots": "Istantanee",
"substreams": "Flusso/i secondario/i", "substreams": "Flusso/i secondario/i",
"timeline": "Timeline", "timeline": "Timeline",
+1
View File
@@ -281,6 +281,7 @@
"play": "", "play": "",
"priority": "Prioridade", "priority": "Prioridade",
"recordings": "Gravações", "recordings": "Gravações",
"screenshot": "",
"snapshots": "Instantâneos", "snapshots": "Instantâneos",
"substreams": "Substream(s)", "substreams": "Substream(s)",
"timeline": "Linha do tempo", "timeline": "Linha do tempo",
+1
View File
@@ -272,6 +272,7 @@
"mute": "", "mute": "",
"play": "", "play": "",
"priority": "Prioridade", "priority": "Prioridade",
"screenshot": "",
"snapshots": "Instantâneos", "snapshots": "Instantâneos",
"substreams": "substreams", "substreams": "substreams",
"timeline": "Linha do tempo", "timeline": "Linha do tempo",
+4
View File
@@ -81,6 +81,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
return this._player?.isPaused() ?? true; return this._player?.isPaused() ?? true;
} }
public async getScreenshotURL(): Promise<string | null> {
return this._player ? await this._player.getScreenshotURL() : null;
}
/** /**
* Master render method. * Master render method.
* @returns A rendered template. * @returns A rendered template.
+9 -5
View File
@@ -9,9 +9,10 @@
// available as compilation time. // 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 { customElement } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js'; import { query } from 'lit/decorators/query.js';
import { screenshotMedia } from '../utils/screenshot.js';
import { dispatchErrorMessageEvent } from '../components/message.js'; import { dispatchErrorMessageEvent } from '../components/message.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { FrigateCardMediaPlayer } from '../types.js'; import { FrigateCardMediaPlayer } from '../types.js';
@@ -20,11 +21,10 @@ import {
dispatchMediaLoadedEvent, dispatchMediaLoadedEvent,
dispatchMediaPauseEvent, dispatchMediaPauseEvent,
dispatchMediaPlayEvent, dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent, dispatchMediaVolumeChangeEvent
} from '../utils/media-info.js'; } from '../utils/media-info.js';
import { import {
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, setControlsOnVideo
hideMediaControlsTemporarily,
} from '../utils/media.js'; } from '../utils/media.js';
customElements.whenDefined('ha-hls-player').then(() => { customElements.whenDefined('ha-hls-player').then(() => {
@@ -75,7 +75,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
public async setControls(controls?: boolean): Promise<void> { public async setControls(controls?: boolean): Promise<void> {
if (this._video) { 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; return this._video?.paused ?? true;
} }
public async getScreenshotURL(): Promise<string | null> {
return this._video ? screenshotMedia(this._video) : null;
}
// ===================================================================================== // =====================================================================================
// Minor modifications from: // Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts // - 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 { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js'; import { customElement } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js'; import { query } from 'lit/decorators/query.js';
import { screenshotMedia } from '../utils/screenshot.js';
import { dispatchErrorMessageEvent } from '../components/message.js'; import { dispatchErrorMessageEvent } from '../components/message.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { FrigateCardMediaPlayer } from '../types.js'; import { FrigateCardMediaPlayer } from '../types.js';
@@ -20,11 +21,12 @@ import {
dispatchMediaLoadedEvent, dispatchMediaLoadedEvent,
dispatchMediaPauseEvent, dispatchMediaPauseEvent,
dispatchMediaPlayEvent, dispatchMediaPlayEvent,
dispatchMediaVolumeChangeEvent, dispatchMediaVolumeChangeEvent
} from '../utils/media-info.js'; } from '../utils/media-info.js';
import { import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo
} from '../utils/media.js'; } from '../utils/media.js';
customElements.whenDefined('ha-web-rtc-player').then(() => { 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> { public async setControls(controls?: boolean): Promise<void> {
if (this._video) { 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; return this._video?.paused ?? true;
} }
public async getScreenshotURL(): Promise<string | null> {
return this._video ? screenshotMedia(this._video) : null;
}
// ===================================================================================== // =====================================================================================
// Minor modifications from: // Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts // - 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 = [ const FRIGATE_CARD_GENERAL_ACTIONS = [
'camera_ui', 'camera_ui',
'clip',
'clips',
'default', 'default',
'diagnostics', 'diagnostics',
'expand', 'expand',
'download', 'download',
'fullscreen', 'fullscreen',
'image',
'live',
'menu_toggle', 'menu_toggle',
'mute', 'mute',
'live_substream_on', 'live_substream_on',
@@ -226,14 +222,11 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
'microphone_unmute', 'microphone_unmute',
'play', 'play',
'pause', 'pause',
'recording', 'screenshot',
'recordings',
'snapshot',
'snapshots',
'timeline',
'unmute', 'unmute',
] as const; ] as const;
const FRIGATE_CARD_ACTIONS = [ const FRIGATE_CARD_ACTIONS = [
...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
...FRIGATE_CARD_GENERAL_ACTIONS, ...FRIGATE_CARD_GENERAL_ACTIONS,
'camera_select', 'camera_select',
'live_substream_select', 'live_substream_select',
@@ -241,6 +234,11 @@ const FRIGATE_CARD_ACTIONS = [
] as const; ] as const;
export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number]; 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({ const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend({
frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS), frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS),
}); });
@@ -260,6 +258,7 @@ const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.ex
}); });
export const frigateCardCustomActionSchema = z.union([ export const frigateCardCustomActionSchema = z.union([
frigateCardViewActionSchema,
frigateCardGeneralActionSchema, frigateCardGeneralActionSchema,
frigateCardCameraSelectActionSchema, frigateCardCameraSelectActionSchema,
frigateCardLiveDependencySelectActionSchema, frigateCardLiveDependencySelectActionSchema,
@@ -1078,6 +1077,7 @@ const menuConfigDefault = {
mute: hiddenButtonDefault, mute: hiddenButtonDefault,
play: hiddenButtonDefault, play: hiddenButtonDefault,
recordings: hiddenButtonDefault, recordings: hiddenButtonDefault,
screenshot: hiddenButtonDefault,
}, },
button_size: 40, button_size: 40,
}; };
@@ -1123,6 +1123,7 @@ const menuConfigSchema = z
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute), mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
}) })
.default(menuConfigDefault.buttons), .default(menuConfigDefault.buttons),
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
@@ -1530,6 +1531,7 @@ export interface FrigateCardMediaPlayer {
unmute(): Promise<void>; unmute(): Promise<void>;
isMuted(): boolean; isMuted(): boolean;
seek(seconds: number): Promise<void>; seek(seconds: number): Promise<void>;
getScreenshotURL(): Promise<string | null>;
// If no value for controls if specified, the player should use the default. // If no value for controls if specified, the player should use the default.
setControls(controls?: boolean): Promise<void>; setControls(controls?: boolean): Promise<void>;
isPaused(): boolean; isPaused(): boolean;
+19
View File
@@ -10,6 +10,7 @@ import {
FrigateCardAction, FrigateCardAction,
FrigateCardCustomAction, FrigateCardCustomAction,
frigateCardCustomActionSchema, frigateCardCustomActionSchema,
FrigateCardViewAction,
} from '../types.js'; } from '../types.js';
/** /**
@@ -173,3 +174,21 @@ export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolea
export const stopEventFromActivatingCardWideActions = (ev: Event): void => { export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
ev.stopPropagation(); 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 { errorToConsole } from './basic';
import { homeAssistantSignPath } from './ha'; 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 ( export const downloadMedia = async (
hass: ExtendedHomeAssistant, hass: ExtendedHomeAssistant,
cameraManager: CameraManager, cameraManager: CameraManager,
@@ -30,29 +59,5 @@ export const downloadMedia = async (
finalURL = response; finalURL = response;
} }
// The download attribute only works on the same origin. downloadURL(finalURL);
// 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();
}
}; };
+1 -1
View File
@@ -195,6 +195,6 @@ export function domainIcon(domain: string, entity?: HassEntity, state?: string):
return FIXED_DOMAIN_ICONS[domain]; 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; 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. * Dispatch a pre-existing MediaLoadedInfo object as an event.
* @param element The element to send the event. * @param element The element to send the event.
@@ -93,6 +81,26 @@ export function dispatchExistingMediaLoadedInfoAsEvent(
dispatchFrigateCardEvent<MediaLoadedInfo>(target, 'media:loaded', MediaLoadedInfo); 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. * Determine if a MediaLoadedInfo object is valid/acceptable.
* @param info The MediaLoadedInfo object. * @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 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 { FrigateCardMediaPlayer } from '../types';
import { Timer } from './timer';
// The number of seconds to hide the video controls for after loading (in order // The number of seconds to hide the video controls for after loading (in order
// to give a cleaner UI appearance, see: // to give a cleaner UI appearance, see:
@@ -6,6 +7,27 @@ import { FrigateCardMediaPlayer } from '../types';
export const MEDIA_LOAD_CONTROLS_HIDE_SECONDS = 2; export const MEDIA_LOAD_CONTROLS_HIDE_SECONDS = 2;
const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1; 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. * Temporarily hide media controls.
* @param element Any HTMLElement that has a controls property (e.g. * @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. * @param seconds The number of seconds to hide the controls for.
*/ */
export const hideMediaControlsTemporarily = ( export const hideMediaControlsTemporarily = (
element: HTMLElement & { video: FrigateCardHTMLVideoElement,
controls: boolean;
_controlsHideTimeoutID?: number;
},
seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS, seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS,
): void => { ): void => {
element.controls = false; const oldValue = video.controls;
setControlsOnVideo(video, false);
if (element._controlsHideTimeoutID) { video._controlsHideTimer ??= new Timer();
window.clearTimeout(element._controlsHideTimeoutID); video._controlsHideTimer.start(seconds, () => {
} setControlsOnVideo(video, oldValue);
element._controlsHideTimeoutID = window.setTimeout(() => { });
element.controls = true;
delete element._controlsHideTimeoutID;
}, seconds * 1000);
}; };
/** /**
@@ -42,8 +58,10 @@ export const playMediaMutingIfNecessary = async (
// and then try again. This works around some browsers that prevent // and then try again. This works around some browsers that prevent
// auto-play unless the video is muted. // auto-play unless the video is muted.
if (video?.play) { if (video?.play) {
video.play().catch(async (ev) => { try {
if (ev.name === 'NotAllowedError' && !player.isMuted()) { await video.play();
} catch (err: unknown) {
if ((err as Error).name === 'NotAllowedError' && !player.isMuted()) {
await player.mute(); await player.mute();
try { try {
await video.play(); await video.play();
@@ -51,6 +69,6 @@ export const playMediaMutingIfNecessary = async (
// Pass. // 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 { export class MicrophoneController {
protected _stream?: MediaStream | null; 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 // 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 // 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); 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 { protected _startTimer(): void {
if (this._disconnectSeconds) { if (this._disconnectSeconds) {
this._clearTimer(); this._timer.start(this._disconnectSeconds, () => {
this._timerID = window.setTimeout(() => {
this._clearTimer();
this.disconnect(); 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(); const errors = error.format();
return Object.keys(errors).filter((v) => !v.startsWith('_')); 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, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
import Panzoom from '@dermotduffy/panzoom';
import round from 'lodash-es/round'; import round from 'lodash-es/round';
import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic'; import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic';
@@ -11,6 +10,7 @@ export class Zoom {
protected _element: HTMLElement; protected _element: HTMLElement;
protected _panzoom?: PanzoomObject; protected _panzoom?: PanzoomObject;
protected _zoomed = false; protected _zoomed = false;
protected _allowClick = true;
protected _events = isHoverableDevice() protected _events = isHoverableDevice()
? { ? {
@@ -31,9 +31,23 @@ export class Zoom {
// If we do not prevent default here, the media carousels scroll. // If we do not prevent default here, the media carousels scroll.
ev.preventDefault(); 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) => { protected _moveHandler = (ev: Event) => {
if (this._shouldZoomOrPan(ev)) { if (this._shouldZoomOrPan(ev)) {
this._panzoom?.handleMove(ev as PointerEvent); this._panzoom?.handleMove(ev as PointerEvent);
@@ -64,11 +78,17 @@ export class Zoom {
protected _shouldZoomOrPan(ev: Event): boolean { protected _shouldZoomOrPan(ev: Event): boolean {
return ( return (
!this._isScaleNormal(this._panzoom?.getScale()) || !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) (ev instanceof WheelEvent && ev.ctrlKey)
); );
} }
protected _setTouchAction(touchEnabled: boolean): void {
this._element.style.touchAction = touchEnabled ? '' : 'none';
}
public activate(): void { public activate(): void {
this._panzoom = Panzoom(this._element, { this._panzoom = Panzoom(this._element, {
contain: 'outside', contain: 'outside',
@@ -78,6 +98,11 @@ export class Zoom {
// Do not force the cursor style (by default it will always show the // Do not force the cursor style (by default it will always show the
// 'move' type cursor whether or not it is zoomed in). // 'move' type cursor whether or not it is zoomed in).
cursor: undefined, 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 = ( const registerListeners = (
@@ -94,17 +119,20 @@ export class Zoom {
registerListeners(this._events['move'], this._moveHandler, { capture: true }); registerListeners(this._events['move'], this._moveHandler, { capture: true });
registerListeners(this._events['up'], this._upHandler, { capture: true }); registerListeners(this._events['up'], this._upHandler, { capture: true });
registerListeners(['wheel'], this._wheelHandler); registerListeners(['wheel'], this._wheelHandler);
registerListeners(['click'], this._clickHandler, { capture: true });
this._element.addEventListener('panzoomzoom', (ev: Event) => { this._element.addEventListener('panzoomzoom', (ev: Event) => {
// Take care here to only dispatch the zoomed/unzoomed events when the // Take care here to only dispatch the zoomed/unzoomed events when the
// absolute state changes (rather than on every single zoom adjustment). // absolute state changes (rather than on every single zoom adjustment).
if (this._isScaleNormal((<CustomEvent<PanzoomEventDetail>>ev).detail.scale)) { if (this._isScaleNormal((<CustomEvent<PanzoomEventDetail>>ev).detail.scale)) {
if (this._zoomed) { if (this._zoomed) {
this._setTouchAction(true);
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed'); dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
} }
this._zoomed = false; this._zoomed = false;
} else { } else {
if (!this._zoomed) { if (!this._zoomed) {
this._setTouchAction(false);
dispatchFrigateCardEvent(this._element, 'zoom:zoomed'); dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
} }
this._zoomed = true; 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, getCameraEntityFromConfig,
sortMedia, sortMedia,
} from '../../src/camera-manager/util.js'; } from '../../src/camera-manager/util.js';
import { ViewMedia, ViewMediaType } from '../../src/view/media.js';
import { CameraConfig, cameraConfigSchema } from '../../src/types.js'; import { CameraConfig, cameraConfigSchema } from '../../src/types.js';
import { ViewMedia, ViewMediaType } from '../../src/view/media.js';
import { TestViewMedia } from '../test-utils.js';
describe('convertRangeToCacheFriendlyTimes', () => { describe('convertRangeToCacheFriendlyTimes', () => {
it('should return cache friendly within hour range', () => { 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', () => { describe('sortMedia', () => {
const media_1 = new TestViewMedia( const media_1 = new TestViewMedia(
'id-1', '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 { HassEntities, HassEntity } from 'home-assistant-js-websocket';
import { vi } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { CameraManagerEngineFactory } from '../src/camera-manager/engine-factory';
import { FrigateEvent, FrigateRecording } from '../src/camera-manager/frigate/types'; 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 { import {
CameraConfig, CameraConfig,
ExtendedHomeAssistant,
FrigateCardCondition, FrigateCardCondition,
FrigateCardConfig, FrigateCardConfig,
MediaLoadedInfo,
RawFrigateCardConfig,
cameraConfigSchema, cameraConfigSchema,
frigateCardConditionSchema, frigateCardConditionSchema,
frigateCardConfigSchema, frigateCardConfigSchema,
} from '../src/types'; } from '../src/types';
import { Entity } from '../src/utils/ha/entity-registry/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 => { export const createCameraConfig = (config?: unknown): CameraConfig => {
return cameraConfigSchema.parse(config); return cameraConfigSchema.parse(config ?? {});
}; };
export const createCondition = ( export const createCondition = (
@@ -22,12 +35,16 @@ export const createCondition = (
return frigateCardConditionSchema.parse(condition ?? {}); return frigateCardConditionSchema.parse(condition ?? {});
}; };
export const createConfig = (config?: Partial<FrigateCardConfig>): FrigateCardConfig => { export const createConfig = (config?: RawFrigateCardConfig): FrigateCardConfig => {
return frigateCardConfigSchema.parse(config); return frigateCardConfigSchema.parse({
type: 'frigate-hass-card',
cameras: [],
...config,
});
}; };
export const createHASS = (states?: HassEntities): HomeAssistant => { export const createHASS = (states?: HassEntities): ExtendedHomeAssistant => {
const hass = mock<HomeAssistant>(); const hass = mock<ExtendedHomeAssistant>();
if (states) { if (states) {
hass.states = states; hass.states = states;
} }
@@ -89,3 +106,89 @@ export const createFrigateRecording = (recording?: Partial<FrigateRecording>) =>
...recording, ...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 { handleActionConfig, hasAction } from 'custom-card-helpers';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { actionSchema } from '../../src/types'; import {
actionSchema,
FrigateCardAction,
FrigateCardCustomAction,
frigateCardCustomActionSchema,
} from '../../src/types';
import { import {
convertActionToFrigateCardCustomAction, convertActionToFrigateCardCustomAction,
createFrigateCardCustomAction, createFrigateCardCustomAction,
frigateCardHandleAction,
frigateCardHandleActionConfig, frigateCardHandleActionConfig,
frigateCardHasAction, frigateCardHasAction,
getActionConfigGivenAction, getActionConfigGivenAction,
isViewAction,
stopEventFromActivatingCardWideActions, stopEventFromActivatingCardWideActions,
} from '../../src/utils/action'; } from '../../src/utils/action';
import { createHASS } from '../test-utils'; 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', () => { describe('frigateCardHasAction', () => {
const action = actionSchema.parse({ const action = actionSchema.parse({
action: 'toggle', action: 'toggle',
@@ -200,3 +224,45 @@ describe('stopEventFromActivatingCardWideActions', () => {
expect(event.stopPropagation).toBeCalled(); 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 { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
import { CameraManager } from '../../src/camera-manager/manager.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 { CameraConfigs } from '../../src/camera-manager/types.js';
import { getAllDependentCameras, getCameraID } from '../../src/utils/camera.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'); vi.mock('../../src/camera-manager/manager.js');
@@ -54,11 +52,7 @@ describe('getAllDependentCameras', () => {
['two', createCameraConfig({})], ['two', createCameraConfig({})],
]); ]);
const cameraManager = new CameraManager(mock<CameraManagerEngineFactory>(), {}); const cameraManager = createCameraManager({ configs: cameraConfigs });
const store = mock<CameraManagerStore>();
vi.mocked(cameraManager.getStore).mockReturnValue(store);
store.getCameras.mockReturnValue(cameraConfigs);
expect(getAllDependentCameras(cameraManager, 'one')).toEqual( expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
new Set(['one', 'two']), new Set(['one', 'two']),
); );
@@ -76,11 +70,7 @@ describe('getAllDependentCameras', () => {
['two', createCameraConfig({})], ['two', createCameraConfig({})],
]); ]);
const cameraManager = new CameraManager(mock<CameraManagerEngineFactory>(), {}); const cameraManager = createCameraManager({ configs: cameraConfigs });
const store = mock<CameraManagerStore>();
vi.mocked(cameraManager.getStore).mockReturnValue(store);
store.getCameras.mockReturnValue(cameraConfigs);
expect(getAllDependentCameras(cameraManager, 'one')).toEqual( expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
new Set(['one', 'two']), new Set(['one', 'two']),
); );
+1 -1
View File
@@ -4,7 +4,7 @@ import { log } from '../../src/utils/debug.js';
describe('log', () => { describe('log', () => {
const spy = vi.spyOn(global.console, 'debug').mockReturnValue(undefined); const spy = vi.spyOn(global.console, 'debug').mockReturnValue(undefined);
afterAll(() => { afterAll(() => {
vi.resetAllMocks(); vi.restoreAllMocks();
}); });
it('should do nothing without debug logging set', () => { it('should do nothing without debug logging set', () => {
log({}, 'foo'); 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 { 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 { getAllDependentCameras } from '../../src/utils/camera';
import { import {
createViewWithNextStream, createViewWithNextStream,
@@ -9,7 +7,9 @@ import {
hasSubstream, hasSubstream,
} from '../../src/utils/substream'; } from '../../src/utils/substream';
import { View } from '../../src/view/view'; import { View } from '../../src/view/view';
import { createCameraManager } from '../test-utils';
vi.mock('../../src/camera-manager/manager.js');
vi.mock('../../src/utils/camera'); vi.mock('../../src/utils/camera');
describe('createViewWithSelectedSubstream', () => { describe('createViewWithSelectedSubstream', () => {
@@ -95,7 +95,7 @@ describe('createViewWithNextStream', () => {
camera: 'camera', camera: 'camera',
}); });
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera'])); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
const cameraManager = mock<CameraManager>(); const cameraManager = createCameraManager()
const newView = createViewWithNextStream(cameraManager, view); const newView = createViewWithNextStream(cameraManager, view);
expect(newView.camera).toBe(view.camera); expect(newView.camera).toBe(view.camera);
expect(newView.view).toBe(view.view); expect(newView.view).toBe(view.view);
@@ -107,7 +107,7 @@ describe('createViewWithNextStream', () => {
camera: 'camera', camera: 'camera',
}); });
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
const cameraManager = mock<CameraManager>(); const cameraManager = createCameraManager()
const newView = createViewWithNextStream(cameraManager, view); const newView = createViewWithNextStream(cameraManager, view);
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']])); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
}); });
@@ -122,7 +122,7 @@ describe('createViewWithNextStream', () => {
}, },
}); });
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
const cameraManager = mock<CameraManager>(); const cameraManager = createCameraManager()
const newView = createViewWithNextStream(cameraManager, view); const newView = createViewWithNextStream(cameraManager, view);
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']])); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
}); });
@@ -137,7 +137,7 @@ describe('createViewWithNextStream', () => {
}, },
}); });
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
const cameraManager = mock<CameraManager>(); const cameraManager = createCameraManager()
const newView = createViewWithNextStream(cameraManager, view); const newView = createViewWithNextStream(cameraManager, view);
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']])); 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); 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', () => { it('deactivate should remove event handlers', () => {
const element = document.createElement('div'); const element = document.createElement('div');
@@ -150,8 +192,7 @@ describe('Zoom', () => {
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc); element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc); element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
const panzoom = createMockPanZoom(); vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
createAndRegisterZoom(element); createAndRegisterZoom(element);
@@ -180,4 +221,35 @@ describe('Zoom', () => {
element.dispatchEvent(ev_2); element.dispatchEvent(ev_2);
expect(unzoomedFunc).toBeCalled(); 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 { QueryType } from '../../src/camera-manager/types';
import { ViewMedia } from '../../src/view/media'; import { ViewMedia } from '../../src/view/media';
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { MediaQueriesResults } from '../../src/view/media-queries-results';
import { import { View, dispatchViewContextChangeEvent } from '../../src/view/view';
View, import { createView } from '../test-utils';
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',
});
};
// @vitest-environment jsdom // @vitest-environment jsdom
describe('View Basics', () => { describe('View Basics', () => {
+5 -5
View File
@@ -636,14 +636,14 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@graphiteds/core@npm:^1.9.6": "@graphiteds/core@npm:^1.9.11":
version: 1.9.7 version: 1.9.11
resolution: "@graphiteds/core@npm:1.9.7" resolution: "@graphiteds/core@npm:1.9.11"
dependencies: dependencies:
"@duetds/date-picker": ^1.4.0 "@duetds/date-picker": ^1.4.0
"@popperjs/core": ^2.11.5 "@popperjs/core": ^2.11.5
"@stencil/core": ^2.20.0 "@stencil/core": ^2.20.0
checksum: d9fa01a5e63fa4b26010893f85324ddfc1961c08ba1a934da5371941d648582e722a87f5af682084be24f28aa5b3aec3bda6a8ff8759b2f7246e5db13f528391 checksum: de67030f51fb165563bd73dc55a1294063f40e6de1bd55f864ea9c627069da81fbdb171de7908ae34a8c3f8d0505fd9414762bd695a0e449304d441867bc59cc
languageName: node languageName: node
linkType: hard linkType: hard
@@ -3041,7 +3041,7 @@ __metadata:
"@cycjimmy/jsmpeg-player": ^6.0.4 "@cycjimmy/jsmpeg-player": ^6.0.4
"@dermotduffy/panzoom": ^4.5.1 "@dermotduffy/panzoom": ^4.5.1
"@egjs/hammerjs": ^2.0.17 "@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/scoped-registry-mixin": ^1.0.1
"@lit-labs/task": ^1.1.3 "@lit-labs/task": ^1.1.3
"@rollup/plugin-babel": ^5.3.1 "@rollup/plugin-babel": ^5.3.1