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:
@@ -31,7 +31,7 @@ jobs:
|
||||
filename: frigate-hass-card.zip
|
||||
|
||||
- name: Upload JS files to release
|
||||
uses: svenstaro/upload-release-action@2.5.0
|
||||
uses: svenstaro/upload-release-action@2.6.1
|
||||
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
- name: Upload Zip file to release
|
||||
uses: svenstaro/upload-release-action@2.5.0
|
||||
uses: svenstaro/upload-release-action@2.6.1
|
||||
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -433,6 +433,7 @@ menu:
|
||||
| `camera_ui` | :white_check_mark: | The `camera_ui` menu button: brings the user to a context-appropriate page on the UI of their camera engine (e.g. the Frigate camera homepage). Will only appear if the camera engine supports a camera UI (e.g. if `frigate.url` option is set for `frigate` engine users).|
|
||||
| `fullscreen` | :white_check_mark: | The `fullscreen` menu button: expand the card to consume the fullscreen. |
|
||||
| `expand` | :white_check_mark: | The `expand` menu button: expand the card into a popup/dialog. |
|
||||
| `screenshot` | :white_check_mark: | The `screenshot` menu button: take a screenshot of the loaded media (e.g. a still from a video). |
|
||||
| `timeline` | :white_check_mark: | The `timeline` menu button: show the event timeline. |
|
||||
| `media_player` | :white_check_mark: | The `media_player` menu button: sends the visible media to a remote media player. Supports Frigate clips, snapshots and live camera (only for cameras that specify a `camera_entity` and only using the default HA stream (equivalent to the `ha` live provider). `jsmpeg` or `webrtc-card` are not supported, although live can still be played as long as `camera_entity` is specified. In the player list, a `tap` will send the media to the player, a `hold` will stop the media on the player. |
|
||||
| `microphone` | :white_check_mark: | The `microphone` button allows usage of 2-way audio in certain configurations. See [Using 2-way audio](#using-2-way-audio). |
|
||||
@@ -1291,7 +1292,7 @@ Parameters for the `custom:frigate-card-ptz` element:
|
||||
| Parameter | Description |
|
||||
| - | - |
|
||||
| `action` | Must be `custom:frigate-card-action`. |
|
||||
| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_on`, `live_substream_off`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`, `mute`, `unmute`, `play`, `pause`|
|
||||
| `frigate_card_action` | Call a Frigate Card action. Acceptable values are `default`, `clip`, `clips`, `image`, `live`, `recording`, `recordings`, `snapshot`, `snapshots`, `download`, `timeline`, `camera_ui`, `fullscreen`, `camera_select`, `menu_toggle`, `media_player`, `live_substream_on`, `live_substream_off`, `live_substream_select`, `expand`, `microphone_mute`, `microphone_unmute`, `mute`, `unmute`, `play`, `pause`, `screenshot`|
|
||||
|
||||
<a name="custom-actions"></a>
|
||||
|
||||
@@ -1312,6 +1313,7 @@ Parameters for the `custom:frigate-card-ptz` element:
|
||||
|`microphone_mute`, `microphone_unmute`| Mute or unmute the microphone. See [Using 2-way audio](#using-2-way-audio). |
|
||||
|`mute`, `unmute`| Mute or unmute the loaded media. |
|
||||
|`play`, `pause`| Play or pause the loaded media. |
|
||||
|`screenshot`| Take a screenshot of the loaded media (e.g. a still from a video). |
|
||||
|
||||
<a name="views"></a>
|
||||
|
||||
@@ -1584,6 +1586,10 @@ Pan around a large camera view to only show part of the video feed in the card a
|
||||
|
||||
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/zoom.gif" alt="Zoom Support" width="400px">
|
||||
|
||||
### Taking card actions via the URL
|
||||
|
||||
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/navigate-picture-elements.gif" alt="Taking card actions via the URL" width="400px">
|
||||
|
||||
## Examples
|
||||
|
||||
### Illustrative Expanded Configuration Reference
|
||||
@@ -2422,6 +2428,12 @@ elements:
|
||||
frigate_card_action: media_player
|
||||
media_player: media_player.nesthub
|
||||
media_player_action: stop
|
||||
- type: custom:frigate-card-menu-icon
|
||||
icon: mdi:alpha-o-circle
|
||||
title: Screenshot
|
||||
tap_action:
|
||||
action: custom:frigate-card-action
|
||||
frigate_card_action: screenshot
|
||||
```
|
||||
</details>
|
||||
|
||||
@@ -3745,6 +3757,47 @@ https://ha.mydomain.org/lovelace-test/0?frigate-card-action:main:clips
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Expand: Choosing the camera from a separate picture elements card</summary>
|
||||
|
||||
In this example, the card will select a given camera when the user navigates from a *separate* Picture Elements card:
|
||||
|
||||
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/navigate-picture-elements.gif" alt="Taking card actions via the URL" width="400px">
|
||||
|
||||
Frigate Card configuration:
|
||||
|
||||
```yaml
|
||||
type: custom:frigate-card
|
||||
cameras:
|
||||
- camera_entity: camera.living_room
|
||||
- camera_entity: camera.landing
|
||||
```
|
||||
|
||||
Picture Elements configuration (assumes the dashboard is `/lovelace-frigate/map`):
|
||||
|
||||
```yaml
|
||||
type: picture-elements
|
||||
image: https://demo.home-assistant.io/stub_config/floorplan.png
|
||||
elements:
|
||||
- type: icon
|
||||
icon: mdi:cctv
|
||||
style:
|
||||
top: 22%
|
||||
left: 30%
|
||||
tap_action:
|
||||
action: navigate
|
||||
navigation_path: /lovelace-frigate/map?frigate-card-action:camera_select=camera.living_room
|
||||
- type: icon
|
||||
icon: mdi:cctv
|
||||
style:
|
||||
top: 71%
|
||||
left: 42%
|
||||
tap_action:
|
||||
action: navigate
|
||||
navigation_path: /lovelace-frigate/map?frigate-card-action:camera_select=camera.landing
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Automation actions
|
||||
|
||||
@@ -3836,13 +3889,20 @@ view:
|
||||
|
||||
It is possible to pass the Frigate card one or more actions from the URL (e.g. select a particular camera, open the live view in expanded mode, etc).
|
||||
|
||||
To send an action to *all* Frigate cards on a dashboard:
|
||||
The Frigate card will execute these actions in the following circumstances:
|
||||
|
||||
* On initial card load.
|
||||
* On 'tab' change in a dashboard.
|
||||
* When a `navigate` [action](https://www.home-assistant.io/dashboards/actions/) is called on the dashboard (e.g. a button click requests navigation).
|
||||
* When the user uses the `back` / `forward` browser buttons whilst viewing a dashboard.
|
||||
|
||||
To send an action to *all* Frigate cards:
|
||||
|
||||
```
|
||||
[PATH_TO_YOUR_HA_DASHBOARD]?frigate-card-action:[ACTION]=[VALUE]
|
||||
```
|
||||
|
||||
To send an action to a named Frigate card on the dashboard:
|
||||
To send an action to a named Frigate card:
|
||||
|
||||
```
|
||||
[PATH_TO_YOUR_HA_DASHBOARD]?frigate-card-action:[CARD_ID]:[ACTION]=[VALUE]
|
||||
@@ -3854,6 +3914,10 @@ To send an action to a named Frigate card on the dashboard:
|
||||
| `CARD_ID` | When specified only cards that have a `card_id` parameter will act. |
|
||||
| `VALUE` | An optional value to use with the `camera_select` and `live_substream_select` actions. |
|
||||
|
||||
**Note**: If a dashboard has multiple Frigate cards on it, even if they are on
|
||||
different 'tabs' within that dashboard, they will all respond to the actions
|
||||
unless the action is targeted with a `CARD_ID` as shown above.
|
||||
|
||||
#### Actions
|
||||
|
||||
| Action | Supported in query string | Explanation |
|
||||
@@ -3876,6 +3940,7 @@ To send an action to a named Frigate card on the dashboard:
|
||||
| `play`, `pause` | :heavy_multiplication_x: | |
|
||||
| `recording` | :white_check_mark: | |
|
||||
| `recordings` | :white_check_mark: | |
|
||||
| `screenshot`| :heavy_multiplication_x: | Latest media information is not available on initial render. |
|
||||
| `snapshot` | :white_check_mark: | |
|
||||
| `snapshots` | :white_check_mark: | |
|
||||
|
||||
@@ -3940,6 +4005,21 @@ live:
|
||||
mode: none
|
||||
```
|
||||
|
||||
### Title "Popups" are annoying / continually popping up
|
||||
|
||||
Title popups can be disabled for live or media viewer views with this configuration:
|
||||
|
||||
```yaml
|
||||
live:
|
||||
controls:
|
||||
title:
|
||||
mode: none
|
||||
media_viewer:
|
||||
controls:
|
||||
title:
|
||||
mode: none
|
||||
```
|
||||
|
||||
### Microphone / 2-way audio doesn't work
|
||||
|
||||
There are many requirements for 2-way audio to work. See [Using 2-way
|
||||
@@ -4126,3 +4206,8 @@ The Home Assistant container will get preconfigured during first initialization,
|
||||
1. Use the same version number for the release title and tag.
|
||||
1. Choose 'This is a pre-release' for a beta version.
|
||||
1. Hit 'Publish release'.
|
||||
|
||||
### Translations
|
||||
[](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
@@ -1,28 +1,17 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @type { import("@inlang/core/config").DefineConfig }
|
||||
*/
|
||||
export async function defineConfig(env) {
|
||||
const plugin = await env.$import(
|
||||
"https://cdn.jsdelivr.net/gh/samuelstroschein/inlang-plugin-json@1/dist/index.js"
|
||||
const { default: pluginJson } = await env.$import(
|
||||
"https://cdn.jsdelivr.net/gh/samuelstroschein/inlang-plugin-json@2/dist/index.js"
|
||||
);
|
||||
|
||||
const { standardLintRules } = await env.$import(
|
||||
"https://cdn.jsdelivr.net/gh/inlang/standard-lint-rules@1/dist/index.js"
|
||||
const { default: standardLintRules } = await env.$import(
|
||||
"https://cdn.jsdelivr.net/gh/inlang/standard-lint-rules@2/dist/index.js"
|
||||
);
|
||||
|
||||
const pluginConfig = {
|
||||
pathPattern: "./src/localize/languages/{language}.json",
|
||||
};
|
||||
|
||||
return {
|
||||
referenceLanguage: "en",
|
||||
languages: await plugin.getLanguages({ ...env, pluginConfig }),
|
||||
readResources: (args) => plugin.readResources({ ...args, ...env, pluginConfig }),
|
||||
writeResources: (args) => plugin.writeResources({ ...args, ...env, pluginConfig }),
|
||||
lint: {
|
||||
rules: [standardLintRules()],
|
||||
},
|
||||
referenceLanguage: 'en',
|
||||
plugins: [pluginJson({
|
||||
pathPattern: "./src/localize/languages/{language}.json",
|
||||
variableReferencePattern: ["{", "}"],
|
||||
}), standardLintRules()]
|
||||
};
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frigate-hass-card",
|
||||
"version": "5.1.1",
|
||||
"version": "5.2.0",
|
||||
"description": "Frigate Lovelace Card for Home Assistant",
|
||||
"keywords": [
|
||||
"frigate",
|
||||
@@ -18,7 +18,7 @@
|
||||
"@cycjimmy/jsmpeg-player": "^6.0.4",
|
||||
"@dermotduffy/panzoom": "^4.5.1",
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
"@graphiteds/core": "^1.9.6",
|
||||
"@graphiteds/core": "^1.9.11",
|
||||
"@lit-labs/scoped-registry-mixin": "^1.0.1",
|
||||
"@lit-labs/task": "^1.1.3",
|
||||
"@types/bluebird": "^3.5.36",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DirectiveParameters,
|
||||
} from 'lit/directive.js';
|
||||
import { stopEventFromActivatingCardWideActions } from './utils/action.js';
|
||||
import { Timer } from './utils/timer.js';
|
||||
|
||||
interface ActionHandler extends HTMLElement {
|
||||
holdTime: number;
|
||||
@@ -25,14 +26,13 @@ interface FrigateCardActionHandlerOptions extends ActionHandlerOptions {
|
||||
}
|
||||
|
||||
class ActionHandler extends HTMLElement implements ActionHandler {
|
||||
public holdTime = 400;
|
||||
public holdTime = 0.4;
|
||||
|
||||
protected timer?: number;
|
||||
protected holdTimer = new Timer();
|
||||
protected doubleClickTimer = new Timer();
|
||||
|
||||
protected held = false;
|
||||
|
||||
private dblClickTimeout?: number;
|
||||
|
||||
public connectedCallback(): void {
|
||||
[
|
||||
'touchcancel',
|
||||
@@ -46,10 +46,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
||||
document.addEventListener(
|
||||
ev,
|
||||
() => {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
this.holdTimer.stop();
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
@@ -79,9 +76,9 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
||||
|
||||
const start = (): void => {
|
||||
this.held = false;
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.holdTimer.start(this.holdTime, () => {
|
||||
this.held = true;
|
||||
}, this.holdTime);
|
||||
});
|
||||
|
||||
fireEvent(element, 'action', { action: 'start_tap' });
|
||||
};
|
||||
@@ -103,8 +100,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.timer);
|
||||
this.timer = undefined;
|
||||
this.holdTimer.stop();
|
||||
|
||||
fireEvent(element, 'action', { action: 'end_tap' });
|
||||
|
||||
@@ -113,15 +109,13 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
||||
} else if (options?.hasDoubleClick) {
|
||||
if (
|
||||
(ev.type === 'click' && (ev as MouseEvent).detail < 2) ||
|
||||
!this.dblClickTimeout
|
||||
!this.doubleClickTimer.isRunning()
|
||||
) {
|
||||
this.dblClickTimeout = window.setTimeout(() => {
|
||||
this.dblClickTimeout = undefined;
|
||||
fireEvent(element, 'action', { action: 'tap' });
|
||||
}, 250);
|
||||
this.doubleClickTimer.start(0.25, () =>
|
||||
fireEvent(element, 'action', { action: 'tap' }),
|
||||
);
|
||||
} else {
|
||||
clearTimeout(this.dblClickTimeout);
|
||||
this.dblClickTimeout = undefined;
|
||||
this.doubleClickTimer.stop();
|
||||
fireEvent(element, 'action', { action: 'double_tap' });
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import { Timer } from './utils/timer';
|
||||
|
||||
export class CachedValueController<T> implements ReactiveController {
|
||||
protected _value?: T;
|
||||
@@ -7,7 +8,7 @@ export class CachedValueController<T> implements ReactiveController {
|
||||
protected _callback: () => T;
|
||||
protected _timerStartCallback?: () => void;
|
||||
protected _timerStopCallback?: () => void;
|
||||
protected _timerID?: number;
|
||||
protected _timer = new Timer();
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost,
|
||||
@@ -56,11 +57,10 @@ export class CachedValueController<T> implements ReactiveController {
|
||||
* Disable the timer.
|
||||
*/
|
||||
public stopTimer(): void {
|
||||
if (this._timerID !== undefined) {
|
||||
window.clearInterval(this._timerID);
|
||||
if (this._timer.isRunning()) {
|
||||
this._timer.stop();
|
||||
this._timerStopCallback?.();
|
||||
}
|
||||
this._timerID = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,15 +71,15 @@ export class CachedValueController<T> implements ReactiveController {
|
||||
|
||||
if (this._timerSeconds > 0) {
|
||||
this._timerStartCallback?.();
|
||||
this._timerID = window.setInterval(() => {
|
||||
this._timer.startRepeated(this._timerSeconds, () => {
|
||||
this.updateValue();
|
||||
this._host.requestUpdate();
|
||||
}, this._timerSeconds * 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public hasTimer(): boolean {
|
||||
return !!this._timerID;
|
||||
return this._timer.isRunning();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -325,7 +325,7 @@ export class FrigateCameraManagerEngine
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/recording/${cameraConfig.frigate.camera_name}` +
|
||||
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
|
||||
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
|
||||
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}` +
|
||||
`?download=true`,
|
||||
sign: true,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ const dayStringToDate = (arg: unknown): Date | unknown => {
|
||||
return typeof arg === 'string' ? dayToDate(arg) : arg;
|
||||
};
|
||||
|
||||
const eventSchema = z.object({
|
||||
export const eventSchema = z.object({
|
||||
camera: z.string(),
|
||||
end_time: z.number().nullable(),
|
||||
false_positive: z.boolean().nullable(),
|
||||
|
||||
+151
-608
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
||||
return !this._cachedValueController?.hasTimer() ?? true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
return this._cachedValueController?.value ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the camera entity for the current camera configuration.
|
||||
* @returns The entity or undefined if no camera entity is available.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mayHaveAudio } from '../../../utils/audio';
|
||||
import {
|
||||
hideMediaControlsTemporarily,
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
setControlsOnVideo,
|
||||
} from '../../../utils/media';
|
||||
import {
|
||||
dispatchMediaLoadedEvent,
|
||||
@@ -271,7 +272,7 @@ export class VideoRTC extends HTMLElement {
|
||||
*/
|
||||
oninit() {
|
||||
this.video = document.createElement('video');
|
||||
this.video.controls = this.controls;
|
||||
setControlsOnVideo(this.video, this.controls);
|
||||
this.video.playsInline = true;
|
||||
this.video.preload = 'auto';
|
||||
|
||||
@@ -633,7 +634,7 @@ export class VideoRTC extends HTMLElement {
|
||||
let receivedFirstFrame = false;
|
||||
|
||||
this.ondata = (data) => {
|
||||
this.video.controls = false;
|
||||
setControlsOnVideo(this.video, false);
|
||||
this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
|
||||
|
||||
if (!receivedFirstFrame) {
|
||||
@@ -672,7 +673,7 @@ export class VideoRTC extends HTMLElement {
|
||||
|
||||
context.drawImage(video2, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
this.video.controls = false;
|
||||
setControlsOnVideo(this.video, false);
|
||||
this.video.poster = canvas.toDataURL('image/jpeg');
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
MicrophoneConfig,
|
||||
} from '../../types.js';
|
||||
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint';
|
||||
import { setControlsOnVideo } from '../../utils/media.js';
|
||||
import { screenshotMedia } from '../../utils/screenshot.js';
|
||||
import '../image.js';
|
||||
import { dispatchErrorMessageEvent } from '../message';
|
||||
import { VideoRTC } from './go2rtc/video-rtc';
|
||||
@@ -84,7 +86,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
||||
|
||||
public async setControls(controls?: boolean): Promise<void> {
|
||||
if (this._player?.video) {
|
||||
this._player.video.controls = controls ?? this.controls;
|
||||
setControlsOnVideo(this._player.video, controls ?? this.controls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +94,10 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
||||
return this._player?.video.paused ?? true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
return this._player ? screenshotMedia(this._player.video) : null;
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this._player = undefined;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
|
||||
return this._playerRef.value?.isPaused() ?? true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
return await this._playerRef.value?.getScreenshotURL() ?? null;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass) {
|
||||
return;
|
||||
|
||||
@@ -49,6 +49,10 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
||||
return this._refImage.value?.isPaused() ?? true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
return await this._refImage.value?.getScreenshotURL() ?? null;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.cameraConfig) {
|
||||
return;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
dispatchMediaPlayEvent,
|
||||
} from '../../utils/media-info.js';
|
||||
import { dispatchErrorMessageEvent } from '../message.js';
|
||||
import { Timer } from '../../utils/timer.js';
|
||||
|
||||
// Number of seconds a signed URL is valid for.
|
||||
const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
|
||||
@@ -41,7 +42,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
||||
|
||||
protected _jsmpegCanvasElement?: HTMLCanvasElement;
|
||||
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
|
||||
protected _refreshPlayerTimerID?: number;
|
||||
protected _refreshPlayerTimer = new Timer();
|
||||
|
||||
public async play(): Promise<void> {
|
||||
return this._jsmpegVideoPlayer?.play();
|
||||
@@ -83,6 +84,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
||||
return this._jsmpegVideoPlayer?.player?.paused ?? true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
return this._jsmpegCanvasElement?.toDataURL('image/jpeg') ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a JSMPEG player.
|
||||
* @param url The URL for the player to connect to.
|
||||
@@ -106,6 +111,9 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
||||
audio: false,
|
||||
videoBufferSize: 1024 * 1024 * 4,
|
||||
|
||||
// Necessary for screenshots.
|
||||
preserveDrawingBuffer: true,
|
||||
|
||||
// Override with user-specified options.
|
||||
...this.cameraConfig?.jsmpeg?.options,
|
||||
|
||||
@@ -145,10 +153,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
||||
* Reset / destroy the player.
|
||||
*/
|
||||
protected _resetPlayer(): void {
|
||||
if (this._refreshPlayerTimerID) {
|
||||
window.clearTimeout(this._refreshPlayerTimerID);
|
||||
this._refreshPlayerTimerID = undefined;
|
||||
}
|
||||
this._refreshPlayerTimer.stop();
|
||||
if (this._jsmpegVideoPlayer) {
|
||||
try {
|
||||
this._jsmpegVideoPlayer.destroy();
|
||||
@@ -213,9 +218,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
||||
}
|
||||
|
||||
await this._createJSMPEGPlayer(address);
|
||||
this._refreshPlayerTimerID = window.setTimeout(() => {
|
||||
this.requestUpdate();
|
||||
}, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
|
||||
this._refreshPlayerTimer.start(
|
||||
JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS,
|
||||
() => this.requestUpdate(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Task } from '@lit-labs/task';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { CameraEndpoints } from '../../camera-manager/types.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss';
|
||||
import {
|
||||
@@ -10,20 +11,21 @@ import {
|
||||
FrigateCardError,
|
||||
FrigateCardMediaPlayer,
|
||||
} from '../../types.js';
|
||||
import { mayHaveAudio } from '../../utils/audio.js';
|
||||
import {
|
||||
dispatchMediaLoadedEvent,
|
||||
dispatchMediaPauseEvent,
|
||||
dispatchMediaPlayEvent,
|
||||
dispatchMediaVolumeChangeEvent,
|
||||
} from '../../utils/media-info.js';
|
||||
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
|
||||
import { renderTask } from '../../utils/task.js';
|
||||
import {
|
||||
hideMediaControlsTemporarily,
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
setControlsOnVideo,
|
||||
} from '../../utils/media.js';
|
||||
import { CameraEndpoints } from '../../camera-manager/types.js';
|
||||
import { mayHaveAudio } from '../../utils/audio.js';
|
||||
import { screenshotMedia } from '../../utils/screenshot.js';
|
||||
import { renderTask } from '../../utils/task.js';
|
||||
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
|
||||
|
||||
// Create a wrapper for AlexxIT's WebRTC card
|
||||
// - https://github.com/AlexxIT/WebRTC
|
||||
@@ -85,7 +87,7 @@ export class FrigateCardLiveWebRTCCard
|
||||
public async setControls(controls?: boolean): Promise<void> {
|
||||
const player = this._getPlayer();
|
||||
if (player) {
|
||||
player.controls = controls ?? this.controls;
|
||||
setControlsOnVideo(player, controls ?? this.controls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +95,11 @@ export class FrigateCardLiveWebRTCCard
|
||||
return this._getPlayer()?.paused ?? true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
const video = this._getPlayer();
|
||||
return video ? screenshotMedia(video) : null;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
@@ -191,6 +198,7 @@ export class FrigateCardLiveWebRTCCard
|
||||
this.updateComplete.then(() => {
|
||||
const video = this._getPlayer();
|
||||
if (video) {
|
||||
setControlsOnVideo(video, this.controls);
|
||||
video.onloadeddata = () => {
|
||||
if (this.controls) {
|
||||
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
|
||||
@@ -206,7 +214,6 @@ export class FrigateCardLiveWebRTCCard
|
||||
video.onplay = () => dispatchMediaPlayEvent(this);
|
||||
video.onpause = () => dispatchMediaPauseEvent(this);
|
||||
video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this);
|
||||
video.controls = this.controls;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -756,19 +756,19 @@ export class FrigateCardLiveProvider
|
||||
public async pause(): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this._refProvider.value?.updateComplete;
|
||||
this._refProvider.value?.pause();
|
||||
await this._refProvider.value?.pause();
|
||||
}
|
||||
|
||||
public async mute(): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this._refProvider.value?.updateComplete;
|
||||
this._refProvider.value?.mute();
|
||||
await this._refProvider.value?.mute();
|
||||
}
|
||||
|
||||
public async unmute(): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this._refProvider.value?.updateComplete;
|
||||
this._refProvider.value?.unmute();
|
||||
await this._refProvider.value?.unmute();
|
||||
}
|
||||
|
||||
public isMuted(): boolean {
|
||||
@@ -778,19 +778,25 @@ export class FrigateCardLiveProvider
|
||||
public async seek(seconds: number): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this._refProvider.value?.updateComplete;
|
||||
this._refProvider.value?.seek(seconds);
|
||||
await this._refProvider.value?.seek(seconds);
|
||||
}
|
||||
|
||||
public async setControls(controls?: boolean): Promise<void> {
|
||||
await this.updateComplete;
|
||||
await this._refProvider.value?.updateComplete;
|
||||
this._refProvider.value?.setControls(controls);
|
||||
await this._refProvider.value?.setControls(controls);
|
||||
}
|
||||
|
||||
public isPaused(): boolean {
|
||||
return this._refProvider.value?.isPaused() ?? true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
await this.updateComplete;
|
||||
await this._refProvider.value?.updateComplete;
|
||||
return await this._refProvider.value?.getScreenshotURL() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the fully resolved live provider.
|
||||
* @returns A live provider (that is not 'auto').
|
||||
|
||||
@@ -22,6 +22,7 @@ import './carousel.js';
|
||||
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
|
||||
import { FrigateCardTitleControl } from './title-control.js';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import { Timer } from '../utils/timer';
|
||||
|
||||
interface CarouselMediaLoadedInfo {
|
||||
slide: number;
|
||||
@@ -126,7 +127,7 @@ export class FrigateCardMediaCarousel extends LitElement {
|
||||
protected _nextControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
|
||||
protected _previousControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
|
||||
protected _titleControlRef: Ref<FrigateCardTitleControl> = createRef();
|
||||
protected _titleTimerID: number | null = null;
|
||||
protected _titleTimer = new Timer();
|
||||
|
||||
protected _boundAutoPlayHandler = this.autoPlay.bind(this);
|
||||
protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this);
|
||||
@@ -231,13 +232,10 @@ export class FrigateCardMediaCarousel extends LitElement {
|
||||
*/
|
||||
protected _titleHandler(): void {
|
||||
const show = () => {
|
||||
this._titleTimerID = null;
|
||||
this._titleTimer.stop();
|
||||
this._titleControlRef.value?.show();
|
||||
};
|
||||
|
||||
if (this._titleTimerID) {
|
||||
window.clearTimeout(this._titleTimerID);
|
||||
}
|
||||
if (this._titleControlRef.value?.isVisible()) {
|
||||
// If it's already visible, update it immediately (but also update it
|
||||
// after the timer expires to ensure it re-positions if necessary, see
|
||||
@@ -248,7 +246,7 @@ export class FrigateCardMediaCarousel extends LitElement {
|
||||
// Allow a brief pause after the media loads, but before the title is
|
||||
// displayed. This allows for a pleasant appearance/disappear of the title,
|
||||
// and allows for the browser to finish rendering the carousel.
|
||||
this._titleTimerID = window.setTimeout(show, 0.5 * 1000);
|
||||
this._titleTimer.start(0.5, show);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,8 +31,7 @@ import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
|
||||
import { refreshDynamicStateParameters } from '../utils/ha';
|
||||
import './submenu.js';
|
||||
import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
|
||||
|
||||
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
|
||||
import { FRIGATE_BUTTON_MENU_ICON } from '../const.js';
|
||||
|
||||
/**
|
||||
* A menu for the FrigateCard.
|
||||
|
||||
@@ -47,7 +47,9 @@ import {
|
||||
hideMediaControlsTemporarily,
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
playMediaMutingIfNecessary,
|
||||
setControlsOnVideo,
|
||||
} from '../utils/media.js';
|
||||
import { screenshotMedia } from '../utils/screenshot.js';
|
||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
||||
@@ -562,6 +564,7 @@ export class FrigateCardViewerProvider
|
||||
protected _refFrigateCardMediaPlayer: Ref<Element & FrigateCardMediaPlayer> =
|
||||
createRef();
|
||||
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
|
||||
protected _refImageProvider: Ref<HTMLImageElement> = createRef();
|
||||
|
||||
public async play(): Promise<void> {
|
||||
await playMediaMutingIfNecessary(
|
||||
@@ -612,7 +615,10 @@ export class FrigateCardViewerProvider
|
||||
if (this._refFrigateCardMediaPlayer.value) {
|
||||
return this._refFrigateCardMediaPlayer.value.setControls(controls);
|
||||
} else if (this._refVideoProvider.value) {
|
||||
this._refVideoProvider.value.controls = controls ?? this.viewerConfig?.controls.builtin ?? true;
|
||||
setControlsOnVideo(
|
||||
this._refVideoProvider.value,
|
||||
controls ?? this.viewerConfig?.controls.builtin ?? true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,6 +631,17 @@ export class FrigateCardViewerProvider
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
if (this._refFrigateCardMediaPlayer.value) {
|
||||
return await this._refFrigateCardMediaPlayer.value.getScreenshotURL();
|
||||
} else if (this._refVideoProvider.value) {
|
||||
return screenshotMedia(this._refVideoProvider.value);
|
||||
} else if (this._refImageProvider.value) {
|
||||
return this._refImageProvider.value.src;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a clip view that matches the current (snapshot) query.
|
||||
*/
|
||||
@@ -731,6 +748,8 @@ export class FrigateCardViewerProvider
|
||||
});
|
||||
}
|
||||
|
||||
// Note: crossorigin="anonymous" is required on <video> below in order to
|
||||
// allow screenshot of motionEye videos which currently go cross-origin.
|
||||
return this._useZoomIfRequired(html`
|
||||
${ViewMediaClassifier.isVideo(this.media)
|
||||
? this.media.getVideoContentType() === VideoContentType.HLS
|
||||
@@ -755,6 +774,7 @@ export class FrigateCardViewerProvider
|
||||
title="${this.media.getTitle() ?? ''}"
|
||||
muted
|
||||
playsinline
|
||||
crossorigin="anonymous"
|
||||
?autoplay=${false}
|
||||
?controls=${this.viewerConfig.controls.builtin}
|
||||
@loadedmetadata=${(ev: Event) => {
|
||||
@@ -785,6 +805,7 @@ export class FrigateCardViewerProvider
|
||||
</video>
|
||||
`
|
||||
: html`<img
|
||||
${ref(this._refImageProvider)}
|
||||
aria-label="${this.media.getTitle() ?? ''}"
|
||||
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
|
||||
title="${this.media.getTitle() ?? ''}"
|
||||
|
||||
@@ -274,3 +274,5 @@ export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072;
|
||||
// improved rendering performance.
|
||||
export const MEDIA_CHUNK_SIZE_DEFAULT = 50;
|
||||
export const MEDIA_CHUNK_SIZE_MAX = 1000;
|
||||
|
||||
export const FRIGATE_BUTTON_MENU_ICON = 'frigate';
|
||||
@@ -1786,6 +1786,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
)}
|
||||
${this._renderMenuButton('play') /* */}
|
||||
${this._renderMenuButton('mute')}
|
||||
${this._renderMenuButton('screenshot')}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"play": "Play / Pause",
|
||||
"priority": "Priority",
|
||||
"recordings": "Recordings",
|
||||
"screenshot": "Screenshot",
|
||||
"snapshots": "Snapshots",
|
||||
"substreams": "Substream(s)",
|
||||
"timeline": "Timeline",
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"mute": "",
|
||||
"play": "",
|
||||
"priority": "Priorità",
|
||||
"screenshot": "",
|
||||
"snapshots": "Istantanee",
|
||||
"substreams": "Flusso/i secondario/i",
|
||||
"timeline": "Timeline",
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
"play": "",
|
||||
"priority": "Prioridade",
|
||||
"recordings": "Gravações",
|
||||
"screenshot": "",
|
||||
"snapshots": "Instantâneos",
|
||||
"substreams": "Substream(s)",
|
||||
"timeline": "Linha do tempo",
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"mute": "",
|
||||
"play": "",
|
||||
"priority": "Prioridade",
|
||||
"screenshot": "",
|
||||
"snapshots": "Instantâneos",
|
||||
"substreams": "substreams",
|
||||
"timeline": "Linha do tempo",
|
||||
|
||||
@@ -81,6 +81,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
||||
return this._player?.isPaused() ?? true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
return this._player ? await this._player.getScreenshotURL() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
// available as compilation time.
|
||||
// ====================================================================
|
||||
|
||||
import { CSSResultGroup, TemplateResult, css, html, unsafeCSS } from 'lit';
|
||||
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { query } from 'lit/decorators/query.js';
|
||||
import { screenshotMedia } from '../utils/screenshot.js';
|
||||
import { dispatchErrorMessageEvent } from '../components/message.js';
|
||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||
import { FrigateCardMediaPlayer } from '../types.js';
|
||||
@@ -20,11 +21,10 @@ import {
|
||||
dispatchMediaLoadedEvent,
|
||||
dispatchMediaPauseEvent,
|
||||
dispatchMediaPlayEvent,
|
||||
dispatchMediaVolumeChangeEvent,
|
||||
dispatchMediaVolumeChangeEvent
|
||||
} from '../utils/media-info.js';
|
||||
import {
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
hideMediaControlsTemporarily,
|
||||
hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, setControlsOnVideo
|
||||
} from '../utils/media.js';
|
||||
|
||||
customElements.whenDefined('ha-hls-player').then(() => {
|
||||
@@ -75,7 +75,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
||||
|
||||
public async setControls(controls?: boolean): Promise<void> {
|
||||
if (this._video) {
|
||||
this._video.controls = controls ?? this.controls;
|
||||
setControlsOnVideo(this._video, controls ?? this.controls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,10 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
||||
return this._video?.paused ?? true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
return this._video ? screenshotMedia(this._video) : null;
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// Minor modifications from:
|
||||
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { query } from 'lit/decorators/query.js';
|
||||
import { screenshotMedia } from '../utils/screenshot.js';
|
||||
import { dispatchErrorMessageEvent } from '../components/message.js';
|
||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||
import { FrigateCardMediaPlayer } from '../types.js';
|
||||
@@ -20,11 +21,12 @@ import {
|
||||
dispatchMediaLoadedEvent,
|
||||
dispatchMediaPauseEvent,
|
||||
dispatchMediaPlayEvent,
|
||||
dispatchMediaVolumeChangeEvent,
|
||||
dispatchMediaVolumeChangeEvent
|
||||
} from '../utils/media-info.js';
|
||||
import {
|
||||
hideMediaControlsTemporarily,
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
setControlsOnVideo
|
||||
} from '../utils/media.js';
|
||||
|
||||
customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
@@ -74,7 +76,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
|
||||
public async setControls(controls?: boolean): Promise<void> {
|
||||
if (this._video) {
|
||||
this._video.controls = controls ?? this.controls;
|
||||
setControlsOnVideo(this._video, controls ?? this.controls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +84,10 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||
return this._video?.paused ?? true;
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
return this._video ? screenshotMedia(this._video) : null;
|
||||
}
|
||||
|
||||
// =====================================================================================
|
||||
// Minor modifications from:
|
||||
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
|
||||
|
||||
+11
-9
@@ -209,15 +209,11 @@ const frigateCardCustomActionsBaseSchema = customActionSchema.extend({
|
||||
|
||||
const FRIGATE_CARD_GENERAL_ACTIONS = [
|
||||
'camera_ui',
|
||||
'clip',
|
||||
'clips',
|
||||
'default',
|
||||
'diagnostics',
|
||||
'expand',
|
||||
'download',
|
||||
'fullscreen',
|
||||
'image',
|
||||
'live',
|
||||
'menu_toggle',
|
||||
'mute',
|
||||
'live_substream_on',
|
||||
@@ -226,14 +222,11 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
|
||||
'microphone_unmute',
|
||||
'play',
|
||||
'pause',
|
||||
'recording',
|
||||
'recordings',
|
||||
'snapshot',
|
||||
'snapshots',
|
||||
'timeline',
|
||||
'screenshot',
|
||||
'unmute',
|
||||
] as const;
|
||||
const FRIGATE_CARD_ACTIONS = [
|
||||
...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||
...FRIGATE_CARD_GENERAL_ACTIONS,
|
||||
'camera_select',
|
||||
'live_substream_select',
|
||||
@@ -241,6 +234,11 @@ const FRIGATE_CARD_ACTIONS = [
|
||||
] as const;
|
||||
export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number];
|
||||
|
||||
const frigateCardViewActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED),
|
||||
});
|
||||
export type FrigateCardViewAction = z.infer<typeof frigateCardViewActionSchema>;
|
||||
|
||||
const frigateCardGeneralActionSchema = frigateCardCustomActionsBaseSchema.extend({
|
||||
frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS),
|
||||
});
|
||||
@@ -260,6 +258,7 @@ const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.ex
|
||||
});
|
||||
|
||||
export const frigateCardCustomActionSchema = z.union([
|
||||
frigateCardViewActionSchema,
|
||||
frigateCardGeneralActionSchema,
|
||||
frigateCardCameraSelectActionSchema,
|
||||
frigateCardLiveDependencySelectActionSchema,
|
||||
@@ -1078,6 +1077,7 @@ const menuConfigDefault = {
|
||||
mute: hiddenButtonDefault,
|
||||
play: hiddenButtonDefault,
|
||||
recordings: hiddenButtonDefault,
|
||||
screenshot: hiddenButtonDefault,
|
||||
},
|
||||
button_size: 40,
|
||||
};
|
||||
@@ -1123,6 +1123,7 @@ const menuConfigSchema = z
|
||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||
})
|
||||
.default(menuConfigDefault.buttons),
|
||||
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
||||
@@ -1530,6 +1531,7 @@ export interface FrigateCardMediaPlayer {
|
||||
unmute(): Promise<void>;
|
||||
isMuted(): boolean;
|
||||
seek(seconds: number): Promise<void>;
|
||||
getScreenshotURL(): Promise<string | null>;
|
||||
// If no value for controls if specified, the player should use the default.
|
||||
setControls(controls?: boolean): Promise<void>;
|
||||
isPaused(): boolean;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
FrigateCardAction,
|
||||
FrigateCardCustomAction,
|
||||
frigateCardCustomActionSchema,
|
||||
FrigateCardViewAction,
|
||||
} from '../types.js';
|
||||
|
||||
/**
|
||||
@@ -173,3 +174,21 @@ export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolea
|
||||
export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
export const isViewAction = (
|
||||
action: FrigateCardCustomAction,
|
||||
): action is FrigateCardViewAction => {
|
||||
switch (action.frigate_card_action) {
|
||||
case 'clip':
|
||||
case 'clips':
|
||||
case 'image':
|
||||
case 'live':
|
||||
case 'recording':
|
||||
case 'recordings':
|
||||
case 'snapshot':
|
||||
case 'snapshots':
|
||||
case 'timeline':
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
+30
-25
@@ -5,6 +5,35 @@ import { ViewMedia } from '../view/media';
|
||||
import { errorToConsole } from './basic';
|
||||
import { homeAssistantSignPath } from './ha';
|
||||
|
||||
export const downloadURL = (url: string, filename = 'download'): void => {
|
||||
// The download attribute only works on the same origin.
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes
|
||||
const isSameOrigin = new URL(url).origin === window.location.origin;
|
||||
const dataURL = url.startsWith('data:');
|
||||
|
||||
if (
|
||||
navigator.userAgent.startsWith('Home Assistant/') ||
|
||||
navigator.userAgent.startsWith('HomeAssistant/') ||
|
||||
(!isSameOrigin && !dataURL)
|
||||
) {
|
||||
// Home Assistant companion apps cannot download files without opening a
|
||||
// new browser window.
|
||||
//
|
||||
// User-agents are specified here:
|
||||
// - Android: https://github.com/home-assistant/android/blob/b285c9525dd4837a82db931c1b2321c0511494e6/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantApis.kt#L23
|
||||
// - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
// Use the HTML5 download attribute to prevent a new window from
|
||||
// temporarily opening.
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('download', filename);
|
||||
link.href = url;
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
};
|
||||
|
||||
export const downloadMedia = async (
|
||||
hass: ExtendedHomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
@@ -30,29 +59,5 @@ export const downloadMedia = async (
|
||||
finalURL = response;
|
||||
}
|
||||
|
||||
// The download attribute only works on the same origin.
|
||||
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes
|
||||
const isSameOrigin = new URL(finalURL).origin === window.location.origin;
|
||||
|
||||
if (
|
||||
!isSameOrigin ||
|
||||
navigator.userAgent.startsWith('Home Assistant/') ||
|
||||
navigator.userAgent.startsWith('HomeAssistant/')
|
||||
) {
|
||||
// Home Assistant companion apps cannot download files without opening a
|
||||
// new browser window.
|
||||
//
|
||||
// User-agents are specified here:
|
||||
// - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107
|
||||
// - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75
|
||||
window.open(finalURL, '_blank');
|
||||
} else {
|
||||
// Use the HTML5 download attribute to prevent a new window from
|
||||
// temporarily opening.
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('download', 'download');
|
||||
link.href = finalURL;
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
downloadURL(finalURL);
|
||||
};
|
||||
|
||||
@@ -195,6 +195,6 @@ export function domainIcon(domain: string, entity?: HassEntity, state?: string):
|
||||
return FIXED_DOMAIN_ICONS[domain];
|
||||
}
|
||||
|
||||
console.warn(`Unable to find icon for domain ${domain}`);
|
||||
console.warn(`Unable to find icon for domain: ${domain}`);
|
||||
return DEFAULT_DOMAIN_ICON;
|
||||
}
|
||||
|
||||
@@ -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
@@ -69,18 +69,6 @@ export function dispatchMediaLoadedEvent(
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchMediaVolumeChangeEvent(target: HTMLElement): void {
|
||||
dispatchFrigateCardEvent(target, 'media:volumechange');
|
||||
}
|
||||
|
||||
export function dispatchMediaPlayEvent(target: HTMLElement): void {
|
||||
dispatchFrigateCardEvent(target, 'media:play');
|
||||
}
|
||||
|
||||
export function dispatchMediaPauseEvent(target: HTMLElement): void {
|
||||
dispatchFrigateCardEvent(target, 'media:pause');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a pre-existing MediaLoadedInfo object as an event.
|
||||
* @param element The element to send the event.
|
||||
@@ -93,6 +81,26 @@ export function dispatchExistingMediaLoadedInfoAsEvent(
|
||||
dispatchFrigateCardEvent<MediaLoadedInfo>(target, 'media:loaded', MediaLoadedInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a media unloaded event.
|
||||
* @param element The element to send the event.
|
||||
*/
|
||||
export function dispatchMediaUnloadedEvent(element: HTMLElement): void {
|
||||
dispatchFrigateCardEvent(element, 'media:unloaded');
|
||||
}
|
||||
|
||||
export function dispatchMediaVolumeChangeEvent(target: HTMLElement): void {
|
||||
dispatchFrigateCardEvent(target, 'media:volumechange');
|
||||
}
|
||||
|
||||
export function dispatchMediaPlayEvent(target: HTMLElement): void {
|
||||
dispatchFrigateCardEvent(target, 'media:play');
|
||||
}
|
||||
|
||||
export function dispatchMediaPauseEvent(target: HTMLElement): void {
|
||||
dispatchFrigateCardEvent(target, 'media:pause');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a MediaLoadedInfo object is valid/acceptable.
|
||||
* @param info The MediaLoadedInfo object.
|
||||
@@ -103,11 +111,3 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean {
|
||||
info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a media unloaded event.
|
||||
* @param element The element to send the event.
|
||||
*/
|
||||
export function dispatchMediaUnloadedEvent(element: HTMLElement): void {
|
||||
dispatchFrigateCardEvent(element, 'media:unloaded');
|
||||
}
|
||||
|
||||
+34
-16
@@ -1,4 +1,5 @@
|
||||
import { FrigateCardMediaPlayer } from '../types';
|
||||
import { Timer } from './timer';
|
||||
|
||||
// The number of seconds to hide the video controls for after loading (in order
|
||||
// to give a cleaner UI appearance, see:
|
||||
@@ -6,6 +7,27 @@ import { FrigateCardMediaPlayer } from '../types';
|
||||
export const MEDIA_LOAD_CONTROLS_HIDE_SECONDS = 2;
|
||||
const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1;
|
||||
|
||||
export type FrigateCardHTMLVideoElement = HTMLVideoElement & {
|
||||
_controlsHideTimer?: Timer;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the controls on a video and removes a timer that may have been added by
|
||||
* hideMediaControlsTemporarily.
|
||||
* @param video
|
||||
* @param value
|
||||
*/
|
||||
export const setControlsOnVideo = (
|
||||
video: FrigateCardHTMLVideoElement,
|
||||
value: boolean,
|
||||
): void => {
|
||||
if (video._controlsHideTimer) {
|
||||
video._controlsHideTimer.stop();
|
||||
delete video._controlsHideTimer;
|
||||
}
|
||||
video.controls = value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Temporarily hide media controls.
|
||||
* @param element Any HTMLElement that has a controls property (e.g.
|
||||
@@ -13,21 +35,15 @@ const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1;
|
||||
* @param seconds The number of seconds to hide the controls for.
|
||||
*/
|
||||
export const hideMediaControlsTemporarily = (
|
||||
element: HTMLElement & {
|
||||
controls: boolean;
|
||||
_controlsHideTimeoutID?: number;
|
||||
},
|
||||
video: FrigateCardHTMLVideoElement,
|
||||
seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS,
|
||||
): void => {
|
||||
element.controls = false;
|
||||
|
||||
if (element._controlsHideTimeoutID) {
|
||||
window.clearTimeout(element._controlsHideTimeoutID);
|
||||
}
|
||||
element._controlsHideTimeoutID = window.setTimeout(() => {
|
||||
element.controls = true;
|
||||
delete element._controlsHideTimeoutID;
|
||||
}, seconds * 1000);
|
||||
const oldValue = video.controls;
|
||||
setControlsOnVideo(video, false);
|
||||
video._controlsHideTimer ??= new Timer();
|
||||
video._controlsHideTimer.start(seconds, () => {
|
||||
setControlsOnVideo(video, oldValue);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -42,8 +58,10 @@ export const playMediaMutingIfNecessary = async (
|
||||
// and then try again. This works around some browsers that prevent
|
||||
// auto-play unless the video is muted.
|
||||
if (video?.play) {
|
||||
video.play().catch(async (ev) => {
|
||||
if (ev.name === 'NotAllowedError' && !player.isMuted()) {
|
||||
try {
|
||||
await video.play();
|
||||
} catch (err: unknown) {
|
||||
if ((err as Error).name === 'NotAllowedError' && !player.isMuted()) {
|
||||
await player.mute();
|
||||
try {
|
||||
await video.play();
|
||||
@@ -51,6 +69,6 @@ export const playMediaMutingIfNecessary = async (
|
||||
// Pass.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
@@ -1,8 +1,9 @@
|
||||
import { errorToConsole } from "./basic";
|
||||
import { errorToConsole } from './basic';
|
||||
import { Timer } from './timer';
|
||||
|
||||
export class MicrophoneController {
|
||||
protected _stream?: MediaStream | null;
|
||||
protected _timerID: number | null = null;
|
||||
protected _timer = new Timer();
|
||||
|
||||
// We keep mute state separate from the stream state so that mute/unmute can
|
||||
// be expressed before the stream is created -- and when it's create it will
|
||||
@@ -68,20 +69,11 @@ export class MicrophoneController {
|
||||
return !this._stream || this._stream.getTracks().every((track) => !track.enabled);
|
||||
}
|
||||
|
||||
protected _clearTimer(): void {
|
||||
if (this._timerID) {
|
||||
window.clearTimeout(this._timerID);
|
||||
this._timerID = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected _startTimer(): void {
|
||||
if (this._disconnectSeconds) {
|
||||
this._clearTimer();
|
||||
this._timerID = window.setTimeout(() => {
|
||||
this._clearTimer();
|
||||
this._timer.start(this._disconnectSeconds, () => {
|
||||
this.disconnect();
|
||||
}, this._disconnectSeconds * 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -56,3 +56,65 @@ export function getParseErrorKeys<T>(error: z.ZodError<T>): string[] {
|
||||
const errors = error.format();
|
||||
return Object.keys(errors).filter((v) => !v.startsWith('_'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration parse errors.
|
||||
* @param error The ZodError object from parsing.
|
||||
* @returns An array of string error paths.
|
||||
*/
|
||||
export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> | null => {
|
||||
/* Zod errors involving unions are complex, as Zod may not be able to tell
|
||||
* where the 'real' error is vs simply a union option not matching. This
|
||||
* function finds all ZodError "issues" that don't have an error with 'type'
|
||||
* in that object ('type' is the union discriminator for picture elements,
|
||||
* the major union in the schema). An array of user-readable error
|
||||
* locations is returned, or an empty list if none is available. None being
|
||||
* available suggests the configuration has an error, but we can't tell
|
||||
* exactly why (or rather Zod simply says it doesn't match any of the
|
||||
* available unions). This usually suggests the user specified an incorrect
|
||||
* type name entirely. */
|
||||
const contenders = new Set<string>();
|
||||
if (error && error.issues) {
|
||||
for (let i = 0; i < error.issues.length; i++) {
|
||||
const issue = error.issues[i];
|
||||
if (issue.code == 'invalid_union') {
|
||||
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
|
||||
for (let j = 0; j < unionErrors.length; j++) {
|
||||
const nestedErrors = getParseErrorPaths(unionErrors[j]);
|
||||
if (nestedErrors && nestedErrors.size) {
|
||||
nestedErrors.forEach(contenders.add, contenders);
|
||||
}
|
||||
}
|
||||
} else if (issue.code == 'invalid_type') {
|
||||
if (issue.path[issue.path.length - 1] == 'type') {
|
||||
return null;
|
||||
}
|
||||
contenders.add(getParseErrorPathString(issue.path));
|
||||
} else if (issue.code != 'custom') {
|
||||
contenders.add(getParseErrorPathString(issue.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
return contenders;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert an array of strings and indices into a more user readable string,
|
||||
* e.g. [a, 1, b, 2] => 'a[1] -> b[2]'
|
||||
* @param path An array of strings and numbers.
|
||||
* @returns A single string.
|
||||
*/
|
||||
const getParseErrorPathString = (path: (string | number)[]): string => {
|
||||
let out = '';
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const item = path[i];
|
||||
if (typeof item == 'number') {
|
||||
out += '[' + item + ']';
|
||||
} else if (out) {
|
||||
out += ' -> ' + item;
|
||||
} else {
|
||||
out = item;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
+31
-3
@@ -1,5 +1,4 @@
|
||||
import { PanzoomObject, PanzoomEventDetail } from '@dermotduffy/panzoom';
|
||||
import Panzoom from '@dermotduffy/panzoom';
|
||||
import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
|
||||
import round from 'lodash-es/round';
|
||||
import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic';
|
||||
|
||||
@@ -11,6 +10,7 @@ export class Zoom {
|
||||
protected _element: HTMLElement;
|
||||
protected _panzoom?: PanzoomObject;
|
||||
protected _zoomed = false;
|
||||
protected _allowClick = true;
|
||||
|
||||
protected _events = isHoverableDevice()
|
||||
? {
|
||||
@@ -31,9 +31,23 @@ export class Zoom {
|
||||
|
||||
// If we do not prevent default here, the media carousels scroll.
|
||||
ev.preventDefault();
|
||||
this._allowClick = false;
|
||||
} else {
|
||||
this._allowClick = true;
|
||||
}
|
||||
};
|
||||
|
||||
protected _clickHandler = (ev: Event) => {
|
||||
// When mouse clicking is used to pan, need to avoid that causing a click
|
||||
// handler elsewhere in the card being called. Example: Viewing a snapshot,
|
||||
// and panning within it should not cause a related clip to play (the click
|
||||
// handler in the viewer).
|
||||
if (!this._allowClick) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
this._allowClick = true;
|
||||
};
|
||||
|
||||
protected _moveHandler = (ev: Event) => {
|
||||
if (this._shouldZoomOrPan(ev)) {
|
||||
this._panzoom?.handleMove(ev as PointerEvent);
|
||||
@@ -64,11 +78,17 @@ export class Zoom {
|
||||
protected _shouldZoomOrPan(ev: Event): boolean {
|
||||
return (
|
||||
!this._isScaleNormal(this._panzoom?.getScale()) ||
|
||||
(ev instanceof TouchEvent && ev.touches.length > 1) ||
|
||||
// TouchEvent does not exist on Firefox on non-touch events.
|
||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1174
|
||||
(window.TouchEvent && ev instanceof TouchEvent && ev.touches.length > 1) ||
|
||||
(ev instanceof WheelEvent && ev.ctrlKey)
|
||||
);
|
||||
}
|
||||
|
||||
protected _setTouchAction(touchEnabled: boolean): void {
|
||||
this._element.style.touchAction = touchEnabled ? '' : 'none';
|
||||
}
|
||||
|
||||
public activate(): void {
|
||||
this._panzoom = Panzoom(this._element, {
|
||||
contain: 'outside',
|
||||
@@ -78,6 +98,11 @@ export class Zoom {
|
||||
// Do not force the cursor style (by default it will always show the
|
||||
// 'move' type cursor whether or not it is zoomed in).
|
||||
cursor: undefined,
|
||||
|
||||
// Disable automatic touchAction setting from Panzoom() as otherwise it
|
||||
// effectively disables dashboard scrolling.
|
||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/1181
|
||||
touchAction: '',
|
||||
});
|
||||
|
||||
const registerListeners = (
|
||||
@@ -94,17 +119,20 @@ export class Zoom {
|
||||
registerListeners(this._events['move'], this._moveHandler, { capture: true });
|
||||
registerListeners(this._events['up'], this._upHandler, { capture: true });
|
||||
registerListeners(['wheel'], this._wheelHandler);
|
||||
registerListeners(['click'], this._clickHandler, { capture: true });
|
||||
|
||||
this._element.addEventListener('panzoomzoom', (ev: Event) => {
|
||||
// Take care here to only dispatch the zoomed/unzoomed events when the
|
||||
// absolute state changes (rather than on every single zoom adjustment).
|
||||
if (this._isScaleNormal((<CustomEvent<PanzoomEventDetail>>ev).detail.scale)) {
|
||||
if (this._zoomed) {
|
||||
this._setTouchAction(true);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
|
||||
}
|
||||
this._zoomed = false;
|
||||
} else {
|
||||
if (!this._zoomed) {
|
||||
this._setTouchAction(false);
|
||||
dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
|
||||
}
|
||||
this._zoomed = true;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { RecordingSegmentsCache, RequestCache } from '../../../src/camera-manager/cache';
|
||||
import { FrigateCameraManagerEngine } from '../../../src/camera-manager/frigate/engine-frigate';
|
||||
import {
|
||||
FrigateEventViewMedia,
|
||||
FrigateRecordingViewMedia,
|
||||
} from '../../../src/camera-manager/frigate/media';
|
||||
import { FrigateEvent, eventSchema } from '../../../src/camera-manager/frigate/types.js';
|
||||
import { CameraConfig, RawFrigateCardConfig } from '../../../src/types';
|
||||
import { ViewMedia } from '../../../src/view/media';
|
||||
import { createCameraConfig, createHASS } from '../../test-utils';
|
||||
|
||||
const createEngine = (): FrigateCameraManagerEngine => {
|
||||
return new FrigateCameraManagerEngine(
|
||||
{},
|
||||
new RecordingSegmentsCache(),
|
||||
new RequestCache(),
|
||||
);
|
||||
};
|
||||
|
||||
const createRecordingMedia = (): FrigateRecordingViewMedia => {
|
||||
return new FrigateRecordingViewMedia(
|
||||
'recording',
|
||||
'camera-1',
|
||||
{
|
||||
cameraID: 'camera-1',
|
||||
startTime: new Date('2023-06-16T20:00:00Z'),
|
||||
endTime: new Date('2023-06-16T20:59:59Z'),
|
||||
events: 1,
|
||||
},
|
||||
'recording-id',
|
||||
'recording-content-id',
|
||||
'recording-title',
|
||||
);
|
||||
};
|
||||
|
||||
const createEvent = (): FrigateEvent => {
|
||||
return eventSchema.parse({
|
||||
camera: 'camera-1',
|
||||
end_time: 1686974399,
|
||||
false_positive: false,
|
||||
has_clip: true,
|
||||
has_snapshot: true,
|
||||
id: 'event-id',
|
||||
label: 'person',
|
||||
sub_label: null,
|
||||
start_time: 1686970800,
|
||||
top_score: 0.8,
|
||||
zones: [],
|
||||
retain_indefinitely: true,
|
||||
});
|
||||
};
|
||||
|
||||
const createClipMedia = (): FrigateEventViewMedia => {
|
||||
return new FrigateEventViewMedia(
|
||||
'clip',
|
||||
'camera-1',
|
||||
createEvent(),
|
||||
'event-clip-content-id',
|
||||
'event-clip-thumbnail',
|
||||
);
|
||||
};
|
||||
|
||||
const createSnapshotMedia = (): FrigateEventViewMedia => {
|
||||
return new FrigateEventViewMedia(
|
||||
'snapshot',
|
||||
'camera-1',
|
||||
createEvent(),
|
||||
'event-snapshot-content-id',
|
||||
'event-snapshot-thumbnail',
|
||||
);
|
||||
};
|
||||
|
||||
const createFrigateCameraConfig = (config?: RawFrigateCardConfig): CameraConfig => {
|
||||
return createCameraConfig({
|
||||
frigate: {
|
||||
camera_name: 'camera-1',
|
||||
},
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
describe('getMediaDownloadPath', () => {
|
||||
afterEach(() => {});
|
||||
|
||||
it('should get event with clip download path', async () => {
|
||||
const endpoint = await createEngine().getMediaDownloadPath(
|
||||
createHASS(),
|
||||
createFrigateCameraConfig(),
|
||||
createClipMedia(),
|
||||
);
|
||||
|
||||
expect(endpoint).toEqual({
|
||||
endpoint: '/api/frigate/frigate/notifications/event-id/clip.mp4?download=true',
|
||||
sign: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get event with snapshot download path', async () => {
|
||||
const endpoint = await createEngine().getMediaDownloadPath(
|
||||
createHASS(),
|
||||
createFrigateCameraConfig(),
|
||||
createSnapshotMedia(),
|
||||
);
|
||||
|
||||
expect(endpoint).toEqual({
|
||||
endpoint: '/api/frigate/frigate/notifications/event-id/snapshot.jpg?download=true',
|
||||
sign: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get recording download path', async () => {
|
||||
const endpoint = await createEngine().getMediaDownloadPath(
|
||||
createHASS(),
|
||||
createFrigateCameraConfig(),
|
||||
createRecordingMedia(),
|
||||
);
|
||||
|
||||
expect(endpoint).toEqual({
|
||||
endpoint:
|
||||
'/api/frigate/frigate/recording/camera-1/start/1686945600/end/1686949199?download=true',
|
||||
sign: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get no path for unknown type', async () => {
|
||||
const endpoint = await createEngine().getMediaDownloadPath(
|
||||
createHASS(),
|
||||
createFrigateCameraConfig(),
|
||||
new ViewMedia('clip', 'camera-1'),
|
||||
);
|
||||
expect(endpoint).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
getCameraEntityFromConfig,
|
||||
sortMedia,
|
||||
} from '../../src/camera-manager/util.js';
|
||||
import { ViewMedia, ViewMediaType } from '../../src/view/media.js';
|
||||
import { CameraConfig, cameraConfigSchema } from '../../src/types.js';
|
||||
import { ViewMedia, ViewMediaType } from '../../src/view/media.js';
|
||||
import { TestViewMedia } from '../test-utils.js';
|
||||
|
||||
describe('convertRangeToCacheFriendlyTimes', () => {
|
||||
it('should return cache friendly within hour range', () => {
|
||||
@@ -74,30 +75,6 @@ describe('capEndDate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ViewMedia itself has no native way to set startTime and ID that aren't linked
|
||||
// to an engine.
|
||||
class TestViewMedia extends ViewMedia {
|
||||
protected _ID: string | null;
|
||||
protected _startTime: Date;
|
||||
|
||||
constructor(
|
||||
ID: string | null,
|
||||
startTime: Date,
|
||||
mediaType: ViewMediaType,
|
||||
cameraID: string,
|
||||
) {
|
||||
super(mediaType, cameraID);
|
||||
this._ID = ID;
|
||||
this._startTime = startTime;
|
||||
}
|
||||
public getID(): string | null {
|
||||
return this._ID;
|
||||
}
|
||||
public getStartTime(): Date | null {
|
||||
return this._startTime;
|
||||
}
|
||||
}
|
||||
|
||||
describe('sortMedia', () => {
|
||||
const media_1 = new TestViewMedia(
|
||||
'id-1',
|
||||
|
||||
+110
-7
@@ -1,19 +1,32 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
|
||||
import { vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManagerEngineFactory } from '../src/camera-manager/engine-factory';
|
||||
import { FrigateEvent, FrigateRecording } from '../src/camera-manager/frigate/types';
|
||||
import { CameraManager } from '../src/camera-manager/manager';
|
||||
import { CameraManagerStore } from '../src/camera-manager/store';
|
||||
import {
|
||||
CameraConfigs,
|
||||
CameraManagerCameraCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
} from '../src/camera-manager/types';
|
||||
import {
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardCondition,
|
||||
FrigateCardConfig,
|
||||
MediaLoadedInfo,
|
||||
RawFrigateCardConfig,
|
||||
cameraConfigSchema,
|
||||
frigateCardConditionSchema,
|
||||
frigateCardConfigSchema,
|
||||
} from '../src/types';
|
||||
import { Entity } from '../src/utils/ha/entity-registry/types';
|
||||
import { ViewMedia, ViewMediaType } from '../src/view/media';
|
||||
import { View, ViewParameters } from '../src/view/view';
|
||||
|
||||
export const createCameraConfig = (config: unknown): CameraConfig => {
|
||||
return cameraConfigSchema.parse(config);
|
||||
export const createCameraConfig = (config?: unknown): CameraConfig => {
|
||||
return cameraConfigSchema.parse(config ?? {});
|
||||
};
|
||||
|
||||
export const createCondition = (
|
||||
@@ -22,12 +35,16 @@ export const createCondition = (
|
||||
return frigateCardConditionSchema.parse(condition ?? {});
|
||||
};
|
||||
|
||||
export const createConfig = (config?: Partial<FrigateCardConfig>): FrigateCardConfig => {
|
||||
return frigateCardConfigSchema.parse(config);
|
||||
export const createConfig = (config?: RawFrigateCardConfig): FrigateCardConfig => {
|
||||
return frigateCardConfigSchema.parse({
|
||||
type: 'frigate-hass-card',
|
||||
cameras: [],
|
||||
...config,
|
||||
});
|
||||
};
|
||||
|
||||
export const createHASS = (states?: HassEntities): HomeAssistant => {
|
||||
const hass = mock<HomeAssistant>();
|
||||
export const createHASS = (states?: HassEntities): ExtendedHomeAssistant => {
|
||||
const hass = mock<ExtendedHomeAssistant>();
|
||||
if (states) {
|
||||
hass.states = states;
|
||||
}
|
||||
@@ -89,3 +106,89 @@ export const createFrigateRecording = (recording?: Partial<FrigateRecording>) =>
|
||||
...recording,
|
||||
};
|
||||
};
|
||||
|
||||
export const createView = (options?: Partial<ViewParameters>): View => {
|
||||
return new View({
|
||||
view: 'live',
|
||||
camera: 'camera',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
export const createCameraManager = (options?: {
|
||||
store?: CameraManagerStore;
|
||||
configs?: CameraConfigs;
|
||||
}): CameraManager => {
|
||||
const cameraManager = new CameraManager(mock<CameraManagerEngineFactory>(), {});
|
||||
let store: CameraManagerStore | undefined = options?.store;
|
||||
if (!store) {
|
||||
store = mock<CameraManagerStore>();
|
||||
const configs = options?.configs ?? new Map([['camera', createCameraConfig()]]);
|
||||
vi.mocked(store.getCameras).mockReturnValue(configs);
|
||||
vi.mocked(store.getVisibleCameras).mockReturnValue(configs);
|
||||
vi.mocked(store.getCameraConfig).mockImplementation((cameraID): CameraConfig => {
|
||||
return configs.get(cameraID) ?? createCameraConfig();
|
||||
});
|
||||
}
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(store);
|
||||
return cameraManager;
|
||||
};
|
||||
|
||||
export const createCameraCapabilities = (
|
||||
options?: Partial<CameraManagerCameraCapabilities>,
|
||||
): CameraManagerCameraCapabilities => {
|
||||
return {
|
||||
canFavoriteEvents: false,
|
||||
canFavoriteRecordings: false,
|
||||
canSeek: false,
|
||||
supportsClips: false,
|
||||
supportsRecordings: false,
|
||||
supportsSnapshots: false,
|
||||
supportsTimeline: false,
|
||||
...options,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMediaCapabilities = (
|
||||
options?: Partial<CameraManagerMediaCapabilities>,
|
||||
): CameraManagerMediaCapabilities => {
|
||||
return {
|
||||
canFavorite: false,
|
||||
canDownload: false,
|
||||
...options,
|
||||
};
|
||||
};
|
||||
|
||||
export const createMediaLoadedInfo = (
|
||||
options?: Partial<MediaLoadedInfo>,
|
||||
): MediaLoadedInfo => {
|
||||
return {
|
||||
width: 100,
|
||||
height: 100,
|
||||
...options,
|
||||
};
|
||||
};
|
||||
|
||||
// ViewMedia itself has no native way to set startTime and ID that aren't linked
|
||||
// to an engine.
|
||||
export class TestViewMedia extends ViewMedia {
|
||||
protected _id: string | null;
|
||||
protected _startTime: Date;
|
||||
|
||||
constructor(
|
||||
id: string | null,
|
||||
startTime: Date,
|
||||
mediaType: ViewMediaType,
|
||||
cameraID: string,
|
||||
) {
|
||||
super(mediaType, cameraID);
|
||||
this._id = id;
|
||||
this._startTime = startTime;
|
||||
}
|
||||
public getID(): string | null {
|
||||
return this._id;
|
||||
}
|
||||
public getStartTime(): Date | null {
|
||||
return this._startTime;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { handleActionConfig, hasAction } from 'custom-card-helpers';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { actionSchema } from '../../src/types';
|
||||
import {
|
||||
actionSchema,
|
||||
FrigateCardAction,
|
||||
FrigateCardCustomAction,
|
||||
frigateCardCustomActionSchema,
|
||||
} from '../../src/types';
|
||||
import {
|
||||
convertActionToFrigateCardCustomAction,
|
||||
createFrigateCardCustomAction,
|
||||
frigateCardHandleAction,
|
||||
frigateCardHandleActionConfig,
|
||||
frigateCardHasAction,
|
||||
getActionConfigGivenAction,
|
||||
isViewAction,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../../src/utils/action';
|
||||
import { createHASS } from '../test-utils';
|
||||
@@ -173,6 +180,23 @@ describe('frigateCardHandleActionConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('frigateCardHandleAction', () => {
|
||||
const element = document.createElement('div');
|
||||
const action = actionSchema.parse({
|
||||
action: 'none',
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call action handler', () => {
|
||||
frigateCardHandleAction(element, createHASS(), {}, action);
|
||||
expect(handleActionConfig).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('frigateCardHasAction', () => {
|
||||
const action = actionSchema.parse({
|
||||
action: 'toggle',
|
||||
@@ -200,3 +224,45 @@ describe('stopEventFromActivatingCardWideActions', () => {
|
||||
expect(event.stopPropagation).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isViewAction', () => {
|
||||
const createAction = (action: FrigateCardAction): FrigateCardCustomAction => {
|
||||
return frigateCardCustomActionSchema.parse({
|
||||
action: 'fire-dom-event' as const,
|
||||
frigate_card_action: action,
|
||||
});
|
||||
};
|
||||
it('should return true for clip view ', () => {
|
||||
expect(isViewAction(createAction('clip'))).toBeTruthy();
|
||||
});
|
||||
it('should return true for clips view ', () => {
|
||||
expect(isViewAction(createAction('clips'))).toBeTruthy();
|
||||
});
|
||||
it('should return true for image view ', () => {
|
||||
expect(isViewAction(createAction('image'))).toBeTruthy();
|
||||
});
|
||||
it('should return true for live view ', () => {
|
||||
expect(isViewAction(createAction('live'))).toBeTruthy();
|
||||
});
|
||||
it('should return true for recording view ', () => {
|
||||
expect(isViewAction(createAction('recording'))).toBeTruthy();
|
||||
});
|
||||
it('should return true for live view ', () => {
|
||||
expect(isViewAction(createAction('live'))).toBeTruthy();
|
||||
});
|
||||
it('should return true for recordings view ', () => {
|
||||
expect(isViewAction(createAction('recordings'))).toBeTruthy();
|
||||
});
|
||||
it('should return true for snapshot view ', () => {
|
||||
expect(isViewAction(createAction('snapshot'))).toBeTruthy();
|
||||
});
|
||||
it('should return true for snapshots view ', () => {
|
||||
expect(isViewAction(createAction('snapshots'))).toBeTruthy();
|
||||
});
|
||||
it('should return true for timeline view ', () => {
|
||||
expect(isViewAction(createAction('timeline'))).toBeTruthy();
|
||||
});
|
||||
it('should return false for anything else', () => {
|
||||
expect(isViewAction(createAction('diagnostics'))).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
|
||||
import { CameraManager } from '../../src/camera-manager/manager.js';
|
||||
import { CameraManagerStore } from '../../src/camera-manager/store.js';
|
||||
import { CameraConfigs } from '../../src/camera-manager/types.js';
|
||||
import { getAllDependentCameras, getCameraID } from '../../src/utils/camera.js';
|
||||
import { createCameraConfig } from '../test-utils.js';
|
||||
import { createCameraConfig, createCameraManager } from '../test-utils.js';
|
||||
|
||||
vi.mock('../../src/camera-manager/manager.js');
|
||||
|
||||
@@ -54,11 +52,7 @@ describe('getAllDependentCameras', () => {
|
||||
['two', createCameraConfig({})],
|
||||
]);
|
||||
|
||||
const cameraManager = new CameraManager(mock<CameraManagerEngineFactory>(), {});
|
||||
const store = mock<CameraManagerStore>();
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(store);
|
||||
store.getCameras.mockReturnValue(cameraConfigs);
|
||||
|
||||
const cameraManager = createCameraManager({ configs: cameraConfigs });
|
||||
expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
|
||||
new Set(['one', 'two']),
|
||||
);
|
||||
@@ -76,11 +70,7 @@ describe('getAllDependentCameras', () => {
|
||||
['two', createCameraConfig({})],
|
||||
]);
|
||||
|
||||
const cameraManager = new CameraManager(mock<CameraManagerEngineFactory>(), {});
|
||||
const store = mock<CameraManagerStore>();
|
||||
vi.mocked(cameraManager.getStore).mockReturnValue(store);
|
||||
store.getCameras.mockReturnValue(cameraConfigs);
|
||||
|
||||
const cameraManager = createCameraManager({ configs: cameraConfigs });
|
||||
expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
|
||||
new Set(['one', 'two']),
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { log } from '../../src/utils/debug.js';
|
||||
describe('log', () => {
|
||||
const spy = vi.spyOn(global.console, 'debug').mockReturnValue(undefined);
|
||||
afterAll(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it('should do nothing without debug logging set', () => {
|
||||
log({}, 'foo');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { CameraManager } from '../../src/camera-manager/manager';
|
||||
import { getAllDependentCameras } from '../../src/utils/camera';
|
||||
import {
|
||||
createViewWithNextStream,
|
||||
@@ -9,7 +7,9 @@ import {
|
||||
hasSubstream,
|
||||
} from '../../src/utils/substream';
|
||||
import { View } from '../../src/view/view';
|
||||
import { createCameraManager } from '../test-utils';
|
||||
|
||||
vi.mock('../../src/camera-manager/manager.js');
|
||||
vi.mock('../../src/utils/camera');
|
||||
|
||||
describe('createViewWithSelectedSubstream', () => {
|
||||
@@ -95,7 +95,7 @@ describe('createViewWithNextStream', () => {
|
||||
camera: 'camera',
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const cameraManager = createCameraManager()
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.camera).toBe(view.camera);
|
||||
expect(newView.view).toBe(view.view);
|
||||
@@ -107,7 +107,7 @@ describe('createViewWithNextStream', () => {
|
||||
camera: 'camera',
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const cameraManager = createCameraManager()
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
|
||||
});
|
||||
@@ -122,7 +122,7 @@ describe('createViewWithNextStream', () => {
|
||||
},
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const cameraManager = createCameraManager()
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
||||
});
|
||||
@@ -137,7 +137,7 @@ describe('createViewWithNextStream', () => {
|
||||
},
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const cameraManager = createCameraManager()
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -124,6 +124,48 @@ describe('Zoom', () => {
|
||||
expect(panzoom.handleUp).toBeCalledWith(ev_5);
|
||||
});
|
||||
|
||||
it('should ignore click after pointerdown', () => {
|
||||
const outer = document.createElement('div');
|
||||
const inner = document.createElement('div');
|
||||
outer.appendChild(inner);
|
||||
const clickHandler = vi.fn();
|
||||
outer.addEventListener('click', clickHandler);
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
|
||||
createAndRegisterZoom(inner);
|
||||
|
||||
// Simulate being zoomed in.
|
||||
panzoom.getScale = vi.fn().mockReturnValue(1.2);
|
||||
|
||||
// A click on its own will be fine.
|
||||
const click_1 = new MouseEvent('click', { bubbles: true });
|
||||
inner.dispatchEvent(click_1);
|
||||
expect(clickHandler).toBeCalledTimes(1);
|
||||
|
||||
// A click after a pointerdown will be ignored.
|
||||
const pointerdown_1 = new PointerEvent('pointerdown');
|
||||
inner.dispatchEvent(pointerdown_1);
|
||||
|
||||
const click_2 = new MouseEvent('click', { bubbles: true });
|
||||
inner.dispatchEvent(click_2);
|
||||
|
||||
// Click will have been ignored.
|
||||
expect(clickHandler).toBeCalledTimes(1);
|
||||
|
||||
// Simulate being zoomed out.
|
||||
panzoom.getScale = vi.fn().mockReturnValue(1.0);
|
||||
const pointerdown_2 = new PointerEvent('pointerdown');
|
||||
inner.dispatchEvent(pointerdown_2);
|
||||
|
||||
const click_3 = new MouseEvent('click', { bubbles: true });
|
||||
inner.dispatchEvent(click_3);
|
||||
|
||||
// Click will have been processed.
|
||||
expect(clickHandler).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('deactivate should remove event handlers', () => {
|
||||
const element = document.createElement('div');
|
||||
|
||||
@@ -150,8 +192,7 @@ describe('Zoom', () => {
|
||||
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
|
||||
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
|
||||
|
||||
const panzoom = createMockPanZoom();
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
@@ -180,4 +221,35 @@ describe('Zoom', () => {
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(unzoomedFunc).toBeCalled();
|
||||
});
|
||||
|
||||
it('should set touch action on zoom/unzoom', () => {
|
||||
const element = document.createElement('div');
|
||||
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
|
||||
|
||||
createAndRegisterZoom(element);
|
||||
|
||||
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1.2,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_1);
|
||||
expect(element.style.touchAction).toBe('none');
|
||||
|
||||
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
|
||||
detail: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
isSVG: false,
|
||||
originalEvent: new PointerEvent('pointermove'),
|
||||
},
|
||||
});
|
||||
element.dispatchEvent(ev_2);
|
||||
expect(element.style.touchAction).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
+3
-15
@@ -1,22 +1,10 @@
|
||||
import { describe, it, expect, vi, test } from 'vitest';
|
||||
import { describe, expect, it, test, vi } from 'vitest';
|
||||
import { QueryType } from '../../src/camera-manager/types';
|
||||
import { ViewMedia } from '../../src/view/media';
|
||||
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
|
||||
import { MediaQueriesResults } from '../../src/view/media-queries-results';
|
||||
import {
|
||||
View,
|
||||
ViewParameters,
|
||||
dispatchViewContextChangeEvent,
|
||||
} from '../../src/view/view';
|
||||
import { ViewContext } from 'view';
|
||||
|
||||
const createView = (options?: Partial<ViewParameters>): View => {
|
||||
return new View({
|
||||
...options,
|
||||
view: options?.view ?? 'live',
|
||||
camera: options?.camera ?? 'camera',
|
||||
});
|
||||
};
|
||||
import { View, dispatchViewContextChangeEvent } from '../../src/view/view';
|
||||
import { createView } from '../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('View Basics', () => {
|
||||
|
||||
@@ -636,14 +636,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@graphiteds/core@npm:^1.9.6":
|
||||
version: 1.9.7
|
||||
resolution: "@graphiteds/core@npm:1.9.7"
|
||||
"@graphiteds/core@npm:^1.9.11":
|
||||
version: 1.9.11
|
||||
resolution: "@graphiteds/core@npm:1.9.11"
|
||||
dependencies:
|
||||
"@duetds/date-picker": ^1.4.0
|
||||
"@popperjs/core": ^2.11.5
|
||||
"@stencil/core": ^2.20.0
|
||||
checksum: d9fa01a5e63fa4b26010893f85324ddfc1961c08ba1a934da5371941d648582e722a87f5af682084be24f28aa5b3aec3bda6a8ff8759b2f7246e5db13f528391
|
||||
checksum: de67030f51fb165563bd73dc55a1294063f40e6de1bd55f864ea9c627069da81fbdb171de7908ae34a8c3f8d0505fd9414762bd695a0e449304d441867bc59cc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3041,7 +3041,7 @@ __metadata:
|
||||
"@cycjimmy/jsmpeg-player": ^6.0.4
|
||||
"@dermotduffy/panzoom": ^4.5.1
|
||||
"@egjs/hammerjs": ^2.0.17
|
||||
"@graphiteds/core": ^1.9.6
|
||||
"@graphiteds/core": ^1.9.11
|
||||
"@lit-labs/scoped-registry-mixin": ^1.0.1
|
||||
"@lit-labs/task": ^1.1.3
|
||||
"@rollup/plugin-babel": ^5.3.1
|
||||
|
||||
Reference in New Issue
Block a user