is rendering right now, so we provide a
+ // stateOverride to evaluate the condition in that context.
const config = getOverriddenConfig(
+ this.conditionControllerEpoch.controller,
this.liveConfig,
this.liveOverrides,
- conditionState,
+ { camera: cameraID },
) as LiveConfig;
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraID);
@@ -540,6 +549,9 @@ export class FrigateCardLiveCarousel extends LitElement {
{
@@ -642,7 +648,7 @@ export class FrigateCardLiveCarousel extends LitElement {
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
- .titlePopupConfig=${config.controls.title}
+ .titlePopupConfig=${this.liveConfig.controls.title}
.selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
@@ -655,7 +661,7 @@ export class FrigateCardLiveCarousel extends LitElement {
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
- .controlConfig=${config.controls.next_previous}
+ .controlConfig=${this.liveConfig.controls.next_previous}
.label=${cameraMetadataPrevious?.title ?? ''}
.icon=${cameraMetadataPrevious?.icon}
?disabled=${prevID === null}
@@ -670,7 +676,7 @@ export class FrigateCardLiveCarousel extends LitElement {
slot="next"
.hass=${this.hass}
.direction=${'next'}
- .controlConfig=${config.controls.next_previous}
+ .controlConfig=${this.liveConfig.controls.next_previous}
.label=${cameraMetadataNext?.title ?? ''}
.icon=${cameraMetadataNext?.icon}
?disabled=${nextID === null}
@@ -721,6 +727,9 @@ export class FrigateCardLiveProvider
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
+ @property({ attribute: false })
+ public microphoneStream?: MediaStream;
+
@state()
protected _isVideoMediaLoaded = false;
@@ -772,6 +781,16 @@ export class FrigateCardLiveProvider
this._refProvider.value?.seek(seconds);
}
+ public async setControls(controls?: boolean): Promise {
+ await this.updateComplete;
+ await this._refProvider.value?.updateComplete;
+ this._refProvider.value?.setControls(controls);
+ }
+
+ public isPaused(): boolean {
+ return this._refProvider.value?.isPaused() ?? true;
+ }
+
/**
* Get the fully resolved live provider.
* @returns A live provider (that is not 'auto').
@@ -839,6 +858,9 @@ export class FrigateCardLiveProvider
if (this.liveConfig?.show_image_during_load) {
this._importPromises.push(import('./live-image.js'));
}
+ if (this.liveConfig?.zoomable) {
+ this._importPromises.push(import('./../zoomer.js'));
+ }
}
if (changedProps.has('cameraConfig')) {
const provider = this._getResolvedProvider();
@@ -865,6 +887,17 @@ export class FrigateCardLiveProvider
return result;
}
+ protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
+ return this.liveConfig?.zoomable
+ ? html` this.setControls(false)}
+ @frigate-card:zoom:unzoomed=${() => this.setControls()}
+ >
+ ${template}
+ `
+ : template;
+ }
+
/**
* Master render method.
* @returns A rendered template.
@@ -885,9 +918,9 @@ export class FrigateCardLiveProvider
hidden: showImageDuringLoading,
};
- return html`
+ return this._useZoomIfRequired(html`
${showImageDuringLoading || provider === 'image'
- ? html`
`
@@ -920,6 +954,9 @@ export class FrigateCardLiveProvider
.hass=${this.hass}
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
+ .microphoneStream=${this.microphoneStream}
+ .microphoneConfig=${this.liveConfig.microphone}
+ ?controls=${this.liveConfig.controls.builtin}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
`
@@ -931,6 +968,7 @@ export class FrigateCardLiveProvider
.cameraConfig=${this.cameraConfig}
.cameraEndpoints=${this.cameraEndpoints}
.cardWideConfig=${this.cardWideConfig}
+ ?controls=${this.liveConfig.controls.builtin}
@frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)}
>
`
@@ -946,12 +984,9 @@ export class FrigateCardLiveProvider
>
`
: html``}
- `;
+ `);
}
- /**
- * Get styles.
- */
static get styles(): CSSResultGroup {
return unsafeCSS(liveProviderStyle);
}
diff --git a/src/components/viewer.ts b/src/components/viewer.ts
index 3a8f8d72..699ced25 100644
--- a/src/components/viewer.ts
+++ b/src/components/viewer.ts
@@ -9,8 +9,12 @@ import {
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
+import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
+import { CameraManager } from '../camera-manager/manager.js';
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
+import { localize } from '../localize/localize.js';
+import '../patches/ha-hls-player';
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import viewerProviderStyle from '../scss/viewer-provider.scss';
import viewerStyle from '../scss/viewer.scss';
@@ -24,39 +28,41 @@ import {
ViewerConfig,
} from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
+import { mayHaveAudio } from '../utils/audio.js';
import { contentsChanged, errorToConsole } from '../utils/basic.js';
+import { canonicalizeHAURL } from '../utils/ha/index.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
-import { View } from '../view/view.js';
+import {
+ dispatchMediaLoadedEvent,
+ dispatchMediaPauseEvent,
+ dispatchMediaPlayEvent,
+ dispatchMediaVolumeChangeEvent,
+} from '../utils/media-info.js';
+import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
+import {
+ changeViewToRecentEventsForCameraAndDependents,
+ changeViewToRecentRecordingForCameraAndDependents,
+} from '../utils/media-to-view.js';
+import {
+ hideMediaControlsTemporarily,
+ MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
+ playMediaMutingIfNecessary,
+} from '../utils/media.js';
+import { ViewMediaClassifier } from '../view/media-classifier';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
+import { MediaQueriesResults } from '../view/media-queries-results.js';
+import { VideoContentType, ViewMedia } from '../view/media.js';
+import { View } from '../view/view.js';
+import type { CarouselSelect } from './carousel.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload } from './embla-plugins/lazyload.js';
import {
FrigateCardMediaCarousel,
wrapMediaLoadedEventForCarousel,
} from './media-carousel.js';
-import type { CarouselSelect } from './carousel.js';
import './next-prev-control.js';
-import './title-control.js';
-import '../patches/ha-hls-player';
import './surround.js';
-import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
-import { CameraManager } from '../camera-manager/manager.js';
-import {
- changeViewToRecentEventsForCameraAndDependents,
- changeViewToRecentRecordingForCameraAndDependents,
-} from '../utils/media-to-view.js';
-import { VideoContentType, ViewMedia } from '../view/media.js';
-import { ViewMediaClassifier } from '../view/media-classifier';
-import { guard } from 'lit/directives/guard.js';
-import { localize } from '../localize/localize.js';
-import { MediaQueriesResults } from '../view/media-queries-results.js';
-import { canonicalizeHAURL } from '../utils/ha/index.js';
-import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
-import { playMediaMutingIfNecessary } from '../utils/media.js';
-import {
- hideMediaControlsTemporarily,
- MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
-} from '../utils/media.js';
+import './title-control.js';
export interface MediaViewerViewContext {
seek?: Date;
@@ -602,6 +608,23 @@ export class FrigateCardViewerProvider
}
}
+ public async setControls(controls?: boolean): Promise {
+ 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;
+ }
+ }
+
+ public isPaused(): boolean {
+ if (this._refFrigateCardMediaPlayer.value) {
+ return this._refFrigateCardMediaPlayer.value.isPaused();
+ } else if (this._refVideoProvider.value) {
+ return this._refVideoProvider.value.paused;
+ }
+ return true;
+ }
+
/**
* Dispatch a clip view that matches the current (snapshot) query.
*/
@@ -674,6 +697,21 @@ export class FrigateCardViewerProvider
this.requestUpdate();
});
}
+
+ if (changedProps.has('viewerConfig') && this.viewerConfig?.zoomable) {
+ import('./zoomer.js');
+ }
+ }
+
+ protected _useZoomIfRequired(template: TemplateResult): TemplateResult {
+ return this.viewerConfig?.zoomable
+ ? html` this.setControls(false)}
+ @frigate-card:zoom:unzoomed=${() => this.setControls()}
+ >
+ ${template}
+ `
+ : template;
}
protected render(): TemplateResult | void {
@@ -693,61 +731,73 @@ export class FrigateCardViewerProvider
});
}
- return ViewMediaClassifier.isVideo(this.media)
- ? this.media.getVideoContentType() === VideoContentType.HLS
- ? html`
- `
- : html`
-
- `
- : html`
{
- if (this.viewerConfig?.snapshot_click_plays_clip) {
- this._dispatchRelatedClipView();
- }
- }}
- @load=${(e: Event) => {
- dispatchMediaLoadedEvent(this, e);
- }}
- />`;
+ `
+ : html`
+
+ `
+ : html`
{
+ if (this.viewerConfig?.snapshot_click_plays_clip) {
+ this._dispatchRelatedClipView();
+ }
+ }}
+ @load=${(ev: Event) => {
+ dispatchMediaLoadedEvent(this, ev, { player: this });
+ }}
+ />`}
+ `);
}
static get styles(): CSSResultGroup {
diff --git a/src/components/views.ts b/src/components/views.ts
index 13a60204..f7032397 100644
--- a/src/components/views.ts
+++ b/src/components/views.ts
@@ -1,15 +1,15 @@
import {
- CSSResultGroup,
- html,
- LitElement,
- PropertyValues,
- TemplateResult,
- unsafeCSS
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+ unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
-import { ConditionState, getOverridesByKey } from '../card-condition';
+import { ConditionControllerEpoch, getOverridesByKey } from '../conditions';
import viewsStyle from '../scss/views.scss';
import { CardWideConfig, ExtendedHomeAssistant, FrigateCardConfig } from '../types.js';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
@@ -40,14 +40,14 @@ export class FrigateCardViews extends LitElement {
public resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
- public conditionState?: ConditionState;
-
- @property({ attribute: false })
- public cameras?: ConditionState;
+ public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public hide?: boolean;
+ @property({ attribute: false })
+ public microphoneStream?: MediaStream;
+
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('view') || changedProps.has('config')) {
if (this.view?.is('live') || this._shouldLivePreload()) {
@@ -72,7 +72,7 @@ export class FrigateCardViews extends LitElement {
}
}
}
-
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldUpdate(_: PropertyValues): boolean {
// Future: Updates to `hass` and `conditionState` here will be frequent.
@@ -150,6 +150,7 @@ export class FrigateCardViews extends LitElement {
.view=${this.view}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
+ .supportZoom=${true}
>
`
: ``}
@@ -199,10 +200,11 @@ export class FrigateCardViews extends LitElement {
.hass=${this.hass}
.view=${this.view}
.liveConfig=${this.nonOverriddenConfig.live}
- .conditionState=${this.conditionState}
- .liveOverrides=${getOverridesByKey(this.config.overrides, 'live')}
+ .conditionControllerEpoch=${this.conditionControllerEpoch}
+ .liveOverrides=${getOverridesByKey('live', this.config.overrides)}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
+ .microphoneStream=${this.microphoneStream}
class="${classMap(liveClasses)}"
>
diff --git a/src/components/zoomer.ts b/src/components/zoomer.ts
new file mode 100644
index 00000000..cf82844b
--- /dev/null
+++ b/src/components/zoomer.ts
@@ -0,0 +1,64 @@
+import {
+ css,
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+} from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+import { setOrRemoveAttribute } from '../utils/basic.js';
+import { Zoom } from '../utils/zoom/zoom.js';
+
+@customElement('frigate-card-zoomer')
+export class FrigateCardZoomer extends LitElement {
+ protected _zoom = new Zoom(this);
+
+ @state()
+ protected _zoomed = false;
+
+ protected _zoomHandler = () => (this._zoomed = true);
+ protected _unzoomHandler = () => (this._zoomed = false);
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ this.addEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
+ this.addEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
+ this._zoom.activate();
+ }
+
+ disconnectedCallback(): void {
+ this._zoom.deactivate();
+ this.removeEventListener('frigate-card:zoom:zoomed', this._zoomHandler);
+ this.removeEventListener('frigate-card:zoom:unzoomed', this._unzoomHandler);
+ }
+
+ protected willUpdate(changedProps: PropertyValues): void {
+ if (changedProps.has('_zoomed')) {
+ setOrRemoveAttribute(this, this._zoomed, 'zoomed');
+ }
+ }
+ protected render(): TemplateResult | void {
+ return html` `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ :host {
+ width: 100%;
+ height: 100%;
+ display: block;
+ cursor: auto;
+ }
+ :host([zoomed]) {
+ cursor: move;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ 'frigate-card-zoomer': FrigateCardZoomer;
+ }
+}
diff --git a/src/conditions.ts b/src/conditions.ts
new file mode 100644
index 00000000..a47e8ed5
--- /dev/null
+++ b/src/conditions.ts
@@ -0,0 +1,251 @@
+import { HassEntities } from 'home-assistant-js-websocket';
+import merge from 'lodash-es/merge';
+import { copyConfig } from './config-mgmt';
+import {
+ FrigateCardCondition,
+ FrigateCardConfig,
+ frigateConditionalSchema,
+ OverrideConfigurationKey,
+ RawFrigateCardConfig,
+} from './types';
+
+interface ConditionState {
+ view?: string;
+ fullscreen?: boolean;
+ expand?: boolean;
+ camera?: string;
+ state?: HassEntities;
+ media_loaded?: boolean;
+}
+
+export class ConditionEvaluateRequestEvent extends Event {
+ public condition: FrigateCardCondition;
+ public evaluation?: boolean;
+
+ constructor(condition: FrigateCardCondition, eventInitDict?: EventInit) {
+ super('frigate-card:condition:evaluate', eventInitDict);
+ this.condition = condition;
+ }
+}
+
+/**
+ * Evaluate whether a frigateCardCondition is met using an event to evaluate.
+ * @returns A boolean indicating whether the condition is met.
+ */
+export function evaluateConditionViaEvent(
+ element: HTMLElement,
+ condition?: FrigateCardCondition,
+): boolean {
+ if (!condition) {
+ return true;
+ }
+
+ const evaluateEvent = new ConditionEvaluateRequestEvent(condition, {
+ bubbles: true,
+ composed: true,
+ });
+
+ /* Special note on what's going on here:
+ *
+ * Some parts of the card (e.g. ) may have arbitrary
+ * complexity and layers (that this card doesn't control) between that master
+ * element and the element that needs to evaluate the condition. In these
+ * cases there's no clean way to pass state from the rest of card down through
+ * these layers. Instead, an event is dispatched as a "request for evaluation"
+ * (ConditionEvaluateRequestEvent) upwards which is caught by the outer card
+ * and the evaluation result is added to the event object. Because event
+ * propagation is handled synchronously, the result will be added to the event
+ * before the flow proceeds.
+ */
+ element.dispatchEvent(evaluateEvent);
+ return evaluateEvent.evaluation ?? false;
+}
+
+type RawOverrides = {
+ conditions: FrigateCardCondition;
+ overrides: RawFrigateCardConfig;
+}[];
+
+export function getOverriddenConfig(
+ controller: Readonly,
+ config: Readonly,
+ configOverrides?: Readonly,
+ stateOverrides?: Partial,
+): RawFrigateCardConfig {
+ const output = copyConfig(config);
+ let overridden = false;
+ if (configOverrides) {
+ for (const override of configOverrides) {
+ if (controller.evaluateCondition(override.conditions, stateOverrides)) {
+ merge(output, override.overrides);
+ overridden = true;
+ }
+ }
+ }
+ // Attempt to return the same configuration object if it has not been
+ // overridden (to reduce re-renders for a configuration that has not changed).
+ return overridden ? output : config;
+}
+
+export function getOverridesByKey(
+ key: OverrideConfigurationKey,
+ overrides?: Readonly,
+): RawOverrides {
+ return (
+ overrides
+ ?.filter((o) => key in o.overrides)
+ .map((o) => ({
+ conditions: o.conditions,
+ overrides: o.overrides[key] as RawFrigateCardConfig,
+ })) ?? []
+ );
+}
+
+// A tiny wrapper interface to allow the same controller to be passed around
+// immutably within objects that will not be equal (===). Every state change
+// generates a new epoch. This is used for Lit rendering to ensure changes to
+// condition state are recognized as changes even though the controller is the
+// same.
+export interface ConditionControllerEpoch {
+ controller: Readonly;
+}
+
+export class ConditionController {
+ protected _state: ConditionState = {};
+ protected _epoch: ConditionControllerEpoch = this._createEpoch();
+ protected _stateListeners: (() => void)[] = [];
+
+ // Whether or not to include HA state in ConditionState. Doing so increases
+ // CPU usage as HA state is pumped out very fast, so this is only enabled if
+ // the configuration needs to consume it.
+ protected _hasHAStateConditions = false;
+ protected _mediaQueries: MediaQueryList[] = [];
+ protected _mediaQueryTrigger = () => this._triggerChange();
+
+ constructor(config?: FrigateCardConfig) {
+ if (config) {
+ this._initConditions(config);
+ }
+ }
+
+ public addStateListener(callback: () => void): void {
+ this._stateListeners.push(callback);
+ }
+
+ public removeStateListener(callback: () => void): void {
+ this._stateListeners = this._stateListeners.filter(
+ (listener) => listener != callback,
+ );
+ }
+
+ public destroy(): void {
+ this._mediaQueries.forEach((mql) =>
+ mql.removeEventListener('change', this._mediaQueryTrigger),
+ );
+ this._mediaQueries = [];
+ }
+
+ public setState(state: Partial): void {
+ this._state = {
+ ...this._state,
+ ...state,
+ };
+ this._triggerChange();
+ }
+
+ get hasHAStateConditions(): boolean {
+ return this._hasHAStateConditions;
+ }
+
+ public getEpoch(): ConditionControllerEpoch {
+ return this._epoch;
+ }
+
+ public evaluateCondition(
+ condition: Readonly,
+ stateOverrides?: Partial,
+ ): boolean {
+ const state = {
+ ...this._state,
+ ...stateOverrides,
+ };
+
+ let result = true;
+ if (condition.view?.length) {
+ result &&= !!state?.view && condition.view.includes(state.view);
+ }
+ if (condition.fullscreen !== undefined) {
+ result &&=
+ state.fullscreen !== undefined && condition.fullscreen == state.fullscreen;
+ }
+ if (condition.expand !== undefined) {
+ result &&= state.expand !== undefined && condition.expand == state.expand;
+ }
+ if (condition.camera?.length) {
+ result &&= !!state.camera && condition.camera.includes(state.camera);
+ }
+ if (condition.state?.length) {
+ for (const stateTest of condition.state) {
+ result &&=
+ !!state.state &&
+ ((!stateTest.state && !stateTest.state_not) ||
+ (stateTest.entity in state.state &&
+ (!stateTest.state ||
+ state.state[stateTest.entity].state === stateTest.state) &&
+ (!stateTest.state_not ||
+ state.state[stateTest.entity].state !== stateTest.state_not)));
+ }
+ }
+ if (condition.media_loaded !== undefined) {
+ result &&=
+ state.media_loaded !== undefined && condition.media_loaded == state.media_loaded;
+ }
+ if (condition.media_query) {
+ result &&= window.matchMedia(condition.media_query).matches;
+ }
+ return result;
+ }
+
+ protected _createEpoch(): ConditionControllerEpoch {
+ return { controller: this };
+ }
+
+ protected _triggerChange(): void {
+ this._epoch = this._createEpoch();
+ this._stateListeners.forEach((listener) => listener());
+ }
+
+ protected _initConditions(config: FrigateCardConfig): void {
+ const getAllConditions = (config: FrigateCardConfig): FrigateCardCondition[] => {
+ const conditions: FrigateCardCondition[] = [];
+ config.overrides?.forEach((override) => conditions.push(override.conditions));
+
+ // Element conditions can be arbitrarily nested underneath conditionals and
+ // custom elements that this card may not known. Here we recursively parse
+ // down the elements tree, parsing as we go to find valid conditions.
+ const getElementsConditions = (data: unknown): void => {
+ const parseResult = frigateConditionalSchema.safeParse(data);
+ if (parseResult.success) {
+ conditions.push(parseResult.data.conditions);
+ parseResult.data.elements?.forEach(getElementsConditions);
+ } else if (data && typeof data === 'object') {
+ Object.keys(data).forEach((key) => getElementsConditions(data[key]));
+ }
+ };
+ config.elements?.forEach(getElementsConditions);
+ return conditions;
+ };
+
+ const conditions = getAllConditions(config);
+ this._hasHAStateConditions = conditions.some(
+ (condition) => !!condition.state?.length,
+ );
+ conditions.forEach((condition) => {
+ if (condition.media_query) {
+ const mql = window.matchMedia(condition.media_query);
+ mql.addEventListener('change', this._mediaQueryTrigger);
+ this._mediaQueries.push(mql);
+ }
+ });
+ }
+}
diff --git a/src/const.ts b/src/const.ts
index 9e79c0b7..4fa722e6 100644
--- a/src/const.ts
+++ b/src/const.ts
@@ -104,6 +104,8 @@ export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP =
`${CONF_MEDIA_VIEWER}.snapshot_click_plays_clip` as const;
export const CONF_MEDIA_VIEWER_TRANSITION_EFFECT =
`${CONF_MEDIA_VIEWER}.transition_effect` as const;
+export const CONF_MEDIA_VIEWER_CONTROLS_BUILTIN =
+ `${CONF_MEDIA_VIEWER}.controls.builtin` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE =
`${CONF_MEDIA_VIEWER}.controls.next_previous.style` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
@@ -132,6 +134,7 @@ export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE =
`${CONF_MEDIA_VIEWER}.controls.timeline.style` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS =
`${CONF_MEDIA_VIEWER}.controls.timeline.window_seconds` as const;
+export const CONF_MEDIA_VIEWER_ZOOMABLE = `${CONF_MEDIA_VIEWER}.zoomable` as const;
export const CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE =
`${CONF_MEDIA_VIEWER}.controls.title.mode` as const;
@@ -148,6 +151,7 @@ export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const;
export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const;
export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const;
export const CONF_LIVE_AUTO_UNMUTE = `${CONF_LIVE}.auto_unmute` as const;
+export const CONF_LIVE_CONTROLS_BUILTIN = `${CONF_LIVE}.controls.builtin` as const;
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE =
`${CONF_LIVE}.controls.next_previous.style` as const;
export const CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE =
@@ -191,6 +195,11 @@ export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const;
export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as const;
export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
`${CONF_LIVE}.show_image_during_load` as const;
+export const CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS =
+ `${CONF_LIVE}.microphone.disconnect_seconds` as const;
+export const CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED =
+ `${CONF_LIVE}.microphone.always_connected` as const;
+export const CONF_LIVE_ZOOMABLE = `${CONF_LIVE}.zoomable` as const;
const CONF_IMAGE = 'image' as const;
export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const;
@@ -199,6 +208,7 @@ export const CONF_IMAGE_LAYOUT_POSITION_Y = `${CONF_IMAGE}.layout.position.y` as
export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
+export const CONF_IMAGE_ZOOMABLE = `${CONF_IMAGE}.zoomable` as const;
const CONF_TIMELINE = 'timeline' as const;
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
diff --git a/src/editor.ts b/src/editor.ts
index 2e637c7d..41d4d513 100644
--- a/src/editor.ts
+++ b/src/editor.ts
@@ -2,6 +2,11 @@ import { fireEvent, HomeAssistant, LovelaceCardEditor } from 'custom-card-helper
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
+import { FRIGATE_ICON_SVG_PATH } from './camera-manager/frigate/icon.js';
+import {
+ MOTIONEYE_ICON_SVG_PATH,
+ MOTIONEYE_ICON_SVG_VIEWBOX,
+} from './camera-manager/motioneye/icon.js';
import {
copyConfig,
deleteConfigValue,
@@ -12,6 +17,7 @@ import {
upgradeConfig,
} from './config-mgmt.js';
import {
+ CONF_CAMERAS,
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
CONF_CAMERAS_ARRAY_DEPENDENCIES_ALL_CAMERAS,
CONF_CAMERAS_ARRAY_DEPENDENCIES_CAMERAS,
@@ -39,19 +45,22 @@ import {
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
- CONF_CAMERAS,
- CONF_DIMENSIONS_ASPECT_RATIO_MODE,
CONF_DIMENSIONS_ASPECT_RATIO,
+ CONF_DIMENSIONS_ASPECT_RATIO_MODE,
+ CONF_DIMENSIONS_MAX_HEIGHT,
+ CONF_DIMENSIONS_MIN_HEIGHT,
CONF_IMAGE_LAYOUT_FIT,
CONF_IMAGE_LAYOUT_POSITION_X,
CONF_IMAGE_LAYOUT_POSITION_Y,
CONF_IMAGE_MODE,
CONF_IMAGE_REFRESH_SECONDS,
CONF_IMAGE_URL,
+ CONF_IMAGE_ZOOMABLE,
CONF_LIVE_AUTO_MUTE,
CONF_LIVE_AUTO_PAUSE,
CONF_LIVE_AUTO_PLAY,
CONF_LIVE_AUTO_UNMUTE,
+ CONF_LIVE_CONTROLS_BUILTIN,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
@@ -65,6 +74,7 @@ import {
CONF_LIVE_CONTROLS_TIMELINE_MEDIA,
CONF_LIVE_CONTROLS_TIMELINE_MODE,
CONF_LIVE_CONTROLS_TIMELINE_SHOW_RECORDINGS,
+ CONF_LIVE_CONTROLS_TIMELINE_STYLE,
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
CONF_LIVE_CONTROLS_TITLE_MODE,
@@ -74,9 +84,12 @@ import {
CONF_LIVE_LAYOUT_POSITION_Y,
CONF_LIVE_LAZY_LOAD,
CONF_LIVE_LAZY_UNLOAD,
+ CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
+ CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS,
CONF_LIVE_PRELOAD,
CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
CONF_LIVE_TRANSITION_EFFECT,
+ CONF_LIVE_ZOOMABLE,
CONF_MEDIA_GALLERY_CONTROLS_FILTER_MODE,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
CONF_MEDIA_GALLERY_CONTROLS_THUMBNAILS_SHOW_DOWNLOAD_CONTROL,
@@ -87,6 +100,7 @@ import {
CONF_MEDIA_VIEWER_AUTO_PAUSE,
CONF_MEDIA_VIEWER_AUTO_PLAY,
CONF_MEDIA_VIEWER_AUTO_UNMUTE,
+ CONF_MEDIA_VIEWER_CONTROLS_BUILTIN,
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
@@ -99,6 +113,7 @@ import {
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MEDIA,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_MODE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_SHOW_RECORDINGS,
+ CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
@@ -109,6 +124,7 @@ import {
CONF_MEDIA_VIEWER_LAZY_LOAD,
CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP,
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
+ CONF_MEDIA_VIEWER_ZOOMABLE,
CONF_MENU_ALIGNMENT,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_BUTTONS,
@@ -128,27 +144,24 @@ import {
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
CONF_TIMELINE_MEDIA,
CONF_TIMELINE_SHOW_RECORDINGS,
+ CONF_TIMELINE_STYLE,
CONF_TIMELINE_WINDOW_SECONDS,
CONF_VIEW_CAMERA_SELECT,
CONF_VIEW_DARK_MODE,
CONF_VIEW_DEFAULT,
+ CONF_VIEW_SCAN,
CONF_VIEW_SCAN_ENABLED,
CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS,
CONF_VIEW_SCAN_UNTRIGGER_RESET,
CONF_VIEW_SCAN_UNTRIGGER_SECONDS,
- CONF_VIEW_SCAN,
CONF_VIEW_TIMEOUT_SECONDS,
CONF_VIEW_UPDATE_CYCLE_CAMERA,
CONF_VIEW_UPDATE_FORCE,
CONF_VIEW_UPDATE_SECONDS,
MEDIA_CHUNK_SIZE_MAX,
- CONF_DIMENSIONS_MAX_HEIGHT,
- CONF_DIMENSIONS_MIN_HEIGHT,
- CONF_TIMELINE_STYLE,
- CONF_LIVE_CONTROLS_TIMELINE_STYLE,
- CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_STYLE,
} from './const.js';
import { localize } from './localize/localize.js';
+import { setLowPerformanceProfile } from './performance.js';
import frigate_card_editor_style from './scss/editor.scss';
import {
BUTTON_SIZE_MIN,
@@ -162,29 +175,23 @@ import {
} from './types.js';
import { arrayMove, prettifyTitle } from './utils/basic.js';
import { getCameraID } from './utils/camera.js';
-import { FRIGATE_ICON_SVG_PATH } from './camera-manager/frigate/icon.js';
import {
getEntitiesFromHASS,
getEntityTitle,
sideLoadHomeAssistantElements,
} from './utils/ha';
-import { setLowPerformanceProfile } from './performance.js';
-import {
- MOTIONEYE_ICON_SVG_PATH,
- MOTIONEYE_ICON_SVG_VIEWBOX,
-} from './camera-manager/motioneye/icon.js';
const MENU_BUTTONS = 'buttons';
const MENU_CAMERAS = 'cameras';
const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
+const MENU_CAMERAS_ENGINE = 'cameras.engine';
const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
const MENU_CAMERAS_IMAGE = 'cameras.image';
+const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
-const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
-const MENU_CAMERAS_ENGINE = 'cameras.engine';
const MENU_IMAGE_LAYOUT = 'image.layout';
const MENU_LIVE_CONTROLS = 'live.controls';
const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous';
@@ -192,18 +199,19 @@ const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails';
const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
const MENU_LIVE_LAYOUT = 'live.layout';
-const MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS = 'media_gallery.controls.thumbnails';
+const MENU_LIVE_MICROPHONE = 'live.microphone';
const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter';
+const MENU_MEDIA_GALLERY_CONTROLS_THUMBNAILS = 'media_gallery.controls.thumbnails';
const MENU_MEDIA_VIEWER_CONTROLS = 'media_viewer.controls';
const MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS = 'media_viewer.controls.next_previous';
const MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS = 'media_viewer.controls.thumbnails';
const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline';
const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title';
const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
-const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
const MENU_OPTIONS = 'options';
const MENU_PERFORMANCE_FEATURES = 'performance.features';
const MENU_PERFORMANCE_STYLE = 'performance.style';
+const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
const MENU_VIEW_SCAN = 'scan';
interface EditorOptionsSet {
@@ -531,6 +539,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'mjpeg', label: localize('config.cameras.go2rtc.modes.mjpeg') },
];
+ protected _microphoneButtonTypes: EditorSelectOption[] = [
+ { value: '', label: '' },
+ { value: 'momentary', label: localize('config.menu.buttons.types.momentary') },
+ { value: 'toggle', label: localize('config.menu.buttons.types.toggle') },
+ ];
+
public setConfig(config: RawFrigateCardConfig): void {
// Note: This does not use Zod to parse the configuration, so it may be
// partially or completely invalid. It's more useful to have a partially
@@ -836,7 +850,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
* @param button The name of the button.
* @returns A rendered template.
*/
- protected _renderMenuButton(button: string): TemplateResult {
+ protected _renderMenuButton(
+ button: string,
+ additionalOptions?: TemplateResult,
+ ): TemplateResult {
const menuButtonAlignments: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'matching', label: localize('config.menu.buttons.alignments.matching') },
@@ -887,6 +904,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderIconSelector(`${CONF_MENU_BUTTONS}.${button}.icon`, {
label: localize('config.menu.buttons.icon'),
})}
+ ${additionalOptions}
`
: ''}
@@ -994,10 +1012,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
configPathShowRecordings: string,
defaultShowRecordings: boolean,
): TemplateResult {
- return html`
- ${this._renderOptionSelector(configPathStyle, this._timelineStyleTypes, {
- label: localize(`config.common.${CONF_TIMELINE_STYLE}`),
- })}
+ return html` ${this._renderOptionSelector(
+ configPathStyle,
+ this._timelineStyleTypes,
+ {
+ label: localize(`config.common.${CONF_TIMELINE_STYLE}`),
+ },
+ )}
${this._renderNumberInput(configPathWindowSeconds, {
label: localize(`config.common.${CONF_TIMELINE_WINDOW_SECONDS}`),
})}
@@ -1755,6 +1776,16 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderMenuButton('expand') /* */}
${this._renderMenuButton('timeline')}
${this._renderMenuButton('media_player')}
+ ${this._renderMenuButton(
+ 'microphone',
+ html`${this._renderOptionSelector(
+ `${CONF_MENU_BUTTONS}.microphone.type`,
+ this._microphoneButtonTypes,
+ { label: localize('config.menu.buttons.type') },
+ )}`,
+ )}
+ ${this._renderMenuButton('play') /* */}
+ ${this._renderMenuButton('mute')}
`
: ''}
@@ -1764,6 +1795,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderSwitch(CONF_LIVE_PRELOAD, this._defaults.live.preload)}
${this._renderSwitch(CONF_LIVE_DRAGGABLE, this._defaults.live.draggable)}
+ ${this._renderSwitch(CONF_LIVE_ZOOMABLE, this._defaults.live.zoomable)}
${this._renderSwitch(CONF_LIVE_LAZY_LOAD, this._defaults.live.lazy_load)}
${this._renderOptionSelector(
CONF_LIVE_LAZY_UNLOAD,
@@ -1799,6 +1831,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
'config.live.controls.editor_label',
{ name: 'mdi:gamepad' },
html`
+ ${this._renderSwitch(
+ CONF_LIVE_CONTROLS_BUILTIN,
+ this._defaults.live.controls.builtin,
+ {
+ label: localize('config.common.controls.builtin'),
+ },
+ )}
${this._renderNextPreviousControls(
MENU_LIVE_CONTROLS_NEXT_PREVIOUS,
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
@@ -1844,6 +1883,19 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_LIVE_LAYOUT_POSITION_X,
CONF_LIVE_LAYOUT_POSITION_Y,
)}
+ ${this._putInSubmenu(
+ MENU_LIVE_MICROPHONE,
+ true,
+ 'config.live.microphone.editor_label',
+ { name: 'mdi:microphone' },
+ html`
+ ${this._renderNumberInput(CONF_LIVE_MICROPHONE_DISCONNECT_SECONDS)}
+ ${this._renderSwitch(
+ CONF_LIVE_MICROPHONE_ALWAYS_CONNECTED,
+ this._defaults.live.microphone.always_connected,
+ )}
+ `,
+ )}
`
: ''}
@@ -1888,6 +1940,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_MEDIA_VIEWER_DRAGGABLE,
this._defaults.media_viewer.draggable,
)}
+ ${this._renderSwitch(
+ CONF_MEDIA_VIEWER_ZOOMABLE,
+ this._defaults.media_viewer.zoomable,
+ )}
${this._renderSwitch(
CONF_MEDIA_VIEWER_LAZY_LOAD,
this._defaults.media_viewer.lazy_load,
@@ -1906,6 +1962,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
'config.media_viewer.controls.editor_label',
{ name: 'mdi:gamepad' },
html`
+ ${this._renderSwitch(
+ CONF_MEDIA_VIEWER_CONTROLS_BUILTIN,
+ this._defaults.media_viewer.controls.builtin,
+ {
+ label: localize('config.common.controls.builtin'),
+ },
+ )}
${this._renderNextPreviousControls(
MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS,
CONF_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
@@ -1958,6 +2021,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderOptionSelector(CONF_IMAGE_MODE, this._imageModes)}
${this._renderStringInput(CONF_IMAGE_URL)}
${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)}
+ ${this._renderSwitch(CONF_IMAGE_ZOOMABLE, this._defaults.image.zoomable)}
${this._renderMediaLayout(
MENU_IMAGE_LAYOUT,
'config.image.layout',
diff --git a/src/external/go2rtc/README.md b/src/external/go2rtc/README.md
deleted file mode 100644
index 1fc4c6bb..00000000
--- a/src/external/go2rtc/README.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# go2rtc Player
-
-**Link**: https://github.com/AlexxIT/go2rtc/tree/master/www
-
-**Description**: A video player imported from go2rtc.
-
-**Copyright**: [Alexey Khit](https://github.com/AlexxIT)
-
-**License**: [MIT](https://github.com/AlexxIT/go2rtc/blob/master/LICENSE)
diff --git a/src/external/go2rtc/video-rtc.js b/src/external/go2rtc/video-rtc.js
deleted file mode 100644
index 445aa94d..00000000
--- a/src/external/go2rtc/video-rtc.js
+++ /dev/null
@@ -1,597 +0,0 @@
-/**
- * Video player for go2rtc streaming application.
- *
- * All modern web technologies are supported in almost any browser except Apple Safari.
- *
- * Support:
- * - RTCPeerConnection for Safari iOS 11.0+
- * - IntersectionObserver for Safari iOS 12.2+
- *
- * Doesn't support:
- * - MediaSource for Safari iOS all
- * - Customized built-in elements (extends HTMLVideoElement) because all Safari
- * - Public class fields because old Safari (before 14.0)
- * - Autoplay for Safari
- */
-export class VideoRTC extends HTMLElement {
- constructor() {
- super();
-
- this.DISCONNECT_TIMEOUT = 5000;
- this.RECONNECT_TIMEOUT = 30000;
-
- this.CODECS = [
- "avc1.640029", // H.264 high 4.1 (Chromecast 1st and 2nd Gen)
- "avc1.64002A", // H.264 high 4.2 (Chromecast 3rd Gen)
- "avc1.640033", // H.264 high 5.1 (Chromecast with Google TV)
- "hvc1.1.6.L153.B0", // H.265 main 5.1 (Chromecast Ultra)
- "mp4a.40.2", // AAC LC
- "mp4a.40.5", // AAC HE
- "opus", // OPUS Chrome
- ];
-
- /**
- * [config] Supported modes (webrtc, mse, mp4, mjpeg).
- * @type {string}
- */
- this.mode = "webrtc,mse,mp4,mjpeg";
-
- /**
- * [config] Run stream when not displayed on the screen. Default `false`.
- * @type {boolean}
- */
- this.background = false;
-
- /**
- * [config] Run stream only when player in the viewport. Stop when user scroll out player.
- * Value is percentage of visibility from `0` (not visible) to `1` (full visible).
- * Default `0` - disable;
- * @type {number}
- */
- this.visibilityThreshold = 0;
-
- /**
- * [config] Run stream only when browser page on the screen. Stop when user change browser
- * tab or minimise browser windows.
- * @type {boolean}
- */
- this.visibilityCheck = true;
-
- /**
- * [config] WebRTC configuration
- * @type {RTCConfiguration}
- */
- this.pcConfig = {
- iceServers: [{urls: 'stun:stun.l.google.com:19302'}],
- sdpSemantics: 'unified-plan', // important for Chromecast 1
- };
-
- /**
- * [info] WebSocket connection state. Values: CONNECTING, OPEN, CLOSED
- * @type {number}
- */
- this.wsState = WebSocket.CLOSED;
-
- /**
- * [info] WebRTC connection state.
- * @type {number}
- */
- this.pcState = WebSocket.CLOSED;
-
- /**
- * @type {HTMLVideoElement}
- */
- this.video = null;
-
- /**
- * @type {WebSocket}
- */
- this.ws = null;
-
- /**
- * @type {string|URL}
- */
- this.wsURL = "";
-
- /**
- * @type {RTCPeerConnection}
- */
- this.pc = null;
-
- /**
- * @type {number}
- */
- this.connectTS = 0;
-
- /**
- * @type {string}
- */
- this.mseCodecs = "";
-
- /**
- * [internal] Disconnect TimeoutID.
- * @type {number}
- */
- this.disconnectTID = 0;
-
- /**
- * [internal] Reconnect TimeoutID.
- * @type {number}
- */
- this.reconnectTID = 0;
-
- /**
- * [internal] Handler for receiving Binary from WebSocket.
- * @type {Function}
- */
- this.ondata = null;
-
- /**
- * [internal] Handlers list for receiving JSON from WebSocket
- * @type {Object.}}
- */
- this.onmessage = null;
- }
-
- /**
- * Set video source (WebSocket URL). Support relative path.
- * @param {string|URL} value
- */
- set src(value) {
- if (typeof value !== "string") value = value.toString();
- if (value.startsWith("http")) {
- value = "ws" + value.substring(4);
- } else if (value.startsWith("/")) {
- value = "ws" + location.origin.substring(4) + value;
- }
-
- this.wsURL = value;
-
- this.onconnect();
- }
-
- /**
- * Play video. Support automute when autoplay blocked.
- * https://developer.chrome.com/blog/autoplay/
- */
- play() {
- this.video.play().catch(er => {
- if (er.name === "NotAllowedError" && !this.video.muted) {
- this.video.muted = true;
- this.video.play().catch(() => console.debug);
- }
- });
- }
-
- /**
- * Send message to server via WebSocket
- * @param {Object} value
- */
- send(value) {
- if (this.ws) this.ws.send(JSON.stringify(value));
- }
-
- codecs(type) {
- const test = type === "mse"
- ? codec => MediaSource.isTypeSupported(`video/mp4; codecs="${codec}"`)
- : codec => this.video.canPlayType(`video/mp4; codecs="${codec}"`);
- return this.CODECS.filter(test).join();
- }
-
- /**
- * `CustomElement`. Invoked each time the custom element is appended into a
- * document-connected element.
- */
- connectedCallback() {
- if (this.disconnectTID) {
- clearTimeout(this.disconnectTID);
- this.disconnectTID = 0;
- }
-
- // because video autopause on disconnected from DOM
- if (this.video) {
- const seek = this.video.seekable;
- if (seek.length > 0) {
- this.video.currentTime = seek.end(seek.length - 1);
- }
- this.play();
- } else {
- this.oninit();
- }
-
- this.onconnect();
- }
-
- /**
- * `CustomElement`. Invoked each time the custom element is disconnected from the
- * document's DOM.
- */
- disconnectedCallback() {
- if (this.background || this.disconnectTID) return;
- if (this.wsState === WebSocket.CLOSED && this.pcState === WebSocket.CLOSED) return;
-
- this.disconnectTID = setTimeout(() => {
- if (this.reconnectTID) {
- clearTimeout(this.reconnectTID);
- this.reconnectTID = 0;
- }
-
- this.disconnectTID = 0;
-
- this.ondisconnect();
- }, this.DISCONNECT_TIMEOUT);
- }
-
- /**
- * Creates child DOM elements. Called automatically once on `connectedCallback`.
- */
- oninit() {
- this.video = document.createElement("video");
- this.video.controls = true;
- this.video.playsInline = true;
- this.video.preload = "auto";
-
- this.video.style.display = "block"; // fix bottom margin 4px
- this.video.style.width = "100%";
- this.video.style.height = "100%"
-
- this.appendChild(this.video);
-
- if (this.background) return;
-
- if ("hidden" in document && this.visibilityCheck) {
- document.addEventListener("visibilitychange", () => {
- if (document.hidden) {
- this.disconnectedCallback();
- } else if (this.isConnected) {
- this.connectedCallback();
- }
- })
- }
-
- if ("IntersectionObserver" in window && this.visibilityThreshold) {
- const observer = new IntersectionObserver(entries => {
- entries.forEach(entry => {
- if (!entry.isIntersecting) {
- this.disconnectedCallback();
- } else if (this.isConnected) {
- this.connectedCallback();
- }
- });
- }, {threshold: this.visibilityThreshold});
- observer.observe(this);
- }
- }
-
- /**
- * Connect to WebSocket. Called automatically on `connectedCallback`.
- * @return {boolean} true if the connection has started.
- */
- onconnect() {
- if (!this.isConnected || !this.wsURL || this.ws || this.pc) return false;
-
- // CLOSED or CONNECTING => CONNECTING
- this.wsState = WebSocket.CONNECTING;
-
- this.connectTS = Date.now();
-
- this.ws = new WebSocket(this.wsURL);
- this.ws.binaryType = "arraybuffer";
- this.ws.addEventListener("open", ev => this.onopen(ev));
- this.ws.addEventListener("close", ev => this.onclose(ev));
-
- return true;
- }
-
- ondisconnect() {
- this.wsState = WebSocket.CLOSED;
- if (this.ws) {
- this.ws.close();
- this.ws = null;
- }
-
- this.pcState = WebSocket.CLOSED;
- if (this.pc) {
- this.pc.close();
- this.pc = null;
- }
- }
-
- /**
- * @returns {Array.} of modes (mse, webrtc, etc.)
- */
- onopen() {
- // CONNECTING => OPEN
- this.wsState = WebSocket.OPEN;
-
- this.ws.addEventListener("message", ev => {
- if (typeof ev.data === "string") {
- const msg = JSON.parse(ev.data);
- for (const mode in this.onmessage) {
- this.onmessage[mode](msg);
- }
- } else {
- this.ondata(ev.data);
- }
- });
-
- this.ondata = null;
- this.onmessage = {};
-
- const modes = [];
-
- if (this.mode.indexOf("mse") >= 0 && "MediaSource" in window) { // iPhone
- modes.push("mse");
- this.onmse();
- } else if (this.mode.indexOf("mp4") >= 0) {
- modes.push("mp4");
- this.onmp4();
- }
-
- if (this.mode.indexOf("webrtc") >= 0 && "RTCPeerConnection" in window) { // macOS Desktop app
- modes.push("webrtc");
- this.onwebrtc();
- }
-
- if (this.mode.indexOf("mjpeg") >= 0) {
- if (modes.length) {
- this.onmessage["mjpeg"] = msg => {
- if (msg.type !== "error" || msg.value.indexOf(modes[0]) !== 0) return;
- this.onmjpeg();
- }
- } else {
- modes.push("mjpeg");
- this.onmjpeg();
- }
- }
-
- return modes;
- }
-
- /**
- * @return {boolean} true if reconnection has started.
- */
- onclose() {
- if (this.wsState === WebSocket.CLOSED) return false;
-
- // CONNECTING, OPEN => CONNECTING
- this.wsState = WebSocket.CONNECTING;
- this.ws = null;
-
- // reconnect no more than once every X seconds
- const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
-
- this.reconnectTID = setTimeout(() => {
- this.reconnectTID = 0;
- this.onconnect();
- }, delay);
-
- return true;
- }
-
- onmse() {
- const ms = new MediaSource();
- ms.addEventListener("sourceopen", () => {
- URL.revokeObjectURL(this.video.src);
- this.send({type: "mse", value: this.codecs("mse")});
- }, {once: true});
-
- this.video.src = URL.createObjectURL(ms);
- this.video.srcObject = null;
- this.play();
-
- this.mseCodecs = "";
-
- this.onmessage["mse"] = msg => {
- if (msg.type !== "mse") return;
-
- this.mseCodecs = msg.value;
-
- const sb = ms.addSourceBuffer(msg.value);
- sb.mode = "segments"; // segments or sequence
- sb.addEventListener("updateend", () => {
- if (sb.updating) return;
-
- try {
- if (bufLen > 0) {
- const data = buf.slice(0, bufLen);
- bufLen = 0;
- sb.appendBuffer(data);
- } else if (sb.buffered && sb.buffered.length) {
- const end = sb.buffered.end(sb.buffered.length - 1) - 15;
- const start = sb.buffered.start(0);
- if (end > start) {
- sb.remove(start, end);
- ms.setLiveSeekableRange(end, end + 15);
- }
- // console.debug("VideoRTC.buffered", start, end);
- }
- } catch (e) {
- // console.debug(e);
- }
- });
-
- const buf = new Uint8Array(2 * 1024 * 1024);
- let bufLen = 0;
-
- this.ondata = data => {
- if (sb.updating || bufLen > 0) {
- const b = new Uint8Array(data);
- buf.set(b, bufLen);
- bufLen += b.byteLength;
- // console.debug("VideoRTC.buffer", b.byteLength, bufLen);
- } else {
- try {
- sb.appendBuffer(data);
- } catch (e) {
- // console.debug(e);
- }
- }
- }
- }
- }
-
- onwebrtc() {
- const pc = new RTCPeerConnection(this.pcConfig);
-
- /** @type {HTMLVideoElement} */
- const video2 = document.createElement("video");
- video2.addEventListener("loadeddata", ev => this.onpcvideo(ev), {once: true});
-
- pc.addEventListener("icecandidate", ev => {
- const candidate = ev.candidate ? ev.candidate.toJSON().candidate : "";
- this.send({type: "webrtc/candidate", value: candidate});
- });
-
- pc.addEventListener("track", ev => {
- // when stream already init
- if (video2.srcObject !== null) return;
-
- // when audio track not exist in Chrome
- if (ev.streams.length === 0) return;
-
- // when audio track not exist in Firefox
- if (ev.streams[0].id[0] === '{') return;
-
- video2.srcObject = ev.streams[0];
- });
-
- pc.addEventListener("connectionstatechange", () => {
- if (pc.connectionState === "failed" || pc.connectionState === "disconnected") {
- pc.close(); // stop next events
-
- this.pcState = WebSocket.CLOSED;
- this.pc = null;
-
- this.onconnect();
- }
- });
-
- this.onmessage["webrtc"] = msg => {
- switch (msg.type) {
- case "webrtc/candidate":
- pc.addIceCandidate({
- candidate: msg.value,
- sdpMid: "0"
- }).catch(() => console.debug);
- break;
- case "webrtc/answer":
- pc.setRemoteDescription({
- type: "answer",
- sdp: msg.value
- }).catch(() => console.debug);
- break;
- case "error":
- if (msg.value.indexOf("webrtc/offer") < 0) return;
- pc.close();
- }
- };
-
- // Safari doesn't support "offerToReceiveVideo"
- pc.addTransceiver("video", {direction: "recvonly"});
- pc.addTransceiver("audio", {direction: "recvonly"});
-
- pc.createOffer().then(offer => {
- pc.setLocalDescription(offer).then(() => {
- this.send({type: "webrtc/offer", value: offer.sdp});
- });
- });
-
- this.pcState = WebSocket.CONNECTING;
- this.pc = pc;
- }
-
- /**
- * @param ev {Event}
- */
- onpcvideo(ev) {
- if (!this.pc) return;
-
- /** @type {HTMLVideoElement} */
- const video2 = ev.target;
- const state = this.pc.connectionState;
-
- // Firefox doesn't support pc.connectionState
- if (state === "connected" || state === "connecting" || !state) {
- // Video+Audio > Video, H265 > H264, Video > Audio, WebRTC > MSE
- let rtcPriority = 0, msePriority = 0;
-
- /** @type {MediaStream} */
- const ms = video2.srcObject;
- if (ms.getVideoTracks().length > 0) rtcPriority += 0x220;
- if (ms.getAudioTracks().length > 0) rtcPriority += 0x102;
-
- if (this.mseCodecs.indexOf("hvc1.") >= 0) msePriority += 0x230;
- if (this.mseCodecs.indexOf("avc1.") >= 0) msePriority += 0x210;
- if (this.mseCodecs.indexOf("mp4a.") >= 0) msePriority += 0x101;
-
- if (rtcPriority >= msePriority) {
- this.video.srcObject = ms;
- this.play();
-
- this.pcState = WebSocket.OPEN;
-
- this.wsState = WebSocket.CLOSED;
- this.ws.close();
- this.ws = null;
- } else {
- this.pcState = WebSocket.CLOSED;
- this.pc.close();
- this.pc = null;
- }
- }
-
- video2.srcObject = null;
- }
-
- onmjpeg() {
- this.ondata = data => {
- this.video.controls = false;
- this.video.poster = "data:image/jpeg;base64," + VideoRTC.btoa(data);
- };
-
- this.send({type: "mjpeg"});
- }
-
- onmp4() {
- /** @type {HTMLCanvasElement} **/
- const canvas = document.createElement("canvas");
- /** @type {CanvasRenderingContext2D} */
- let context;
-
- /** @type {HTMLVideoElement} */
- const video2 = document.createElement("video");
- video2.autoplay = true;
- video2.playsInline = true;
- video2.muted = true;
-
- video2.addEventListener("loadeddata", ev => {
- if (!context) {
- canvas.width = video2.videoWidth;
- canvas.height = video2.videoHeight;
- context = canvas.getContext('2d');
- }
-
- context.drawImage(video2, 0, 0, canvas.width, canvas.height);
-
- this.video.controls = false;
- this.video.poster = canvas.toDataURL("image/jpeg");
- });
-
- this.ondata = data => {
- video2.src = "data:video/mp4;base64," + VideoRTC.btoa(data);
- };
-
- this.send({type: "mp4", value: this.codecs("mp4")});
- }
-
- static btoa(buffer) {
- const bytes = new Uint8Array(buffer);
- const len = bytes.byteLength;
- let binary = "";
- for (let i = 0; i < len; i++) {
- binary += String.fromCharCode(bytes[i]);
- }
- return window.btoa(binary);
- }
-}
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json
index c4d70334..59b48fdb 100644
--- a/src/localize/languages/en.json
+++ b/src/localize/languages/en.json
@@ -84,6 +84,7 @@
},
"common": {
"controls": {
+ "builtin": "Built-in video controls",
"filter": {
"editor_label": "Media Filter",
"mode": "Filter mode",
@@ -204,7 +205,8 @@
"url": "Arbitrary image specified by URL"
},
"refresh_seconds": "Number of seconds after which to refresh (0=never)",
- "url": "Static image URL for image view"
+ "url": "Static image URL for image view",
+ "zoomable": "Image can be zoomed/panned"
},
"live": {
"auto_mute": "Automatically mute live cameras",
@@ -218,9 +220,16 @@
"layout": "Live Layout",
"lazy_load": "Live cameras are lazily loaded",
"lazy_unload": "Live cameras are lazily unloaded",
+ "microphone": {
+ "always_connected": "Always keep the microphone connected",
+ "disconnect_seconds": "Seconds after which to disconnect microphone (0=never)",
+ "editor_label": "Microphone",
+ "enabled": "Microphone enabled"
+ },
"preload": "Preload live view in the background",
"show_image_during_load": "Show still image while the live stream is loading",
- "transition_effect": "Live camera transition effect"
+ "transition_effect": "Live camera transition effect",
+ "zoomable": "Live cameras can be zoomed/panned"
},
"media_viewer": {
"auto_mute": "Automatically mute media",
@@ -238,7 +247,8 @@
"transition_effects": {
"none": "No transition",
"slide": "Slide transition"
- }
+ },
+ "zoomable": "Media Viewer can be zoomed/panned"
},
"menu": {
"alignment": "Menu alignment",
@@ -267,11 +277,19 @@
"image": "Image",
"live": "Live",
"media_player": "Send to media player",
+ "microphone": "Microphone",
+ "mute": "Mute / Unmute",
+ "play": "Play / Pause",
"priority": "Priority",
"recordings": "Recordings",
"snapshots": "Snapshots",
"substreams": "Substream(s)",
- "timeline": "Timeline"
+ "timeline": "Timeline",
+ "type": "Button type",
+ "types": {
+ "momentary": "Momentary",
+ "toggle": "Toggle"
+ }
},
"position": "Menu position",
"positions": {
@@ -417,6 +435,7 @@
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
"reconnecting": "Reconnecting",
"timeline_no_cameras": "No Frigate cameras to show in timeline",
+ "too_many_automations": "Too many nested automation calls, please check your configuration for loops",
"troubleshooting": "Check troubleshooting",
"unknown": "Unknown error",
"upgrade_available": "An automated card configuration upgrade is available, please visit the visual card editor",
diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json
index 500571fd..bc9653f7 100644
--- a/src/localize/languages/it.json
+++ b/src/localize/languages/it.json
@@ -84,6 +84,7 @@
},
"common": {
"controls": {
+ "builtin": "",
"filter": {
"editor_label": "Filtro multimediale",
"mode": "Modalità filtro",
@@ -204,7 +205,8 @@
"url": "Immagine arbitraria specificata dall'URL"
},
"refresh_seconds": "Numero di secondi dopo i quali aggiornare (0 = mai)",
- "url": "URL di immagine statica per la vista dell'immagine"
+ "url": "URL di immagine statica per la vista dell'immagine",
+ "zoomable": ""
},
"live": {
"auto_mute": "Muta automaticamente le telecamere in diretta",
@@ -218,9 +220,16 @@
"layout": "Disposizione dal vivo",
"lazy_load": "Le telecamere dal vivo sono pigramente cariche",
"lazy_unload": "Le telecamere dal vivo sono pigramente non caricate",
+ "microphone": {
+ "always_connected": "",
+ "disconnect_seconds": "",
+ "editor_label": "",
+ "enabled": ""
+ },
"preload": "Precarica Live View in background",
"show_image_during_load": "Mostra un'immagine fissa durante il caricamento del live streaming",
- "transition_effect": "Effetto di transizione della telecamera dal vivo"
+ "transition_effect": "Effetto di transizione della telecamera dal vivo",
+ "zoomable": ""
},
"media_viewer": {
"auto_mute": "Muta automaticamente i media",
@@ -237,7 +246,8 @@
"transition_effects": {
"none": "Nessuna transizione",
"slide": "Transizione diapositiva"
- }
+ },
+ "zoomable": ""
},
"menu": {
"alignment": "Allineamento dei menu",
@@ -266,10 +276,17 @@
"image": "Immagine",
"live": "Abitare",
"media_player": "Invia a Media Player",
+ "mute": "",
+ "play": "",
"priority": "Priorità",
"snapshots": "Istantanee",
"substreams": "Flusso/i secondario/i",
- "timeline": "Timeline"
+ "timeline": "Timeline",
+ "type": "",
+ "types": {
+ "momentary": "",
+ "toggle": ""
+ }
},
"position": "Posizione del menu",
"positions": {
@@ -411,6 +428,7 @@
"reconnecting": "Riconnessione",
"timeline_no_cameras": "Nessuna telecamera damostrare in Frigate nella timeline",
"troubleshooting": "Controllare la risoluzione dei problemi",
+ "too_many_automations": "",
"unknown": "Errore sconosciuto",
"upgrade_available": "È disponibile un aggiornamento di configurazione della scheda automatizzato, visitare l'editor di schede visive",
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json
index cbb19e24..eb978cce 100644
--- a/src/localize/languages/pt-BR.json
+++ b/src/localize/languages/pt-BR.json
@@ -84,6 +84,7 @@
},
"common": {
"controls": {
+ "builtin": "",
"filter": {
"editor_label": "Filtro de Mídia",
"mode": "Modo do filtro",
@@ -204,7 +205,8 @@
"url": "Imagem arbitrária especificada por URL"
},
"refresh_seconds": "Número de segundos após o qual atualizar (0 = nunca)",
- "url": "Imagem arbitrária especificada por URL"
+ "url": "Imagem arbitrária especificada por URL",
+ "zoomable": ""
},
"live": {
"auto_mute": "Silenciar câmeras ao vivo automaticamente",
@@ -218,9 +220,16 @@
"layout": "Layout dinâmico",
"lazy_load": "As câmeras ao vivo são carregadas lentamente",
"lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente",
+ "microphone": {
+ "always_connected": "",
+ "disconnect_seconds": "",
+ "editor_label": "",
+ "enabled": ""
+ },
"preload": "Pré-carregar a visualização ao vivo em segundo plano",
"show_image_during_load": "Mostrar imagem estática enquanto a transmissão ao vivo está carregando",
- "transition_effect": "Efeito de transição de câmera ao vivo"
+ "transition_effect": "Efeito de transição de câmera ao vivo",
+ "zoomable": ""
},
"media_viewer": {
"auto_mute": "Silenciar mídia automaticamente",
@@ -238,7 +247,8 @@
"transition_effects": {
"none": "Sem transição",
"slide": "Transição de slides"
- }
+ },
+ "zoomable": ""
},
"menu": {
"alignment": "Alinhamento do menu",
@@ -267,11 +277,18 @@
"image": "Imagem",
"live": "Ao vivo",
"media_player": "Enviar para o reprodutor de mídia",
+ "mute": "",
+ "play": "",
"priority": "Prioridade",
"recordings": "Gravações",
"snapshots": "Instantâneos",
"substreams": "Substream(s)",
- "timeline": "Linha do tempo"
+ "timeline": "Linha do tempo",
+ "type": "",
+ "types": {
+ "momentary": "",
+ "toggle": ""
+ }
},
"position": "Posição do menu",
"positions": {
@@ -417,6 +434,7 @@
"no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta",
"reconnecting": "Reconectando",
"timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
+ "too_many_automations": "",
"troubleshooting": "Verifique a solução de problemas",
"unknown": "Erro desconhecido",
"upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json
new file mode 100644
index 00000000..ccb210ea
--- /dev/null
+++ b/src/localize/languages/pt-PT.json
@@ -0,0 +1,488 @@
+{
+ "common": {
+ "frigate_card": "Cartão Frigate",
+ "frigate_card_description": "Um cartão da Lovelace para usar com Frigate",
+ "live": "Ao Vivo",
+ "no_media": "Sem média",
+ "recordings": "Gravações",
+ "version": "Versão"
+ },
+ "config": {
+ "cameras": {
+ "camera_entity": "Entidade da Câmera",
+ "dependencies": {
+ "all_cameras": "Mostrar eventos para todas as câmeras nesta câmera",
+ "cameras": "Mostrar eventos para câmeras específicas nesta câmera",
+ "editor_label": "Opções de dependência"
+ },
+ "engines": {
+ "editor_label": "Editor de etiquetas"
+ },
+ "frigate": {
+ "camera_name": "Nome da câmera do Frigate (detectado automaticamente pela entidade)",
+ "client_id": "ID do cliente do Frigate (para >1 servidor Frigate)",
+ "editor_label": "Opções do Frigate",
+ "labels": "Etiquetas",
+ "url": "URL do servidor Frigate",
+ "zones": "Zonas"
+ },
+ "go2rtc": {
+ "editor_label": "Editor de etiquetas",
+ "modes": {
+ "editor_label": "Editor de etiquetas",
+ "mjpeg": "Mjpeg",
+ "mp4": "Mp4",
+ "mse": "Mse",
+ "webrtc": "Webrtc"
+ },
+ "stream": "Stream"
+ },
+ "hide": "Esconder",
+ "icon": "Ícone para esta câmera (detectado automaticamente pela entidade)",
+ "id": "ID exclusivo para esta câmera nesse cartão",
+ "image": {
+ "editor_label": "Editor etiquetas",
+ "refresh_seconds": "Atualizar em segundos",
+ "url": "Link"
+ },
+ "live_provider": "Fonte de visualização ao vivo para esta câmera",
+ "live_provider_options": {
+ "editor_label": "Editor de etiquetas"
+ },
+ "live_providers": {
+ "auto": "Automatico",
+ "go2rtc": "Go2rtc",
+ "ha": "Ha",
+ "image": "Imagem",
+ "jsmpeg": "JSMpeg",
+ "webrtc-card": "Cartão WebRTC (de @AlexxIT)"
+ },
+ "motioneye": {
+ "editor_label": "Directoria pre-definido",
+ "images": {
+ "directory_pattern": "Directoria pre-definido",
+ "file_pattern": "Ficheiro pre-definido"
+ },
+ "movies": {
+ "directory_pattern": "Directoria pre-definida",
+ "file_pattern": "Ficheiro pre-definido"
+ },
+ "url": "Link"
+ },
+ "title": "Título para esta câmera (detectado automaticamente pela entidade)",
+ "triggers": {
+ "editor_label": "Opções de activação",
+ "entities": "Activar a partir de outras entidades",
+ "motion": "Activar detectando automaticamente o sensor de movimento",
+ "occupancy": "Activar detectando automaticamente o sensor de ocupação"
+ },
+ "webrtc_card": {
+ "editor_label": "Opções do cartão WebRTC",
+ "entity": "Entidade de câmera de cartão WebRTC (não é uma câmera Frigate)",
+ "url": "URL da câmera do cartão WebRTC"
+ }
+ },
+ "common": {
+ "controls": {
+ "builtin": "",
+ "filter": {
+ "editor_label": "Editor de titulos",
+ "mode": "Modo",
+ "modes": {
+ "left": "Esquerda",
+ "none": "Nenhum",
+ "right": "Direita"
+ }
+ },
+ "next_previous": {
+ "editor_label": "Editor de titulos",
+ "size": "Tamanho de controle próximo e anterior",
+ "style": "Estilo do controle próximo e anterior",
+ "styles": {
+ "chevrons": "Setas",
+ "icons": "Ícones",
+ "none": "Nenhum",
+ "thumbnails": "Miniaturas"
+ }
+ },
+ "thumbnails": {
+ "editor_label": "Editor de titulos",
+ "media": "Mostrar miniaturas de clipes ou snapshots",
+ "medias": {
+ "clips": "Miniaturas de clipes",
+ "snapshots": "Miniaturas de Snapshots"
+ },
+ "mode": "Modos",
+ "modes": {
+ "above": "Miniaturas acima da mídia",
+ "below": "Miniaturas abaixo da mídia",
+ "left": "Miniaturas em uma gaveta à esquerda",
+ "none": "Sem miniaturas",
+ "right": "Miniaturas em uma gaveta à direita"
+ },
+ "show_details": "Mostrar detalhes",
+ "show_download_control": "Mostrar o botão de download",
+ "show_favorite_control": "Mostrar o botão de favorito nas miniaturas",
+ "show_timeline_control": "Mostrar a linha do tempo nas miniaturas",
+ "size": "Tamanho das miniaturas em pixels"
+ },
+ "timeline": {
+ "editor_label": "Controles de linha do tempo",
+ "mode": "Modo",
+ "modes": {
+ "above": "Por cima",
+ "below": "Abaixo",
+ "none": "Nenhum"
+ }
+ },
+ "title": {
+ "duration_seconds": "Segundos para exibir o pop-up (0 = para sempre)",
+ "editor_label": "Editor de titulos",
+ "mode": "Modo de exibição de título de mídia",
+ "modes": {
+ "none": "Sem exibição de título",
+ "popup-bottom-left": "Pop-up no canto inferior esquerdo",
+ "popup-bottom-right": "Pop-up no canto inferior direito",
+ "popup-top-left": "Pop-up no canto superior esquerdo",
+ "popup-top-right": "Pop-up no canto superior direito"
+ }
+ }
+ },
+ "layout": {
+ "fit": "Fit",
+ "fits": {
+ "contain": "Conter",
+ "cover": "Tapar",
+ "fill": "Preencher"
+ },
+ "position": {
+ "x": "Percentagem da localização horizontal",
+ "y": "Percentagem da localização vertical"
+ }
+ },
+ "media_action_conditions": {
+ "all": "Todas as oportunidades",
+ "hidden": "Ao ocultar o navegador/aba",
+ "never": "Nunca",
+ "selected": "Ao selecionar",
+ "unselected": "Ao desselecionar",
+ "visible": "Ao mostrar o navegador/aba"
+ },
+ "timeline": {
+ "clustering_threshold": "A contagem de eventos nos quais eles são agrupados (0 = sem agrupamento)",
+ "media": "A mídia que a linha do tempo exibe",
+ "medias": {
+ "all": "Todos os tipos de mídia",
+ "clips": "Clipes",
+ "snapshots": "Instantâneos"
+ },
+ "show_recordings": "Mostrar gravações",
+ "window_seconds": "A duração padrão da visualização da linha do tempo em segundos"
+ }
+ },
+ "dimensions": {
+ "aspect_ratio": "Proporção padrão (e.g. '16:9')",
+ "aspect_ratio_mode": "Modo de proporção",
+ "aspect_ratio_modes": {
+ "dynamic": "A proporção se ajusta à mídia",
+ "static": "Proporção estática",
+ "unconstrained": "Proporção irrestrita"
+ }
+ },
+ "image": {
+ "layout": "Layout",
+ "mode": "Modo de visualização de imagem",
+ "modes": {
+ "camera": "Instantâneo da câmera do Home Assistant, da entidade de câmera",
+ "screensaver": "Logo Frigate embutido",
+ "url": "Imagem arbitrária especificada por URL"
+ },
+ "refresh_seconds": "Número de segundos após o qual atualizar (0 = nunca)",
+ "url": "Imagem arbitrária especificada por URL",
+ "zoomable": ""
+ },
+ "live": {
+ "auto_mute": "Silenciar câmeras ao vivo automaticamente",
+ "auto_pause": "Parar câmeras ao vivo automaticamente",
+ "auto_play": "Reproduzir câmeras ao vivo automaticamente",
+ "auto_unmute": "Ativar automaticamente o som das câmeras ao vivo",
+ "controls": {
+ "editor_label": "Controles da visualização ao vivo"
+ },
+ "draggable": "A visualização ao vivo das câmeras pode ser arrastada/deslizada",
+ "layout": "layout",
+ "lazy_load": "As câmeras ao vivo são carregadas lentamente",
+ "lazy_unload": "As câmeras ao vivo são descarregadas preguiçosamente",
+ "microphone": {
+ "always_connected": "",
+ "disconnect_seconds": "",
+ "editor_label": "",
+ "enabled": ""
+ },
+ "preload": "Pré-carregar a visualização ao vivo em segundo plano",
+ "show_image_during_load": "Mostar imagem durante o carregamento",
+ "transition_effect": "Efeito de transição de câmera ao vivo",
+ "zoomable": ""
+ },
+ "media_viewer": {
+ "auto_mute": "Silenciar mídia automaticamente",
+ "auto_pause": "Parar mídia automaticamente",
+ "auto_play": "Reproduzir mídia automaticamente",
+ "auto_unmute": "Ativar mídia automaticamente",
+ "controls": {
+ "editor_label": "Controles do visualizador de mídia"
+ },
+ "draggable": "Visualizador de eventos pode ser arrastado/deslizado",
+ "layout": "Layout",
+ "lazy_load": "A mídia do Visualizador de eventos é carregada lentamente no carrossel",
+ "transition_effect": "Efeito de transição do Visualizador de eventos",
+ "transition_effects": {
+ "none": "Sem transição",
+ "slide": "Transição de slides"
+ },
+ "zoomable": ""
+ },
+ "menu": {
+ "alignment": "Alinhamento do menu",
+ "alignments": {
+ "bottom": "Alinhado à parte inferior",
+ "left": "Alinhado à esquerda",
+ "right": "Alinhado à direita",
+ "top": "Alinhado ao topo"
+ },
+ "button_size": "Tamanho do botão de menu (e.g. '40px')",
+ "buttons": {
+ "alignment": "Alinhamento do botão",
+ "alignments": {
+ "matching": "Mesmo alinhamento do menu",
+ "opposing": "Opor-se ao alinhamento do menu"
+ },
+ "camera_ui": "Camera",
+ "cameras": "Selecionar câmera",
+ "clips": "Clipes",
+ "download": "Descarregar mídia do evento",
+ "enabled": "Botão ativado",
+ "expand": "Expandir",
+ "frigate": "Frigate menu / Visualização padrão",
+ "fullscreen": "Tela cheia",
+ "icon": "Ícone",
+ "image": "Imagem",
+ "live": "Ao vivo",
+ "media_player": "Enviar para o reprodutor de mídia",
+ "mute": "",
+ "play": "",
+ "priority": "Prioridade",
+ "snapshots": "Instantâneos",
+ "substreams": "substreams",
+ "timeline": "Linha do tempo",
+ "type": "",
+ "types": {
+ "momentary": "",
+ "toggle": ""
+ }
+ },
+ "position": "Posição do menu",
+ "positions": {
+ "bottom": "Posicionado na parte inferior",
+ "left": "Posicionado à esquerda",
+ "right": "Posicionado à direita",
+ "top": "Posicionado no topo"
+ },
+ "style": "Estilo do menu",
+ "styles": {
+ "hidden": "Menu oculto",
+ "hover": "Menu suspenso",
+ "none": "Sem menu",
+ "outside": "Menu externo",
+ "overlay": "Menu sobreposto"
+ }
+ },
+ "overrides": {
+ "info": "Esta configuração do cartão especificou manualmente as substituições configuradas que podem substituir os valores mostrados no editor visual, consulte o editor de código para visualizar/modificar essas substituições"
+ },
+ "performance": {
+ "features": {
+ "animated_progress_indicator": "Animação na barra de progresso",
+ "editor_label": "Editor de etiquetas",
+ "media_chunk_size": "Tamanho do ficheiro"
+ },
+ "profile": "Perfil",
+ "profiles": {
+ "high": "Alto",
+ "low": "Baixo"
+ },
+ "style": {
+ "border_radius": "Tamanho do bordo",
+ "box_shadow": "Caixa de Fundo",
+ "editor_label": "Editor de etiquetas"
+ },
+ "warning": "Avisos"
+ },
+ "view": {
+ "camera_select": "Visualização de câmeras recém-selecionadas",
+ "dark_mode": "Modo escuro",
+ "dark_modes": {
+ "auto": "Automático",
+ "off": "Desligado",
+ "on": "Ligado"
+ },
+ "default": "Visualização padrão",
+ "scan": {
+ "enabled": "Modo scan ativado",
+ "scan_mode": "Modo scan",
+ "show_trigger_status": "Exibir estado do gatilho",
+ "untrigger_reset": "Redefinir a visualização para o padrão após desacionar",
+ "untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar"
+ },
+ "timeout_seconds": "Redefinir para a visualização padrão X segundos após a ação do usuário (0 = nunca)",
+ "update_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada",
+ "update_force": "Forçar atualizações do cartão (ignore a interação do Utilizador)",
+ "update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)",
+ "views": {
+ "clip": "Clipe mais recente",
+ "clips": "Galeria de clipes",
+ "current": "Visualização atual",
+ "image": "Imagem estática",
+ "live": "Visualização ao vivo",
+ "snapshot": "Snapshot mais recente",
+ "snapshots": "Galeria de Snapshots",
+ "timeline": "Visualização da linha do tempo"
+ }
+ }
+ },
+ "editor": {
+ "add_new_camera": "Adicionar nova câmera",
+ "button": "Botão",
+ "camera": "Câmera",
+ "cameras": "Câmeras",
+ "cameras_secondary": "Câmeras para renderizar neste cartão",
+ "delete": "Excluir",
+ "dimensions": "Dimensões",
+ "dimensions_secondary": "Dimensões e opções de forma",
+ "image": "Imagem",
+ "image_secondary": "Opções de visualização de imagem estática",
+ "live": "Ao vivo",
+ "live_secondary": "Opções de visualização da câmera ao vivo",
+ "media_gallery": "Galeria",
+ "media_gallery_secondary": "Galeria Secundaria",
+ "media_viewer": "Visualizador de eventos",
+ "media_viewer_secondary": "Opções do visualizador de Snapshots e clipes",
+ "menu": "Menu",
+ "menu_secondary": "Opções de aparência do menu",
+ "move_down": "Descer",
+ "move_up": "Subir",
+ "overrides": "As substituições estão ativas",
+ "overrides_secondary": "Substituições de configuração dinâmica detectadas",
+ "timeline": "Linha do tempo",
+ "timeline_secondary": "Opções do evento da linha do tempo",
+ "upgrade": "Actualização",
+ "upgrade_available": "Está disponível uma atualização automática do cartão",
+ "view": "Visualizar",
+ "view_secondary": "O que deve ser mostrado neste cartão"
+ },
+ "elements": {
+ "ptz": {
+ "down": "Baixo",
+ "home": "Origem",
+ "left": "Esquerda",
+ "right": "Direira",
+ "up": "Cima",
+ "zoom_in": "Ampliar",
+ "zoom_out": "Reduzir"
+ }
+ },
+ "error": {
+ "could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
+ "could_not_resolve": "Não foi possível resolver o URL de mídia",
+ "diagnostics": "Diagnósticos do cartão. Reveja as informações confidenciais antes de partilhar",
+ "download_no_media": "Nenhuma mídia para download",
+ "download_sign_failed": "Não foi possível assinar o URL de mídia para download",
+ "duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras",
+ "empty_response": "Sem resposta do Home Assistant para a solicitação",
+ "failed_response": "Falha ao receber resposta do Home Assistant para solicitação",
+ "failed_retain": "Não foi possível reter o evento",
+ "failed_sign": "Não foi possível assinar a URL do Home Assistant",
+ "image_load_error": "A imagem não pôde ser carregada",
+ "invalid_configuration": "Configuração inválida",
+ "invalid_configuration_no_hint": "Nenhuma dica de local disponível (tipo incorreto ou ausente?)",
+ "invalid_elements_config": "Configuração de elementos de imagem inválida",
+ "invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação",
+ "jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG",
+ "live_camera_no_endpoint": "Nenhuma câmera ao vivo",
+ "live_camera_not_found": "Nenhuma câmera ao vivo não foi encontrada",
+ "live_camera_unavailable": "Câmera ao vivo indisponivel",
+ "no_camera_engine": "Não existe câmera",
+ "no_camera_entity": "Não existe uma entidade câmera",
+ "no_camera_entity_for_triggers": "Não existe camera para a acção",
+ "no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
+ "no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir",
+ "no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo",
+ "no_visible_cameras": "Sem camaras visiveis",
+ "reconnecting": "A voltar a ligar",
+ "timeline_no_cameras": "Nenhuma câmera do Frigate para mostrar na linha do tempo",
+ "troubleshooting": "Verifique a solução de problemas",
+ "too_many_automations": "",
+ "unknown": "Erro desconhecido",
+ "upgrade_available": "Uma atualização automatizada da configuração do cartão está disponível, visite o editor visual do cartão",
+ "webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
+ "webrtc_card_waiting": "Aguardar o cartão WebRTC carregar ..."
+ },
+ "event": {
+ "camera": "Camera",
+ "duration": "Duração",
+ "in_progress": "Em andamento",
+ "score": "Pontuação",
+ "seek": "Procurar",
+ "start": "Início",
+ "what": "O quê",
+ "where": "Onde"
+ },
+ "media_filter": {
+ "all": "Todos",
+ "camera": "Camera",
+ "favorite": "Favoritos",
+ "media_type": "Tipos de media",
+ "media_types": {
+ "clips": "Clips",
+ "recordings": "Gravações",
+ "snapshots": "Imagens"
+ },
+ "not_favorite": "Não favorito",
+ "select_camera": "Seleciona a camara",
+ "select_favorite": "Seleciona o favorito",
+ "select_media_type": "Seleciona o tipo de media",
+ "select_what": "Seleciona",
+ "select_when": "Seleciona quando",
+ "select_where": "Seleciona onde",
+ "what": "O que",
+ "when": "Quando",
+ "whens": {
+ "past_month": "O mes passado",
+ "past_week": "A semana passada",
+ "today": "Hoje",
+ "yesterday": "Ontem"
+ },
+ "where": "Onde"
+ },
+ "recording": {
+ "camera": "Camera",
+ "duration": "Duração",
+ "events": "Eventos",
+ "in_progress": "Em andamento",
+ "seek": "Procurar",
+ "start": "Começar"
+ },
+ "thumbnail": {
+ "no_thumbnail": "Nenhuma miniatura disponível",
+ "retain_indefinitely": "Evento será retido por tempo indeterminado",
+ "timeline": "Ver evento na linha do tempo"
+ },
+ "timeline": {
+ "pan_behavior": {
+ "pan": "Pan",
+ "seek": "Pan seeks across all media",
+ "seek-in-media": "Pan seeks within selected media item only"
+ },
+ "select_date": "Selecionar a data"
+ }
+}
\ No newline at end of file
diff --git a/src/localize/localize.ts b/src/localize/localize.ts
index cd41f2b0..75d10059 100644
--- a/src/localize/localize.ts
+++ b/src/localize/localize.ts
@@ -56,6 +56,8 @@ export const loadLanguages = async (hass: HomeAssistant): Promise => {
const lang = getLanguage(hass);
if (lang === 'it') {
languages[lang] = await import('./languages/it.json');
+ } else if (lang === 'pt') {
+ languages[lang] = await import('./languages/pt-PT.json');
} else if (lang === 'pt_BR') {
languages[lang] = await import('./languages/pt-BR.json');
}
diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts
index 9ce81467..97e2ca15 100644
--- a/src/patches/ha-camera-stream.ts
+++ b/src/patches/ha-camera-stream.ts
@@ -71,6 +71,16 @@ customElements.whenDefined('ha-camera-stream').then(() => {
this._player?.seek(seconds);
}
+ public async setControls(controls?: boolean): Promise {
+ if (this._player) {
+ this._player.setControls(controls ?? this.controls);
+ }
+ }
+
+ public isPaused(): boolean {
+ return this._player?.isPaused() ?? true;
+ }
+
/**
* Master render method.
* @returns A rendered template.
@@ -84,7 +94,7 @@ customElements.whenDefined('ha-camera-stream').then(() => {
return html`
{
- dispatchMediaLoadedEvent(this, ev);
+ dispatchMediaLoadedEvent(this, ev, { player: this });
}}
.src=${typeof this._connected == 'undefined' || this._connected
? computeMJPEGStreamUrl(this.stateObj)
diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts
index 38f8b148..776961b3 100644
--- a/src/patches/ha-hls-player.ts
+++ b/src/patches/ha-hls-player.ts
@@ -9,17 +9,23 @@
// available as compilation time.
// ====================================================================
-import { css, CSSResultGroup, html, unsafeCSS, TemplateResult } from 'lit';
+import { CSSResultGroup, TemplateResult, css, html, unsafeCSS } from 'lit';
import { customElement } from 'lit/decorators.js';
import { query } from 'lit/decorators/query.js';
import { dispatchErrorMessageEvent } from '../components/message.js';
-import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
-import {
- hideMediaControlsTemporarily,
- MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
-} from '../utils/media.js';
import { FrigateCardMediaPlayer } from '../types.js';
+import { mayHaveAudio } from '../utils/audio.js';
+import {
+ dispatchMediaLoadedEvent,
+ dispatchMediaPauseEvent,
+ dispatchMediaPlayEvent,
+ dispatchMediaVolumeChangeEvent,
+} from '../utils/media-info.js';
+import {
+ MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
+ hideMediaControlsTemporarily,
+} from '../utils/media.js';
customElements.whenDefined('ha-hls-player').then(() => {
@customElement('frigate-card-ha-hls-player')
@@ -67,6 +73,16 @@ customElements.whenDefined('ha-hls-player').then(() => {
}
}
+ public async setControls(controls?: boolean): Promise {
+ if (this._video) {
+ this._video.controls = controls ?? this.controls;
+ }
+ }
+
+ public isPaused(): boolean {
+ return this._video?.paused ?? true;
+ }
+
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
@@ -88,11 +104,25 @@ customElements.whenDefined('ha-hls-player').then(() => {
?playsinline=${this.playsInline}
?controls=${this.controls}
@loadedmetadata=${() => {
- hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
+ if (this.controls) {
+ hideMediaControlsTemporarily(
+ this._video,
+ MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
+ );
+ }
}}
- @loadeddata=${(e) => {
- dispatchMediaLoadedEvent(this, e);
+ @loadeddata=${(ev) => {
+ dispatchMediaLoadedEvent(this, ev, {
+ player: this,
+ capabilities: {
+ supportsPause: true,
+ hasAudio: mayHaveAudio(this._video),
+ },
+ });
}}
+ @volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
+ @play=${() => dispatchMediaPlayEvent(this)}
+ @pause=${() => dispatchMediaPauseEvent(this)}
>
`;
}
diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts
index e568b6e5..d3d40d5e 100644
--- a/src/patches/ha-web-rtc-player.ts
+++ b/src/patches/ha-web-rtc-player.ts
@@ -15,10 +15,16 @@ import { query } from 'lit/decorators/query.js';
import { dispatchErrorMessageEvent } from '../components/message.js';
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
import { FrigateCardMediaPlayer } from '../types.js';
-import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
+import { mayHaveAudio } from '../utils/audio.js';
+import {
+ dispatchMediaLoadedEvent,
+ dispatchMediaPauseEvent,
+ dispatchMediaPlayEvent,
+ dispatchMediaVolumeChangeEvent,
+} from '../utils/media-info.js';
import {
hideMediaControlsTemporarily,
- MEDIA_LOAD_CONTROLS_HIDE_SECONDS
+ MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../utils/media.js';
customElements.whenDefined('ha-web-rtc-player').then(() => {
@@ -66,6 +72,16 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
}
}
+ public async setControls(controls?: boolean): Promise {
+ if (this._video) {
+ this._video.controls = controls ?? this.controls;
+ }
+ }
+
+ public isPaused(): boolean {
+ return this._video?.paused ?? true;
+ }
+
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
@@ -84,11 +100,25 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
?playsinline=${this.playsInline}
?controls=${this.controls}
@loadedmetadata=${() => {
- hideMediaControlsTemporarily(this._video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
+ if (this.controls) {
+ hideMediaControlsTemporarily(
+ this._video,
+ MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
+ );
+ }
}}
- @loadeddata=${(e) => {
- dispatchMediaLoadedEvent(this, e);
+ @loadeddata=${(ev) => {
+ dispatchMediaLoadedEvent(this, ev, {
+ player: this,
+ capabilities: {
+ supportsPause: true,
+ hasAudio: mayHaveAudio(this._video),
+ },
+ });
}}
+ @volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
+ @play=${() => dispatchMediaPlayEvent(this)}
+ @pause=${() => dispatchMediaPauseEvent(this)}
>
`;
}
diff --git a/src/scss/button.scss b/src/scss/button.scss
index 3577c19b..c3148296 100644
--- a/src/scss/button.scss
+++ b/src/scss/button.scss
@@ -19,7 +19,7 @@ ha-icon-button.button {
opacity: 1;
}
50% {
- opacity: 0;
+ opacity: 0.6;
}
100% {
opacity: 1;
diff --git a/src/scss/live-provider.scss b/src/scss/live-provider.scss
index f4d336fd..3ac357c1 100644
--- a/src/scss/live-provider.scss
+++ b/src/scss/live-provider.scss
@@ -1,3 +1,9 @@
+:host {
+ display: block;
+ height: 100%;
+ width: 100;
+}
+
.hidden {
display: none;
}
diff --git a/src/types.ts b/src/types.ts
index 70161d95..dbb5628d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -219,11 +219,19 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
'image',
'live',
'menu_toggle',
+ 'mute',
+ 'live_substream_on',
+ 'live_substream_off',
+ 'microphone_mute',
+ 'microphone_unmute',
+ 'play',
+ 'pause',
'recording',
'recordings',
'snapshot',
'snapshots',
'timeline',
+ 'unmute',
] as const;
const FRIGATE_CARD_ACTIONS = [
...FRIGATE_CARD_GENERAL_ACTIONS,
@@ -261,7 +269,7 @@ export type FrigateCardCustomAction = z.infer;
+
const go2rtcConfigSchema = z.object({
modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(),
stream: z.string().optional(),
@@ -616,7 +640,7 @@ export type MenuSubmenuSelect = z.infer;
export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect;
-const frigateCardConditionSchema = z.object({
+export const frigateCardConditionSchema = z.object({
view: z.string().array().optional(),
fullscreen: z.boolean().optional(),
expand: z.boolean().optional(),
@@ -765,12 +789,14 @@ const viewConfigSchema = z
const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const;
const imageConfigDefault = {
mode: 'url' as const,
+ zoomable: true,
...imageBaseConfigDefault,
};
const imageConfigSchema = imageBaseConfigSchema
.extend({
mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode),
layout: mediaLayoutConfigSchema.optional(),
+ zoomable: z.boolean().default(imageConfigDefault.zoomable),
})
.merge(actionsSchema)
.default(imageConfigDefault);
@@ -918,9 +944,11 @@ const liveConfigDefault = {
lazy_load: true,
lazy_unload: 'never' as const,
draggable: true,
+ zoomable: true,
transition_effect: 'slide' as const,
show_image_during_load: true,
controls: {
+ builtin: true,
next_previous: {
size: 48,
style: 'chevrons' as const,
@@ -932,6 +960,9 @@ const liveConfigDefault = {
duration_seconds: 2,
},
},
+ microphone: {
+ ...microphoneConfigDefault,
+ },
};
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
@@ -944,6 +975,7 @@ const liveOverridableConfigSchema = z
.object({
controls: z
.object({
+ builtin: z.boolean().default(liveConfigDefault.controls.builtin),
next_previous: nextPreviousControlConfigSchema
.extend({
// Live cannot show thumbnails, remove that option.
@@ -975,6 +1007,8 @@ const liveOverridableConfigSchema = z
.boolean()
.default(liveConfigDefault.show_image_during_load),
layout: mediaLayoutConfigSchema.optional(),
+ microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
+ zoomable: z.boolean().default(liveConfigDefault.zoomable),
})
.merge(actionsSchema);
@@ -1037,6 +1071,12 @@ const menuConfigDefault = {
fullscreen: visibleButtonDefault,
expand: hiddenButtonDefault,
media_player: visibleButtonDefault,
+ microphone: {
+ ...hiddenButtonDefault,
+ type: 'momentary' as const,
+ },
+ mute: hiddenButtonDefault,
+ play: hiddenButtonDefault,
recordings: hiddenButtonDefault,
},
button_size: 40,
@@ -1073,7 +1113,16 @@ const menuConfigSchema = z
media_player: visibleButtonSchema.default(
menuConfigDefault.buttons.media_player,
),
+ microphone: hiddenButtonSchema
+ .extend({
+ type: z
+ .enum(['momentary', 'toggle'])
+ .default(menuConfigDefault.buttons.microphone.type),
+ })
+ .default(menuConfigDefault.buttons.microphone),
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
+ mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
+ play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
})
.default(menuConfigDefault.buttons),
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
@@ -1091,9 +1140,11 @@ const viewerConfigDefault = {
auto_unmute: 'never' as const,
lazy_load: true,
draggable: true,
+ zoomable: true,
transition_effect: 'slide' as const,
snapshot_click_plays_clip: true,
controls: {
+ builtin: true,
next_previous: {
size: 48,
style: 'thumbnails' as const,
@@ -1131,6 +1182,7 @@ const viewerConfigSchema = z
.default(viewerConfigDefault.auto_unmute),
lazy_load: z.boolean().default(viewerConfigDefault.lazy_load),
draggable: z.boolean().default(viewerConfigDefault.draggable),
+ zoomable: z.boolean().default(viewerConfigDefault.zoomable),
transition_effect: transitionEffectConfigSchema.default(
viewerConfigDefault.transition_effect,
),
@@ -1139,6 +1191,7 @@ const viewerConfigSchema = z
.default(viewerConfigDefault.snapshot_click_plays_clip),
controls: z
.object({
+ builtin: z.boolean().default(viewerConfigDefault.controls.builtin),
next_previous: viewerNextPreviousControlConfigSchema.default(
viewerConfigDefault.controls.next_previous,
),
@@ -1294,6 +1347,19 @@ const liveOverridesSchema = z
.optional();
export type LiveOverrides = z.infer;
+const automationActionSchema = actionSchema.array().optional();
+export type AutomationActions = z.infer;
+
+const automationSchema = z.object({
+ conditions: frigateCardConditionSchema,
+ actions: automationActionSchema,
+ actions_not: automationActionSchema,
+});
+export type Automation = z.infer;
+
+export const automationsSchema = automationSchema.array().optional();
+export type Automations = z.infer;
+
const performanceConfigDefault = {
profile: 'high' as const,
features: {
@@ -1370,6 +1436,7 @@ export const frigateCardConfigSchema = z.object({
timeline: timelineConfigSchema,
performance: performanceConfigSchema,
debug: debugConfigSchema,
+ automations: automationsSchema,
// Configuration overrides.
overrides: overridesSchema,
@@ -1416,9 +1483,17 @@ export interface ExtendedHomeAssistant extends HomeAssistant {
};
}
+export interface MediaLoadedCapabilities {
+ supports2WayAudio?: boolean;
+ supportsPause?: boolean;
+ hasAudio?: boolean;
+}
+
export interface MediaLoadedInfo {
width: number;
height: number;
+ player?: FrigateCardMediaPlayer;
+ capabilities?: MediaLoadedCapabilities;
}
export const MESSAGE_TYPE_PRIORITIES = {
@@ -1455,6 +1530,9 @@ export interface FrigateCardMediaPlayer {
unmute(): Promise;
isMuted(): boolean;
seek(seconds: number): Promise;
+ // If no value for controls if specified, the player should use the default.
+ setControls(controls?: boolean): Promise;
+ isPaused(): boolean;
}
export interface CardHelpers {
diff --git a/src/utils/action.ts b/src/utils/action.ts
index e64c0c37..edc700d1 100644
--- a/src/utils/action.ts
+++ b/src/utils/action.ts
@@ -6,7 +6,6 @@ import {
} from 'custom-card-helpers';
import {
Actions,
- ActionsConfig,
ActionType,
FrigateCardAction,
FrigateCardCustomAction,
@@ -52,7 +51,7 @@ export function createFrigateCardCustomAction(
action: 'fire-dom-event',
frigate_card_action: action,
camera: args.camera as string,
- ...(args.cardID && { card_id: args.cardID})
+ ...(args.cardID && { card_id: args.cardID }),
};
}
if (action === 'media_player') {
@@ -64,13 +63,13 @@ export function createFrigateCardCustomAction(
frigate_card_action: action,
media_player: args.media_player,
media_player_action: args.media_player_action,
- ...(args.cardID && { card_id: args.cardID})
+ ...(args.cardID && { card_id: args.cardID }),
};
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
- ...(args?.cardID && { card_id: args.cardID})
+ ...(args?.cardID && { card_id: args.cardID }),
};
}
@@ -107,29 +106,6 @@ export function getActionConfigGivenAction(
* that handles the custom action events the card supports.
* @param node The node that fired the event.
* @param hass The Home Assistant object.
- * @param config The multi-action configuration.
- * @param action The action string (e.g. 'hold')
- * @returns Whether or not an action was executed.
- */
-export const frigateCardHandleAction = (
- node: HTMLElement,
- hass: HomeAssistant,
- config: ActionsConfig,
- action: string,
-): boolean => {
- return frigateCardHandleActionConfig(
- node,
- hass,
- config,
- action,
- getActionConfigGivenAction(action, config),
- );
-};
-
-/**
- * Handle an ActionConfig or array of ActionConfigs.
- * @param node The node that fired the event.
- * @param hass The Home Assistant object.
* @param actionConfig A single action config, array of action configs or
* undefined for the default action config for 'tap'.
* @param action The action string (e.g. 'hold')
@@ -143,36 +119,46 @@ export const frigateCardHandleActionConfig = (
entity?: string;
},
action: string,
- actionConfig: ActionType | ActionType[] | undefined,
+ actionConfig?: ActionType | ActionType[],
): boolean => {
// Only allow a tap action to use a default non-config (the more-info config).
if (actionConfig || action == 'tap') {
- // ActionConfig vs ActionType:
- // There is a slight typing (but not functional) difference between
- // ActionType in this card and ActionConfig in `custom-card-helpers`. See
- // `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and
- // reason behind this difference.
- if (Array.isArray(actionConfig)) {
- actionConfig.forEach((action) =>
- handleActionConfig(node, hass, config, action as ActionConfig | undefined),
- );
- } else {
- handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined);
- }
+ frigateCardHandleAction(node, hass, config, actionConfig);
return true;
}
return false;
};
+export const frigateCardHandleAction = (
+ node: HTMLElement,
+ hass: HomeAssistant,
+ config: {
+ camera_image?: string;
+ entity?: string;
+ },
+ actionConfig: ActionType | ActionType[] | undefined,
+): void => {
+ // ActionConfig vs ActionType:
+ // * There is a slight typing (but not functional) difference between
+ // ActionType in this card and ActionConfig in `custom-card-helpers`. See
+ // `ExtendedConfirmationRestrictionConfig` in `types.ts` for the source and
+ // reason behind this difference.
+ if (Array.isArray(actionConfig)) {
+ actionConfig.forEach((action) =>
+ handleActionConfig(node, hass, config, action as ActionConfig | undefined),
+ );
+ } else {
+ handleActionConfig(node, hass, config, actionConfig as ActionConfig | undefined);
+ }
+};
+
/**
* Determine if an action config has a real action. A modified version of
* custom-card-helpers hasAction to also work with arrays of action configs.
* @param config The action config in question.
* @returns `true` if there's a real action defined, `false` otherwise.
*/
-export const frigateCardHasAction = (
- config?: ActionType | ActionType[] | undefined,
-): boolean => {
+export const frigateCardHasAction = (config?: ActionType | ActionType[]): boolean => {
// See note above on 'ActionConfig vs ActionType' for why this cast is
// necessary and harmless.
if (Array.isArray(config)) {
diff --git a/src/utils/audio.ts b/src/utils/audio.ts
new file mode 100644
index 00000000..840d0dc7
--- /dev/null
+++ b/src/utils/audio.ts
@@ -0,0 +1,19 @@
+export interface AudioProperties {
+ mozHasAudio?: boolean;
+ audioTracks?: unknown[];
+}
+
+// There is currently no consistent cross-browser modern way to determine if a
+// viden has audio tracks. The below will work in ~24% of browsers, but notably
+// not in Chrome. There used to be a usable `webkitAudioDecodedByteCount`
+// property, but this now seems to be consistently 0 in Chrome. This generously
+// defaults to assuming there is audio when we cannot rule it out.
+export const mayHaveAudio = (video: HTMLVideoElement & AudioProperties): boolean => {
+ if (video.mozHasAudio !== undefined) {
+ return video.mozHasAudio;
+ }
+ if (video.audioTracks !== undefined) {
+ return Boolean(video.audioTracks?.length);
+ }
+ return true;
+};
diff --git a/src/utils/camera.ts b/src/utils/camera.ts
index 2982c501..a9b8c6d7 100644
--- a/src/utils/camera.ts
+++ b/src/utils/camera.ts
@@ -28,12 +28,21 @@ export function getCameraID(
* Get all cameras that depend on a given camera.
* @param cameraManager The camera manager.
* @param cameraID ID of the target camera.
- * @returns A set of dependent cameraIDs or null.
+ * @returns A set of dependent cameraIDs or null (since JS sets guarantee order,
+ * the first item in the set is guaranteed to be the cameraID itself).
*/
-export const getAllDependentCameras = (
+export function getAllDependentCameras(
+ cameraManager: CameraManager,
+ cameraID: string,
+): Set;
+export function getAllDependentCameras(
cameraManager?: CameraManager,
cameraID?: string,
-): Set | null => {
+): Set | null;
+export function getAllDependentCameras(
+ cameraManager?: CameraManager,
+ cameraID?: string,
+): Set | null {
if (!cameraManager || !cameraID) {
return null;
}
@@ -45,9 +54,7 @@ export const getAllDependentCameras = (
if (cameraConfig) {
cameraIDs.add(cameraID);
const dependentCameras: Set = new Set();
- (cameraConfig.dependencies.cameras || []).forEach((item) =>
- dependentCameras.add(item),
- );
+ cameraConfig.dependencies.cameras.forEach((item) => dependentCameras.add(item));
if (cameraConfig.dependencies.all_cameras) {
cameras.forEach((_, key) => dependentCameras.add(key));
}
@@ -62,4 +69,4 @@ export const getAllDependentCameras = (
getDependentCameras(cameraID);
}
return cameraIDs;
-};
+}
diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts
index de7db2f8..f6e2b0ad 100644
--- a/src/utils/media-info.ts
+++ b/src/utils/media-info.ts
@@ -1,4 +1,8 @@
-import { MediaLoadedInfo } from '../types.js';
+import {
+ FrigateCardMediaPlayer,
+ MediaLoadedCapabilities,
+ MediaLoadedInfo,
+} from '../types.js';
import { dispatchFrigateCardEvent } from './basic.js';
const MEDIA_INFO_HEIGHT_CUTOFF = 50;
@@ -11,6 +15,10 @@ const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
*/
export function createMediaLoadedInfo(
source: Event | HTMLElement,
+ options?: {
+ player?: FrigateCardMediaPlayer;
+ capabilities?: MediaLoadedCapabilities;
+ },
): MediaLoadedInfo | null {
let target: HTMLElement | EventTarget;
if (source instanceof Event) {
@@ -23,16 +31,20 @@ export function createMediaLoadedInfo(
return {
width: (target as HTMLImageElement).naturalWidth,
height: (target as HTMLImageElement).naturalHeight,
+ ...options,
};
} else if (target instanceof HTMLVideoElement) {
return {
width: (target as HTMLVideoElement).videoWidth,
height: (target as HTMLVideoElement).videoHeight,
+ ...options,
};
} else if (target instanceof HTMLCanvasElement) {
return {
width: (target as HTMLCanvasElement).width,
height: (target as HTMLCanvasElement).height,
+ player: options?.player,
+ ...options,
};
}
return null;
@@ -46,13 +58,29 @@ export function createMediaLoadedInfo(
export function dispatchMediaLoadedEvent(
target: HTMLElement,
source: Event | HTMLElement,
+ options?: {
+ player?: FrigateCardMediaPlayer;
+ capabilities?: MediaLoadedCapabilities;
+ },
): void {
- const mediaLoadedInfo = createMediaLoadedInfo(source);
+ const mediaLoadedInfo = createMediaLoadedInfo(source, options);
if (mediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(target, mediaLoadedInfo);
}
}
+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.
diff --git a/src/utils/microphone.ts b/src/utils/microphone.ts
new file mode 100644
index 00000000..772b45f1
--- /dev/null
+++ b/src/utils/microphone.ts
@@ -0,0 +1,87 @@
+import { errorToConsole } from "./basic";
+
+export class MicrophoneController {
+ protected _stream?: MediaStream | null;
+ protected _timerID: number | null = null;
+
+ // 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
+ // have the right mute status.
+ protected _mute = true;
+
+ protected _disconnectSeconds: number;
+
+ constructor(disconnectSeconds?: number) {
+ this._disconnectSeconds = disconnectSeconds ?? 0;
+ }
+
+ public async connect(): Promise {
+ try {
+ this._stream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ video: false,
+ });
+ } catch (e: unknown) {
+ errorToConsole(e as Error);
+ this._stream = null;
+ }
+ this._setMute();
+ }
+
+ public async disconnect(): Promise {
+ this._stream?.getTracks().forEach((track) => track.stop());
+ this._stream = undefined;
+ }
+
+ public getStream(): MediaStream | undefined {
+ return this._stream ?? undefined;
+ }
+
+ protected _setMute(): void {
+ this._stream?.getTracks().forEach((track) => {
+ track.enabled = !this._mute;
+ });
+ this._startTimer();
+ }
+
+ public mute(): void {
+ this._mute = true;
+ this._setMute();
+ }
+
+ public unmute(): void {
+ this._mute = false;
+ this._setMute();
+ }
+
+ public isConnected(): boolean {
+ return !!this._stream;
+ }
+
+ public isForbidden(): boolean {
+ return this._stream === null;
+ }
+
+ public isMuted(): boolean {
+ // For safety, this function always returns the stream mute status directly
+ // (rather the internal state).
+ 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.disconnect();
+ }, this._disconnectSeconds * 1000);
+ }
+ }
+}
diff --git a/src/utils/substream.ts b/src/utils/substream.ts
new file mode 100644
index 00000000..715e022b
--- /dev/null
+++ b/src/utils/substream.ts
@@ -0,0 +1,48 @@
+import { CameraManager } from '../camera-manager/manager';
+import { View } from '../view/view';
+import { getAllDependentCameras } from './camera';
+
+export const createViewWithSelectedSubstream = (
+ view: View,
+ substreamID: string,
+): View | null => {
+ const overrides: Map = view.context?.live?.overrides ?? new Map();
+ overrides.set(view.camera, substreamID);
+ return view.clone().mergeInContext({
+ live: { overrides: overrides },
+ });
+};
+
+export const createViewWithoutSubstream = (view: View): View => {
+ const newView = view.clone();
+ const overrides: Map | undefined = newView.context?.live?.overrides;
+ if (overrides && overrides.has(view.camera)) {
+ newView.context?.live?.overrides?.delete(view.camera);
+ }
+ return newView;
+};
+
+export const hasSubstream = (view: View): boolean => {
+ const override = view?.context?.live?.overrides?.get(view.camera);
+ return !!override && override !== view.camera;
+};
+
+export const createViewWithNextStream = (
+ cameraManager: CameraManager,
+ view: View,
+): View => {
+ const dependencies = [...getAllDependentCameras(cameraManager, view.camera)];
+ if (dependencies.length <= 1) {
+ return view.clone();
+ }
+
+ const newView = view.clone();
+ const overrides: Map = newView.context?.live?.overrides ?? new Map();
+ const currentOverride = overrides.get(newView.camera) ?? newView.camera;
+ const currentIndex = dependencies.indexOf(currentOverride);
+ const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
+ overrides.set(view.camera, dependencies[newIndex]);
+ newView.mergeInContext({ live: { overrides: overrides } });
+
+ return newView;
+};
diff --git a/src/utils/zoom/zoom.ts b/src/utils/zoom/zoom.ts
new file mode 100644
index 00000000..8c0f68b9
--- /dev/null
+++ b/src/utils/zoom/zoom.ts
@@ -0,0 +1,127 @@
+import { PanzoomObject, PanzoomEventDetail } from '@dermotduffy/panzoom';
+import Panzoom from '@dermotduffy/panzoom';
+import round from 'lodash-es/round';
+import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic';
+
+export class Zoom {
+ constructor(element: HTMLElement) {
+ this._element = element;
+ }
+
+ protected _element: HTMLElement;
+ protected _panzoom?: PanzoomObject;
+ protected _zoomed = false;
+
+ protected _events = isHoverableDevice()
+ ? {
+ down: ['pointerdown'],
+ move: ['pointermove'],
+ up: ['pointerup', 'pointerleave', 'pointercancel'],
+ }
+ : {
+ down: ['touchstart'],
+ move: ['touchmove'],
+ up: ['touchend', 'touchcancel'],
+ };
+
+ protected _downHandler = (ev: Event) => {
+ if (this._shouldZoomOrPan(ev)) {
+ this._panzoom?.handleDown(ev as PointerEvent);
+ ev.stopPropagation();
+
+ // If we do not prevent default here, the media carousels scroll.
+ ev.preventDefault();
+ }
+ };
+
+ protected _moveHandler = (ev: Event) => {
+ if (this._shouldZoomOrPan(ev)) {
+ this._panzoom?.handleMove(ev as PointerEvent);
+ ev.stopPropagation();
+ }
+ };
+
+ protected _upHandler = (ev: Event) => {
+ if (this._shouldZoomOrPan(ev)) {
+ this._panzoom?.handleUp(ev as PointerEvent);
+ ev.stopPropagation();
+ }
+ };
+
+ protected _wheelHandler = (ev: Event) => {
+ if (ev instanceof WheelEvent && this._shouldZoomOrPan(ev)) {
+ this._panzoom?.zoomWithWheel(ev);
+ ev.stopPropagation();
+ }
+ };
+
+ protected _isScaleNormal(scale?: number): boolean {
+ // Floating point arithmetic warning: comparing floating point numbers,
+ // round them first.
+ return scale !== undefined && round(scale, 4) <= 1;
+ }
+
+ protected _shouldZoomOrPan(ev: Event): boolean {
+ return (
+ !this._isScaleNormal(this._panzoom?.getScale()) ||
+ (ev instanceof TouchEvent && ev.touches.length > 1) ||
+ (ev instanceof WheelEvent && ev.ctrlKey)
+ );
+ }
+
+ public activate(): void {
+ this._panzoom = Panzoom(this._element, {
+ contain: 'outside',
+ maxScale: 10,
+ minScale: 1,
+ noBind: true,
+ // 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,
+ });
+
+ const registerListeners = (
+ events: string[],
+ func: (ev: Event) => void,
+ options?: AddEventListenerOptions,
+ ) => {
+ events.forEach((eventName) => {
+ this._element.addEventListener(eventName, func, options);
+ });
+ };
+
+ registerListeners(this._events['down'], this._downHandler, { capture: true });
+ registerListeners(this._events['move'], this._moveHandler, { capture: true });
+ registerListeners(this._events['up'], this._upHandler, { capture: true });
+ registerListeners(['wheel'], this._wheelHandler);
+
+ this._element.addEventListener('panzoomzoom', (ev: Event) => {
+ // Take care here to only dispatch the zoomed/unzoomed events when the
+ // absolute state changes (rather than on every single zoom adjustment).
+ if (this._isScaleNormal((>ev).detail.scale)) {
+ if (this._zoomed) {
+ dispatchFrigateCardEvent(this._element, 'zoom:unzoomed');
+ }
+ this._zoomed = false;
+ } else {
+ if (!this._zoomed) {
+ dispatchFrigateCardEvent(this._element, 'zoom:zoomed');
+ }
+ this._zoomed = true;
+ }
+ });
+ }
+
+ public deactivate(): void {
+ const unregisterListener = (events: string[], func: (ev: Event) => void) => {
+ events.forEach((eventName) => {
+ this._element.removeEventListener(eventName, func);
+ });
+ };
+
+ unregisterListener(this._events['down'], this._downHandler);
+ unregisterListener(this._events['move'], this._moveHandler);
+ unregisterListener(this._events['up'], this._upHandler);
+ unregisterListener(['wheel'], this._wheelHandler);
+ }
+}
diff --git a/src/view/view.ts b/src/view/view.ts
index 89d5c69b..0f417081 100644
--- a/src/view/view.ts
+++ b/src/view/view.ts
@@ -79,6 +79,11 @@ export class View {
// changes camera to the *current* camera (via the menu) it will cause a
// new view to issue without a query and just the 'media' view, which
// means the viewer cannot know what kind of media to fetch.
+ //
+ // * Case #3: Staying within the live view in order to preserve substreams
+ // turned on. See:
+ // https://github.com/dermotduffy/frigate-hass-card/issues/1122
+ //
let currentQueriesView: ClipsOrSnapshots | 'recordings' | null = null;
if (MediaQueriesClassifier.areEventQueries(curr.query)) {
@@ -114,6 +119,17 @@ export class View {
: 'recording';
}
}
+
+ if (
+ curr.is('live') &&
+ next.is('live') &&
+ curr.context?.live?.overrides &&
+ !next.context?.live?.overrides
+ ) {
+ const nextLiveContext = next.context?.live ?? {};
+ nextLiveContext.overrides = curr.context.live.overrides;
+ next.mergeInContext({ live: nextLiveContext });
+ }
}
/**
diff --git a/tests/automations.test.ts b/tests/automations.test.ts
new file mode 100644
index 00000000..c5051188
--- /dev/null
+++ b/tests/automations.test.ts
@@ -0,0 +1,123 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import { AutomationsController, AutomationsControllerError } from '../src/automations';
+import { ConditionController } from '../src/conditions';
+import { automationsSchema, FrigateCardError } from '../src/types';
+import { frigateCardHandleAction } from '../src/utils/action.js';
+import { createHASS } from './test-utils';
+
+vi.mock('../src/utils/action.js');
+
+describe('AutomationsController', () => {
+ const actions = [
+ {
+ action: 'custom:frigate-card-action',
+ frigate_card_action: 'clips',
+ },
+ ];
+ const conditions = { fullscreen: true };
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should do nothing without automations', () => {
+ const automationController = new AutomationsController(undefined);
+ automationController.execute(
+ mock(),
+ createHASS(),
+ new ConditionController(),
+ );
+ expect(frigateCardHandleAction).not.toBeCalled();
+ });
+
+ it('should execute actions', () => {
+ const automations = automationsSchema.parse([
+ {
+ conditions: conditions,
+ actions: actions,
+ },
+ ]);
+
+ const automationController = new AutomationsController(automations);
+ const conditionController = new ConditionController();
+ const element = mock();
+ const hass = createHASS();
+
+ automationController.execute(element, hass, conditionController);
+ expect(frigateCardHandleAction).not.toBeCalled();
+
+ conditionController.setState({ fullscreen: true });
+ automationController.execute(element, hass, conditionController);
+ expect(frigateCardHandleAction).toBeCalledTimes(1);
+
+ // Automation will not re-fire when condition continues to evaluate the
+ // same.
+ automationController.execute(element, hass, conditionController);
+ expect(frigateCardHandleAction).toBeCalledTimes(1);
+
+ conditionController.setState({ fullscreen: false });
+ automationController.execute(element, hass, conditionController);
+ expect(frigateCardHandleAction).toBeCalledTimes(1);
+
+ conditionController.setState({ fullscreen: true });
+ automationController.execute(element, hass, conditionController);
+ expect(frigateCardHandleAction).toBeCalledTimes(2);
+ });
+
+ it('should execute actions_not', () => {
+ const automations = automationsSchema.parse([
+ {
+ conditions: conditions,
+ actions_not: actions,
+ },
+ ]);
+
+ const automationController = new AutomationsController(automations);
+ automationController.execute(
+ mock(),
+ createHASS(),
+ new ConditionController(),
+ );
+ expect(frigateCardHandleAction).toBeCalled();
+ });
+
+ it('should prevent automation loops', () => {
+ const automations = automationsSchema.parse([
+ {
+ conditions: { fullscreen: true },
+ actions: actions,
+ },
+ {
+ conditions: { fullscreen: false },
+ actions: actions,
+ },
+ ]);
+
+ const automationController = new AutomationsController(automations);
+ const conditionController = new ConditionController();
+ const element = mock();
+ const hass = createHASS();
+
+ // Create a setup where one automation action causes another...
+ let fullscreen = true;
+ vi.mocked(frigateCardHandleAction).mockImplementation(() => {
+ fullscreen = !fullscreen;
+ conditionController.setState({ fullscreen: fullscreen });
+ automationController.execute(element, hass, conditionController);
+ });
+
+ conditionController.setState({ fullscreen: fullscreen });
+
+ expect(() =>
+ automationController.execute(element, hass, conditionController),
+ ).toThrowError(/Too many nested automation calls/);
+ expect(frigateCardHandleAction).toBeCalledTimes(10);
+ });
+
+ it('should be able to construct error', () => {
+ const error = new AutomationsControllerError('message');
+ expect(error).toBeTruthy();
+ expect(error instanceof FrigateCardError).toBeTruthy();
+ });
+});
diff --git a/tests/cached-value-controller.test.ts b/tests/cached-value-controller.test.ts
new file mode 100644
index 00000000..29714a7b
--- /dev/null
+++ b/tests/cached-value-controller.test.ts
@@ -0,0 +1,109 @@
+import { ReactiveControllerHost } from 'lit';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import { CachedValueController } from '../src/cached-value-controller';
+
+// @vitest-environment jsdom
+describe('CachedValueController', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should construct', () => {
+ const host = mock();
+ const callback = vi.fn();
+ const controller = new CachedValueController(host, 10, callback);
+
+ expect(controller).toBeTruthy();
+ });
+
+ it('should remove host', () => {
+ const host = mock();
+ const callback = vi.fn();
+ const controller = new CachedValueController(host, 10, callback);
+
+ controller.removeController();
+ expect(host.removeController).toBeCalled();
+ });
+
+ it('should have timer', () => {
+ const host = mock();
+ const callback = vi.fn();
+ const startCallback = vi.fn();
+ const stopCallback = vi.fn();
+
+ vi.useFakeTimers();
+
+ const controller = new CachedValueController(
+ host,
+ 10,
+ callback,
+ startCallback,
+ stopCallback,
+ );
+
+ controller.startTimer();
+ expect(startCallback).toBeCalled();
+
+ callback.mockReturnValue(3);
+ vi.runOnlyPendingTimers();
+ expect(callback).toBeCalled();
+ expect(host.requestUpdate).toBeCalled();
+ expect(controller.value).toBe(3);
+
+ callback.mockReturnValue(4);
+ vi.runOnlyPendingTimers();
+ expect(callback).toBeCalled();
+ expect(host.requestUpdate).toBeCalled();
+ expect(controller.value).toBe(4);
+
+ expect(controller.hasTimer()).toBeTruthy();
+
+ controller.stopTimer();
+ expect(stopCallback).toBeCalled();
+
+ callback.mockReset();
+ vi.runOnlyPendingTimers();
+ expect(callback).not.toBeCalled();
+ });
+
+ it('should clear value', () => {
+ const host = mock();
+ const callback = vi.fn().mockReturnValue(42);
+
+ vi.useFakeTimers();
+
+ const controller = new CachedValueController(host, 10, callback);
+ controller.startTimer();
+
+ vi.runOnlyPendingTimers();
+ expect(controller.value).equal(42);
+
+ controller.clearValue();
+ expect(controller.value).toBeUndefined();
+ });
+
+ it('should connect and disconnect host', () => {
+ const host = mock();
+ const callback = vi.fn().mockReturnValue(43);
+ const startCallback = vi.fn();
+ const stopCallback = vi.fn();
+
+ const controller = new CachedValueController(
+ host,
+ 10,
+ callback,
+ startCallback,
+ stopCallback,
+ );
+
+ controller.hostConnected();
+ expect(controller.value).equal(43);
+ expect(startCallback).toBeCalled();
+ expect(host.requestUpdate).toBeCalled();
+
+ controller.hostDisconnected();
+ expect(controller.value).toBeUndefined();
+ expect(stopCallback).toBeCalled();
+ });
+});
diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts
index 24da9446..24ebde87 100644
--- a/tests/camera-manager/engine-factory.test.ts
+++ b/tests/camera-manager/engine-factory.test.ts
@@ -1,16 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
-import { mock } from 'vitest-mock-extended';
+import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
+import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate';
+import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
+import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
+import { Engine } from '../../src/camera-manager/types.js';
+import { CardWideConfig } from '../../src/types.js';
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
import { EntityCache } from '../../src/utils/ha/entity-registry/cache';
-import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
-import { CameraConfig, cameraConfigSchema, CardWideConfig } from '../../src/types.js';
-import { HomeAssistant } from 'custom-card-helpers';
-import { Engine } from '../../src/camera-manager/types.js';
-import { Entity } from '../../src/utils/ha/entity-registry/types.js';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
-import { GenericCameraManagerEngine } from '../../src/camera-manager/generic/engine-generic';
-import { FrigateCameraManagerEngine } from '../../src/camera-manager/frigate/engine-frigate';
-import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye/engine-motioneye';
+import { createCameraConfig, createHASS, createRegistryEntity } from '../test-utils';
vi.mock('../../src/utils/ha/entity-registry');
vi.mock('../../src/utils/ha/entity-registry/cache');
@@ -27,28 +25,6 @@ const createFactory = (options?: {
);
};
-const createCameraConfig = (config: Partial): CameraConfig => {
- return cameraConfigSchema.parse(config);
-};
-
-const createHASS = (): HomeAssistant => {
- return mock();
-};
-
-const createEntity = (entity: Partial): Entity => {
- return {
- ...entity,
- config_entry_id: entity.config_entry_id ?? null,
- device_id: entity.device_id ?? null,
- disabled_by: entity.disabled_by ?? null,
- entity_id: entity.entity_id ?? 'entity_id',
- hidden_by: entity.hidden_by ?? null,
- platform: entity.platform ?? 'platform',
- translation_key: entity.translation_key ?? null,
- unique_id: entity.unique_id ?? 'unique_id',
- };
-};
-
describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
it('should get frigate engine from config', async () => {
const config = createCameraConfig({ engine: 'frigate' });
@@ -62,13 +38,21 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
Engine.MotionEye,
);
});
+ it('should get generic engine from config', async () => {
+ const config = createCameraConfig({ engine: 'generic' });
+ expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe(
+ Engine.Generic,
+ );
+ });
it('should get frigate engine from auto config', async () => {
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager(new EntityCache());
entityRegistryManager.getEntity = vi
.fn()
- .mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'frigate' }));
+ .mockResolvedValue(
+ createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }),
+ );
expect(
await createFactory({
@@ -83,7 +67,7 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
.mockResolvedValue(
- createEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
+ createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }),
);
expect(
@@ -98,7 +82,9 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi
.fn()
- .mockResolvedValue(createEntity({ entity_id: 'camera.foo', platform: 'generic' }));
+ .mockResolvedValue(
+ createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }),
+ );
expect(
await createFactory({
@@ -120,6 +106,49 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error());
+ await expect(
+ createFactory({
+ entityRegistryManager: entityRegistryManager,
+ }).getEngineForCamera(createHASS(), config),
+ ).rejects.toThrow();
+ });
+ it('should treat entity not in registry but with state as generic', async () => {
+ const config = createCameraConfig({
+ engine: 'auto',
+ webrtc_card: { entity: 'camera.foo' },
+ });
+ const entityRegistryManager = new EntityRegistryManager(new EntityCache());
+
+ entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error());
+
+ expect(
+ await createFactory({
+ entityRegistryManager: entityRegistryManager,
+ }).getEngineForCamera(
+ createHASS({
+ 'camera.foo': {
+ entity_id: 'camera.foo',
+ state: 'streaming',
+ last_changed: 'bar',
+ last_updated: 'baz',
+ attributes: {},
+ context: {
+ id: 'context',
+ user_id: null,
+ parent_id: null,
+ },
+ },
+ }),
+ config,
+ ),
+ ).toBe(Engine.Generic);
+ });
+ it('should get engine from webrtc-card configuration', async () => {
+ const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
+ const entityRegistryManager = new EntityRegistryManager(new EntityCache());
+
+ entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error());
+
await expect(
createFactory({
entityRegistryManager: entityRegistryManager,
diff --git a/tests/camera-manager/frigate/util.test.ts b/tests/camera-manager/frigate/util.test.ts
new file mode 100644
index 00000000..b47557b5
--- /dev/null
+++ b/tests/camera-manager/frigate/util.test.ts
@@ -0,0 +1,154 @@
+import add from 'date-fns/add';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import {
+ getEventMediaContentID,
+ getEventThumbnailURL,
+ getEventTitle,
+ getRecordingID,
+ getRecordingMediaContentID,
+ getRecordingTitle,
+} from '../../../src/camera-manager/frigate/util';
+import { CameraConfig } from '../../../src/types';
+import {
+ createCameraConfig,
+ createFrigateEvent,
+ createFrigateRecording,
+} from '../../test-utils';
+
+describe('getEventTitle', () => {
+ const start = new Date('2023-05-06T10:43:00');
+ const end = new Date('2023-05-06T10:44:12');
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+ it('should get finished event title', () => {
+ expect(
+ getEventTitle(
+ createFrigateEvent({
+ start_time: start.getTime() / 1000,
+ end_time: end.getTime() / 1000,
+ top_score: 0.841796875,
+ label: 'person',
+ }),
+ ),
+ ).toBe('2023-05-06 10:43 [72s, Person 84%]');
+ });
+ it('should get in-progress event title', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(add(start, { seconds: 60 }));
+
+ expect(
+ getEventTitle(
+ createFrigateEvent({
+ start_time: start.getTime() / 1000,
+ end_time: null,
+ top_score: 0.841796875,
+ label: 'person',
+ }),
+ ),
+ ).toBe('2023-05-06 10:43 [60s, Person 84%]');
+ });
+ it('should get scoreless event title', () => {
+ expect(
+ getEventTitle(
+ createFrigateEvent({
+ start_time: start.getTime() / 1000,
+ end_time: end.getTime() / 1000,
+ top_score: null,
+ label: 'person',
+ }),
+ ),
+ ).toBe('2023-05-06 10:43 [72s, Person]');
+ });
+});
+
+describe('getRecordingTitle', () => {
+ it('should get recording title', () => {
+ expect(
+ getRecordingTitle(
+ 'Kitchen',
+ createFrigateRecording({
+ startTime: new Date('2023-04-29T14:00:00'),
+ }),
+ ),
+ ).toBe('Kitchen 2023-04-29 14:00');
+ });
+});
+
+describe('getEventThumbnailURL', () => {
+ it('should get thumbnail URL', () => {
+ expect(
+ getEventThumbnailURL(
+ 'clientid',
+ createFrigateEvent({
+ id: '1683396875.643998-hmzrh5',
+ }),
+ ),
+ ).toBe('/api/frigate/clientid/thumbnail/1683396875.643998-hmzrh5');
+ });
+});
+
+describe('getEventMediaContentID', () => {
+ it('should get event content ID', () => {
+ expect(
+ getEventMediaContentID(
+ 'clientid',
+ 'kitchen',
+ createFrigateEvent({
+ id: '1683396875.643998-hmzrh5',
+ }),
+ 'clips',
+ ),
+ ).toBe(
+ 'media-source://frigate/clientid/event/clips/kitchen/1683396875.643998-hmzrh5',
+ );
+ });
+});
+
+describe('getRecordingMediaContentID', () => {
+ it('should get recording content ID', () => {
+ expect(
+ getRecordingMediaContentID(
+ 'clientid',
+ 'kitchen',
+ createFrigateRecording({
+ startTime: new Date('2023-04-29T14:00:00'),
+ }),
+ ),
+ ).toBe('media-source://frigate/clientid/recordings/kitchen/2023-04-29/14');
+ });
+});
+
+describe('getRecordingID', () => {
+ it('should get recording ID', () => {
+ expect(
+ getRecordingID(
+ createCameraConfig({
+ frigate: {
+ client_id: 'unique_client_id',
+ camera_name: 'kitchen',
+ },
+ }),
+ createFrigateRecording({
+ startTime: new Date('2023-04-29T14:00:00Z'),
+ endTime: new Date('2023-04-29T14:59:59Z'),
+ }),
+ ),
+ ).toBe('unique_client_id/kitchen/1682776800000/1682780399000');
+ });
+ it('should get recording ID without client_id or camera_name', () => {
+ // Note: This path is defended against in the code but should not happen in
+ // practice as this would be a malformed (not-zod-parsed) camera config.
+ const cameraConfig = mock();
+ expect(
+ getRecordingID(
+ cameraConfig,
+ createFrigateRecording({
+ startTime: new Date('2023-04-29T14:00:00Z'),
+ endTime: new Date('2023-04-29T14:59:59Z'),
+ }),
+ ),
+ ).toBe('//1682776800000/1682780399000');
+ });
+});
diff --git a/tests/camera-manager/utils.test.ts b/tests/camera-manager/utils.test.ts
new file mode 100644
index 00000000..96ba0731
--- /dev/null
+++ b/tests/camera-manager/utils.test.ts
@@ -0,0 +1,164 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ capEndDate,
+ convertRangeToCacheFriendlyTimes,
+ getCameraEntityFromConfig,
+ sortMedia,
+} from '../../src/camera-manager/util.js';
+import { ViewMedia, ViewMediaType } from '../../src/view/media.js';
+import { CameraConfig, cameraConfigSchema } from '../../src/types.js';
+
+describe('convertRangeToCacheFriendlyTimes', () => {
+ it('should return cache friendly within hour range', () => {
+ expect(
+ convertRangeToCacheFriendlyTimes({
+ start: new Date('2023-04-29T14:01:02'),
+ end: new Date('2023-04-29T14:11:03'),
+ }),
+ ).toEqual({
+ start: new Date('2023-04-29T14:00:00'),
+ end: new Date('2023-04-29T14:59:59.999'),
+ });
+ });
+
+ it('should return cache friendly within day range', () => {
+ expect(
+ convertRangeToCacheFriendlyTimes({
+ start: new Date('2023-04-29T14:01:02'),
+ end: new Date('2023-04-29T15:11:03'),
+ }),
+ ).toEqual({
+ start: new Date('2023-04-29T00:00:00'),
+ end: new Date('2023-04-29T23:59:59.999'),
+ });
+ });
+
+ it('should cap end date', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2023-04-29T14:25'));
+ expect(
+ convertRangeToCacheFriendlyTimes(
+ {
+ start: new Date('2023-04-29T14:01:02'),
+ end: new Date('2023-04-29T14:11:03'),
+ },
+ { endCap: true },
+ ),
+ ).toEqual({
+ start: new Date('2023-04-29T14:00:00'),
+ end: new Date('2023-04-29T14:25:59.999'),
+ });
+ vi.useRealTimers();
+ });
+});
+
+describe('capEndDate', () => {
+ it('should cap end date', () => {
+ const fakeNow = new Date('2023-04-29T14:25');
+ vi.useFakeTimers();
+ vi.setSystemTime(fakeNow);
+
+ expect(capEndDate(new Date('2023-04-29T15:02'))).toEqual(fakeNow);
+
+ vi.useRealTimers();
+ });
+
+ it('should not cap end date', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2023-04-29T14:25'));
+
+ const testDate = new Date('2023-04-29T14:24');
+ expect(capEndDate(testDate)).toEqual(testDate);
+
+ vi.useRealTimers();
+ });
+});
+
+// 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',
+ new Date('2023-04-29T14:25'),
+ 'clip',
+ 'camera-1',
+ );
+ const media_2 = new TestViewMedia(
+ 'id-2',
+ new Date('2023-04-29T14:26'),
+ 'clip',
+ 'camera-1',
+ );
+ const media_3_dup_id = new TestViewMedia(
+ 'id-2',
+ new Date('2023-04-29T14:26'),
+ 'clip',
+ 'camera-1',
+ );
+ const media_4_no_id = new TestViewMedia(
+ null,
+ new Date('2023-04-29T14:27'),
+ 'clip',
+ 'camera-1',
+ );
+
+ it('should sort sorted media', () => {
+ const media = [media_1, media_2];
+ expect(sortMedia(media)).toEqual(media);
+ });
+ it('should sort unsorted media', () => {
+ expect(sortMedia([media_2, media_1])).toEqual([media_1, media_2]);
+ });
+ it('should remove duplicate id', () => {
+ expect(sortMedia([media_1, media_2, media_3_dup_id])).toEqual([media_1, media_2]);
+ });
+ it('should remove de-duplicate by object if no id', () => {
+ expect(sortMedia([media_1, media_2, media_4_no_id, media_4_no_id])).toEqual([
+ media_1,
+ media_2,
+ media_4_no_id,
+ ]);
+ });
+});
+
+describe('getCameraEntityFromConfig', () => {
+ const createCameraConfig = (config: Partial): CameraConfig => {
+ return cameraConfigSchema.parse(config);
+ };
+
+ it('should get camera_entity', () => {
+ expect(getCameraEntityFromConfig(createCameraConfig({ camera_entity: 'foo' }))).toBe(
+ 'foo',
+ );
+ });
+ it('should get camera_entity from webrtc_card config', () => {
+ expect(
+ getCameraEntityFromConfig(createCameraConfig({ webrtc_card: { entity: 'bar' } })),
+ ).toBe('bar');
+ });
+ it('should get no camera_entity', () => {
+ expect(getCameraEntityFromConfig(createCameraConfig({}))).toBeNull();
+ });
+});
diff --git a/tests/conditions.test.ts b/tests/conditions.test.ts
new file mode 100644
index 00000000..3c98db99
--- /dev/null
+++ b/tests/conditions.test.ts
@@ -0,0 +1,350 @@
+import { afterEach, describe, it, expect, vi } from 'vitest';
+import {
+ ConditionController,
+ ConditionEvaluateRequestEvent,
+ evaluateConditionViaEvent,
+ getOverriddenConfig,
+ getOverridesByKey,
+} from '../src/conditions';
+import { createCondition, createConfig, createStateEntity } from './test-utils';
+
+// @vitest-environment jsdom
+describe('ConditionEvaluateRequestEvent', () => {
+ it('should construct', () => {
+ const condition = createCondition({ fullscreen: true });
+ const event = new ConditionEvaluateRequestEvent(condition, {
+ bubbles: true,
+ composed: true,
+ });
+
+ expect(event.type).toBe('frigate-card:condition:evaluate');
+ expect(event.condition).toBe(condition);
+ expect(event.bubbles).toBeTruthy();
+ expect(event.composed).toBeTruthy();
+ });
+});
+
+describe('evaluateConditionViaEvent', () => {
+ it('should evaluate true without condition', () => {
+ const element = document.createElement('div');
+ expect(evaluateConditionViaEvent(element)).toBeTruthy();
+ });
+ it('should dispatch event with condition and evaluate true', () => {
+ const element = document.createElement('div');
+ const condition = createCondition({ fullscreen: true });
+ const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => {
+ expect(ev.condition).toBe(condition);
+ ev.evaluation = true;
+ });
+ element.addEventListener('frigate-card:condition:evaluate', handler);
+
+ expect(evaluateConditionViaEvent(element, condition)).toBeTruthy();
+ expect(handler).toBeCalled();
+ });
+ it('should dispatch event with condition and evaluate false', () => {
+ const element = document.createElement('div');
+ const condition = createCondition({ fullscreen: true });
+ const handler = vi.fn().mockImplementation((ev: ConditionEvaluateRequestEvent) => {
+ expect(ev.condition).toBe(condition);
+ ev.evaluation = false;
+ });
+ element.addEventListener('frigate-card:condition:evaluate', handler);
+
+ expect(evaluateConditionViaEvent(element, condition)).toBeFalsy();
+ expect(handler).toBeCalled();
+ });
+ it('should dispatch event evaluate false if no evaluation', () => {
+ const element = document.createElement('div');
+ const condition = createCondition({ fullscreen: true });
+ const handler = vi.fn();
+ element.addEventListener('frigate-card:condition:evaluate', handler);
+
+ expect(evaluateConditionViaEvent(element, condition)).toBeFalsy();
+ expect(handler).toBeCalled();
+ });
+});
+
+describe('getOverriddenConfig', () => {
+ const config = {
+ menu: {
+ style: 'none',
+ },
+ };
+ const overrides = [
+ {
+ overrides: {
+ menu: {
+ style: 'above',
+ },
+ },
+ conditions: {
+ fullscreen: true,
+ },
+ },
+ ];
+
+ it('should not override config', () => {
+ const controller = new ConditionController();
+ expect(getOverriddenConfig(controller, config, overrides)).toBe(config);
+ });
+
+ it('should override config', () => {
+ const controller = new ConditionController();
+ controller.setState({ fullscreen: true });
+
+ expect(getOverriddenConfig(controller, config, overrides)).toEqual({
+ menu: {
+ style: 'above',
+ },
+ });
+ });
+});
+
+describe('getOverridesByKey', () => {
+ const condition = {
+ fullscreen: true,
+ };
+ const override = {
+ menu: {
+ style: 'above',
+ },
+ };
+ const overrides = [
+ {
+ overrides: override,
+ conditions: condition,
+ },
+ ];
+
+ it('should get overrides', () => {
+ expect(getOverridesByKey('menu', overrides)).toEqual([
+ { conditions: condition, overrides: { style: 'above' } },
+ ]);
+ });
+
+ it('should get no overrides', () => {
+ expect(getOverridesByKey('live', overrides)).toEqual([]);
+ });
+
+ it('should get no overrides when undefined', () => {
+ expect(getOverridesByKey('live')).toEqual([]);
+ });
+});
+
+describe('ConditionController', () => {
+ const config = {
+ type: 'custom:frigate-card',
+ cameras: [],
+ elements: [
+ {
+ type: 'custom:frigate-card-conditional',
+ conditions: {
+ fullscreen: true,
+ },
+ elements: [
+ {
+ type: 'custom:nested-unknown-object',
+ unknown_key: {
+ type: 'custom:frigate-card-conditional',
+ conditions: {
+ media_query: 'media query goes here',
+ },
+ elements: [],
+ },
+ },
+ ],
+ },
+ ],
+ overrides: [
+ {
+ overrides: {
+ menu: {
+ style: 'overlay',
+ },
+ },
+ conditions: {
+ fullscreen: true,
+ state: [
+ {
+ entity: 'binary_sensor.foo',
+ state: 'on',
+ },
+ ],
+ },
+ },
+ ],
+ };
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('should add listener', () => {
+ const controller = new ConditionController();
+ const handler = vi.fn();
+ controller.addStateListener(handler);
+ controller.setState({ fullscreen: true });
+ expect(handler).toBeCalled();
+ });
+
+ it('should remove listener', () => {
+ const controller = new ConditionController();
+ const handler = vi.fn();
+ controller.addStateListener(handler);
+ controller.removeStateListener(handler);
+ controller.setState({ fullscreen: true });
+ expect(handler).not.toBeCalled();
+ });
+
+ it('should get wrapper', () => {
+ const controller = new ConditionController();
+ const wrapper_1 = controller.getEpoch();
+ expect(wrapper_1).toEqual({ controller: controller });
+
+ controller.setState({ fullscreen: true });
+
+ const wrapper_2 = controller.getEpoch();
+ expect(wrapper_2).toEqual({ controller: controller });
+
+ // Since the state was set the wrappers should be different.
+ expect(wrapper_1).not.toBe(wrapper_2);
+ });
+
+ it('should not return hasHAStateConditions without HA state conditions', () => {
+ const controller = new ConditionController();
+ expect(controller.hasHAStateConditions).toBeFalsy();
+ });
+
+ it('should return hasHAStateConditions with HA state conditions', () => {
+ vi.spyOn(window, 'matchMedia').mockReturnValueOnce({
+ matches: false,
+ addEventListener: vi.fn(),
+ } as unknown as MediaQueryList);
+ const controller = new ConditionController(createConfig(config));
+ expect(controller.hasHAStateConditions).toBeTruthy();
+ });
+
+ it('should evaluate conditions with a view', () => {
+ const controller = new ConditionController();
+ const condition = { view: ['foo'] };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ view: 'foo' });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ });
+
+ it('should evaluate conditions with fullscreen', () => {
+ const controller = new ConditionController();
+ const condition = { fullscreen: true };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ fullscreen: true });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ controller.setState({ fullscreen: false });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should evaluate conditions with expand', () => {
+ const controller = new ConditionController();
+ const condition = { expand: true };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ expand: true });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ controller.setState({ expand: false });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should evaluate conditions with camera', () => {
+ const controller = new ConditionController();
+ const condition = { camera: ['bar'] };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ camera: 'bar' });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ controller.setState({ camera: 'will-not-match' });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should evaluate conditions with ha state positive check', () => {
+ const controller = new ConditionController();
+ const condition = {
+ state: [
+ {
+ entity: 'binary_sensor.foo',
+ state: 'on',
+ },
+ ],
+ };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ state: { 'binary_sensor.foo': createStateEntity() } });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ controller.setState({
+ state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) },
+ });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should evaluate conditions with ha state negative check', () => {
+ const controller = new ConditionController();
+ const condition = {
+ state: [
+ {
+ entity: 'binary_sensor.foo',
+ state_not: 'on',
+ },
+ ],
+ };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ state: { 'binary_sensor.foo': createStateEntity() } });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({
+ state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) },
+ });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ });
+
+ it('should evaluate conditions with media_loaded', () => {
+ const controller = new ConditionController();
+ const condition = { media_loaded: true };
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ controller.setState({ media_loaded: true });
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ controller.setState({ media_loaded: false });
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should evaluate conditions with media query', () => {
+ vi.spyOn(window, 'matchMedia')
+ .mockReturnValueOnce({ matches: true })
+ .mockReturnValueOnce({ matches: false });
+
+ const controller = new ConditionController();
+ const condition = { media_query: 'whatever' };
+ expect(controller.evaluateCondition(condition)).toBeTruthy();
+ expect(controller.evaluateCondition(condition)).toBeFalsy();
+ });
+
+ it('should trigger on changes to media query conditions', () => {
+ const addEventListener = vi.fn();
+ const removeEventListener = vi.fn();
+ vi.spyOn(window, 'matchMedia').mockReturnValueOnce({
+ matches: true,
+ addEventListener: addEventListener,
+ removeEventListener: removeEventListener,
+ } as unknown as MediaQueryList);
+
+ const controller = new ConditionController(createConfig(config));
+ expect(addEventListener).toHaveBeenCalledWith('change', expect.anything());
+
+ const callback = vi.fn();
+ controller.addStateListener(callback);
+
+ // Call the media query callback and use it to pretend a match happened. The
+ // callback is the 0th mock innvocation and the 1st argument.
+ addEventListener.mock.calls[0][1]();
+
+ // This should result in a callback to our state listener.
+ expect(callback).toBeCalled();
+
+ // Destroy the controller, which should remove the media query listener.
+ controller.destroy();
+ expect(removeEventListener).toBeCalled();
+ });
+});
diff --git a/tests/test-utils.ts b/tests/test-utils.ts
new file mode 100644
index 00000000..dcc71536
--- /dev/null
+++ b/tests/test-utils.ts
@@ -0,0 +1,91 @@
+import { HomeAssistant } from 'custom-card-helpers';
+import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
+import { mock } from 'vitest-mock-extended';
+import { FrigateEvent, FrigateRecording } from '../src/camera-manager/frigate/types';
+import {
+ CameraConfig,
+ FrigateCardCondition,
+ FrigateCardConfig,
+ cameraConfigSchema,
+ frigateCardConditionSchema,
+ frigateCardConfigSchema,
+} from '../src/types';
+import { Entity } from '../src/utils/ha/entity-registry/types';
+
+export const createCameraConfig = (config: unknown): CameraConfig => {
+ return cameraConfigSchema.parse(config);
+};
+
+export const createCondition = (
+ condition?: Partial,
+): FrigateCardCondition => {
+ return frigateCardConditionSchema.parse(condition ?? {});
+};
+
+export const createConfig = (config?: Partial): FrigateCardConfig => {
+ return frigateCardConfigSchema.parse(config);
+};
+
+export const createHASS = (states?: HassEntities): HomeAssistant => {
+ const hass = mock();
+ if (states) {
+ hass.states = states;
+ }
+ return hass;
+};
+
+export const createRegistryEntity = (entity?: Partial): Entity => {
+ return {
+ config_entry_id: entity?.config_entry_id ?? null,
+ device_id: entity?.device_id ?? null,
+ disabled_by: entity?.disabled_by ?? null,
+ entity_id: entity?.entity_id ?? 'entity_id',
+ hidden_by: entity?.hidden_by ?? null,
+ platform: entity?.platform ?? 'platform',
+ translation_key: entity?.translation_key ?? null,
+ unique_id: entity?.unique_id ?? 'unique_id',
+ };
+};
+
+export const createStateEntity = (entity?: Partial): HassEntity => {
+ return {
+ entity_id: entity?.entity_id ?? 'entity_id',
+ state: entity?.state ?? 'on',
+ last_changed: entity?.last_changed ?? 'never',
+ last_updated: entity?.last_updated ?? 'never',
+ attributes: entity?.attributes ?? {},
+ context: entity?.context ?? {
+ id: 'id',
+ parent_id: 'parent_id',
+ user_id: 'user_id',
+ },
+ };
+};
+
+export const createFrigateEvent = (event?: Partial) => {
+ return {
+ camera: 'camera',
+ end_time: 1683397124,
+ false_positive: false,
+ has_clip: true,
+ has_snapshot: true,
+ id: '1683396875.643998-hmzrh5',
+ label: 'person',
+ sub_label: null,
+ start_time: 1683395000,
+ top_score: 0.841796875,
+ zones: [],
+ retain_indefinitely: false,
+ ...event,
+ };
+};
+
+export const createFrigateRecording = (recording?: Partial) => {
+ return {
+ cameraID: 'cameraID',
+ startTime: new Date('2023-04-29T14:00:00'),
+ endTime: new Date('2023-04-29T14:59:59'),
+ events: 42,
+ ...recording,
+ };
+};
diff --git a/tests/utils/action.test.ts b/tests/utils/action.test.ts
new file mode 100644
index 00000000..264db819
--- /dev/null
+++ b/tests/utils/action.test.ts
@@ -0,0 +1,202 @@
+import { handleActionConfig, hasAction } from 'custom-card-helpers';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { mock } from 'vitest-mock-extended';
+import { actionSchema } from '../../src/types';
+import {
+ convertActionToFrigateCardCustomAction,
+ createFrigateCardCustomAction,
+ frigateCardHandleActionConfig,
+ frigateCardHasAction,
+ getActionConfigGivenAction,
+ stopEventFromActivatingCardWideActions,
+} from '../../src/utils/action';
+import { createHASS } from '../test-utils';
+
+vi.mock('custom-card-helpers');
+
+describe('convertActionToFrigateCardCustomAction', () => {
+ it('should skip null action', () => {
+ expect(convertActionToFrigateCardCustomAction(null)).toBeFalsy();
+ });
+
+ it('should parse valid', () => {
+ expect(
+ convertActionToFrigateCardCustomAction({
+ action: 'custom:frigate-card-action',
+ frigate_card_action: 'download',
+ }),
+ ).toEqual({
+ action: 'fire-dom-event',
+ frigate_card_action: 'download',
+ });
+ });
+
+ it('should not parse invalid', () => {
+ expect(convertActionToFrigateCardCustomAction('this is garbage')).toBeNull();
+ });
+});
+
+describe('createFrigateCardCustomAction', () => {
+ it('should create camera_select', () => {
+ expect(
+ createFrigateCardCustomAction('camera_select', {
+ camera: 'camera',
+ cardID: 'card_id',
+ }),
+ ).toEqual({
+ action: 'fire-dom-event',
+ camera: 'camera',
+ frigate_card_action: 'camera_select',
+ card_id: 'card_id',
+ });
+ });
+
+ it('should not create camera_select without camera', () => {
+ expect(createFrigateCardCustomAction('camera_select')).toBeNull();
+ });
+
+ it('should create media_player', () => {
+ expect(
+ createFrigateCardCustomAction('media_player', {
+ media_player: 'device',
+ media_player_action: 'play',
+ cardID: 'card_id',
+ }),
+ ).toEqual({
+ action: 'fire-dom-event',
+ frigate_card_action: 'media_player',
+ media_player: 'device',
+ media_player_action: 'play',
+ card_id: 'card_id',
+ });
+ });
+
+ it('should not create media_player without player or action', () => {
+ expect(
+ createFrigateCardCustomAction('media_player', {
+ media_player_action: 'play',
+ }),
+ ).toBeNull();
+
+ expect(
+ createFrigateCardCustomAction('media_player', {
+ media_player: 'device',
+ }),
+ ).toBeNull();
+ });
+
+ it('should create general action', () => {
+ expect(
+ createFrigateCardCustomAction('clips', {
+ cardID: 'card_id',
+ }),
+ ).toEqual({
+ action: 'fire-dom-event',
+ frigate_card_action: 'clips',
+ card_id: 'card_id',
+ });
+ });
+});
+
+describe('getActionConfigGivenAction', () => {
+ const action = actionSchema.parse({
+ action: 'fire-dom-event',
+ frigate_card_action: 'clips',
+ });
+
+ it('should not handle undefined arguments', () => {
+ expect(getActionConfigGivenAction()).toBeUndefined();
+ });
+
+ it('should not handle unknown interactions', () => {
+ expect(
+ getActionConfigGivenAction('triple_poke', { triple_poke_action: action }),
+ ).toBeUndefined();
+ });
+
+ it('should handle tap actions', () => {
+ expect(getActionConfigGivenAction('tap', { tap_action: action })).toBe(action);
+ });
+
+ it('should handle hold actions', () => {
+ expect(getActionConfigGivenAction('hold', { hold_action: action })).toBe(action);
+ });
+
+ it('should handle double_tap actions', () => {
+ expect(getActionConfigGivenAction('double_tap', { double_tap_action: action })).toBe(
+ action,
+ );
+ });
+
+ it('should handle end_tap actions', () => {
+ expect(getActionConfigGivenAction('end_tap', { end_tap_action: action })).toBe(
+ action,
+ );
+ });
+
+ it('should handle start_tap actions', () => {
+ expect(getActionConfigGivenAction('start_tap', { start_tap_action: action })).toBe(
+ action,
+ );
+ });
+});
+
+// @vitest-environment jsdom
+describe('frigateCardHandleActionConfig', () => {
+ const element = document.createElement('div');
+ const action = actionSchema.parse({
+ action: 'none',
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should not handle missing arguments', () => {
+ expect(
+ frigateCardHandleActionConfig(element, createHASS(), {}, 'triple_poke'),
+ ).toBeFalsy();
+ });
+
+ it('should handle simple case', () => {
+ frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', action);
+ expect(handleActionConfig).toBeCalled();
+ });
+
+ it('should handle array case', () => {
+ frigateCardHandleActionConfig(element, createHASS(), {}, 'tap', [
+ action,
+ action,
+ action,
+ ]);
+ expect(handleActionConfig).toBeCalledTimes(3);
+ });
+});
+
+describe('frigateCardHasAction', () => {
+ const action = actionSchema.parse({
+ action: 'toggle',
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should handle non-array case', () => {
+ expect(frigateCardHasAction(action)).toBeFalsy();
+ expect(hasAction).toBeCalledTimes(1);
+ });
+ it('should handle array case', () => {
+ expect(frigateCardHasAction([action, action, action])).toBeFalsy();
+ expect(hasAction).toBeCalledTimes(3);
+ });
+});
+
+// @vitest-environment jsdom
+describe('stopEventFromActivatingCardWideActions', () => {
+ it('should stop event from propogating', () => {
+ const event = mock();
+ stopEventFromActivatingCardWideActions(event);
+ expect(event.stopPropagation).toBeCalled();
+ });
+});
diff --git a/tests/utils/audio.test.ts b/tests/utils/audio.test.ts
new file mode 100644
index 00000000..c4901023
--- /dev/null
+++ b/tests/utils/audio.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import { AudioProperties, mayHaveAudio } from '../../src/utils/audio';
+
+// @vitest-environment jsdom
+describe('mayHaveAudio', () => {
+ it('should detect audio when mozHasAudio true', () => {
+ const element: HTMLVideoElement & AudioProperties = document.createElement('video');
+ element.mozHasAudio = true;
+ expect(mayHaveAudio(element)).toBeTruthy();
+ });
+
+ it('should not detect audio when mozHasAudio undefined', () => {
+ const element: HTMLVideoElement & AudioProperties = document.createElement('video');
+ element.mozHasAudio = undefined;
+ expect(mayHaveAudio(element)).toBeFalsy();
+ });
+
+ it('should detect audio when audioTracks has length', () => {
+ // Workaround: "Cannot set property audioTracks of # which has only a getter"
+ const element = {} as HTMLVideoElement & AudioProperties;
+ element.audioTracks = [1, 2, 3];
+ expect(mayHaveAudio(element)).toBeTruthy();
+ });
+
+ it('should not detect audio when audioTracks has no length', () => {
+ // Workaround: "Cannot set property audioTracks of # which has only a getter"
+ const element = {} as HTMLVideoElement & AudioProperties;
+ element.audioTracks = [];
+ expect(mayHaveAudio(element)).toBeFalsy();
+ });
+
+ it('should detect audio when no evidence to the contrary', () => {
+ const element = {} as HTMLVideoElement & AudioProperties;
+ expect(mayHaveAudio(element)).toBeTruthy();
+ });
+});
diff --git a/tests/utils/basic.test.ts b/tests/utils/basic.test.ts
index 1589495f..a17fd576 100644
--- a/tests/utils/basic.test.ts
+++ b/tests/utils/basic.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, afterAll, beforeEach } from 'vitest';
+import { describe, it, expect, vi, afterAll } from 'vitest';
import { FrigateCardError } from '../../src/types';
import {
allPromises,
diff --git a/tests/utils/camera.test.ts b/tests/utils/camera.test.ts
new file mode 100644
index 00000000..1b7c9689
--- /dev/null
+++ b/tests/utils/camera.test.ts
@@ -0,0 +1,88 @@
+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';
+
+vi.mock('../../src/camera-manager/manager.js');
+
+describe('getCameraID', () => {
+ it('should get camera id with id', () => {
+ const config = createCameraConfig({ id: 'foo' });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get camera id with camera_entity', () => {
+ const config = createCameraConfig({ camera_entity: 'foo' });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get camera id with webrtc entity', () => {
+ const config = createCameraConfig({ webrtc_card: { entity: 'foo' } });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get camera id with frigate camera_name', () => {
+ const config = createCameraConfig({
+ frigate: { client_id: 'bar', camera_name: 'foo' },
+ });
+ expect(getCameraID(config)).toBe('foo');
+ });
+ it('should get blank id without anything', () => {
+ const config = createCameraConfig({});
+ expect(getCameraID(config)).toBe('');
+ });
+});
+
+describe('getAllDependentCameras', () => {
+ it('should return null without cameraManager', () => {
+ expect(getAllDependentCameras()).toBeNull();
+ });
+ it('should return null without cameraID', () => {
+ expect(getAllDependentCameras(mock())).toBeNull();
+ });
+ it('should return dependent cameras', () => {
+ const cameraConfigs: CameraConfigs = new Map([
+ [
+ 'one',
+ createCameraConfig({
+ dependencies: {
+ cameras: ['two', 'three'],
+ },
+ }),
+ ],
+ ['two', createCameraConfig({})],
+ ]);
+
+ const cameraManager = new CameraManager(mock(), {});
+ const store = mock();
+ vi.mocked(cameraManager.getStore).mockReturnValue(store);
+ store.getCameras.mockReturnValue(cameraConfigs);
+
+ expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
+ new Set(['one', 'two']),
+ );
+ });
+ it('should return all cameras', () => {
+ const cameraConfigs: CameraConfigs = new Map([
+ [
+ 'one',
+ createCameraConfig({
+ dependencies: {
+ all_cameras: true,
+ },
+ }),
+ ],
+ ['two', createCameraConfig({})],
+ ]);
+
+ const cameraManager = new CameraManager(mock(), {});
+ const store = mock();
+ vi.mocked(cameraManager.getStore).mockReturnValue(store);
+ store.getCameras.mockReturnValue(cameraConfigs);
+
+ expect(getAllDependentCameras(cameraManager, 'one')).toEqual(
+ new Set(['one', 'two']),
+ );
+ });
+});
diff --git a/tests/utils/debug.test.ts b/tests/utils/debug.test.ts
new file mode 100644
index 00000000..52f678a3
--- /dev/null
+++ b/tests/utils/debug.test.ts
@@ -0,0 +1,17 @@
+import { afterAll, describe, expect, it, vi } from 'vitest';
+import { log } from '../../src/utils/debug.js';
+
+describe('log', () => {
+ const spy = vi.spyOn(global.console, 'debug').mockReturnValue(undefined);
+ afterAll(() => {
+ vi.resetAllMocks();
+ });
+ it('should do nothing without debug logging set', () => {
+ log({}, 'foo');
+ expect(spy).not.toBeCalled();
+ });
+ it('should log debug when appropriately configured', () => {
+ log({ debug: { logging: true } }, 'foo');
+ expect(spy).toBeCalledWith('foo');
+ });
+});
diff --git a/tests/utils/microphone.test.ts b/tests/utils/microphone.test.ts
new file mode 100644
index 00000000..36b5197e
--- /dev/null
+++ b/tests/utils/microphone.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
+import { MicrophoneController } from '../../src/utils/microphone';
+import { mock } from 'vitest-mock-extended';
+
+const navigatorMock = {
+ mediaDevices: {
+ getUserMedia: vi.fn(),
+ },
+};
+
+// @vitest-environment jsdom
+describe('MicrophoneController', () => {
+ beforeEach(() => {
+ vi.stubGlobal('navigator', navigatorMock);
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ vi.unstubAllGlobals;
+ });
+
+ const createMockStream = (mute?: boolean): MediaStream => {
+ const stream = mock();
+ const track = mock();
+ track.enabled = !mute;
+ stream.getTracks.mockImplementation(() => [track]);
+ return stream;
+ };
+
+ it('should be muted on creation', () => {
+ const controller = new MicrophoneController();
+ expect(controller).toBeTruthy();
+ expect(controller.isMuted()).toBeTruthy();
+ });
+
+ it('should be undefined without creation', () => {
+ const controller = new MicrophoneController();
+ expect(controller.getStream()).toBeUndefined();
+ });
+
+ it('should connect', async () => {
+ const controller = new MicrophoneController();
+ const stream = createMockStream();
+ navigatorMock.mediaDevices.getUserMedia.mockReturnValue(stream);
+ await controller.connect();
+ expect(controller.isConnected()).toBeTruthy();
+ expect(controller.getStream()).toBe(stream);
+ expect(controller.isMuted()).toBeTruthy();
+ });
+
+ it('should be forbidden when permission denied', async () => {
+ // Don't actually log messages to the console during the test.
+ vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
+ const controller = new MicrophoneController();
+ navigatorMock.mediaDevices.getUserMedia.mockRejectedValue(new Error());
+ await controller.connect();
+ expect(controller.isConnected()).toBeFalsy();
+ expect(controller.isForbidden()).toBeTruthy();
+ });
+
+ it('should mute', async () => {
+ const controller = new MicrophoneController();
+ navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
+ await controller.connect();
+ controller.mute();
+ expect(controller.isMuted()).toBeTruthy();
+
+ controller.unmute();
+ expect(controller.isMuted()).toBeFalsy();
+ });
+
+ it('should be unmuted on creation if unmute called first', async () => {
+ const controller = new MicrophoneController();
+ controller.unmute();
+ navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
+ await controller.connect();
+ expect(controller.isMuted()).toBeFalsy();
+ });
+
+ it('should disconnect', async () => {
+ const controller = new MicrophoneController();
+ navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
+ await controller.connect();
+ expect(controller.isConnected()).toBeTruthy();
+
+ await controller.disconnect();
+ expect(controller.isConnected()).toBeFalsy();
+ });
+
+ it('should automatically disconnect', async () => {
+ const seconds = 10;
+ vi.useFakeTimers();
+
+ const controller = new MicrophoneController(seconds);
+ navigatorMock.mediaDevices.getUserMedia.mockReturnValue(createMockStream());
+
+ await controller.connect();
+ expect(controller.isConnected()).toBeTruthy();
+
+ vi.advanceTimersByTime(seconds * 1000);
+
+ expect(controller.isConnected()).toBeFalsy();
+ vi.useRealTimers();
+ });
+});
diff --git a/tests/utils/substream.test.ts b/tests/utils/substream.test.ts
new file mode 100644
index 00000000..9132c764
--- /dev/null
+++ b/tests/utils/substream.test.ts
@@ -0,0 +1,144 @@
+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,
+ createViewWithSelectedSubstream,
+ createViewWithoutSubstream,
+ hasSubstream,
+} from '../../src/utils/substream';
+import { View } from '../../src/view/view';
+
+vi.mock('../../src/utils/camera');
+
+describe('createViewWithSelectedSubstream', () => {
+ it('should create view with selected substream', () => {
+ const view = new View({ view: 'live', camera: 'camera' });
+ const newView = createViewWithSelectedSubstream(view, 'substream');
+ expect(newView?.context?.live?.overrides).toEqual(
+ new Map([['camera', 'substream']]),
+ );
+ });
+
+ it('should create view with selected substream with existing overrides', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera']]),
+ },
+ },
+ });
+ const newView = createViewWithSelectedSubstream(view, 'substream');
+ expect(newView?.context?.live?.overrides).toEqual(
+ new Map([['camera', 'substream']]),
+ );
+ });
+});
+
+describe('createViewWithoutSubstream', () => {
+ it('should create view without substream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera']]),
+ },
+ },
+ });
+ const newView = createViewWithoutSubstream(view);
+ expect(newView?.context?.live?.overrides).toEqual(new Map());
+ });
+});
+
+describe('hasSubstream', () => {
+ it('should detect substream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera2']]),
+ },
+ },
+ });
+ expect(hasSubstream(view)).toBeTruthy();
+ });
+ it('should not detect substream when absent', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ });
+ expect(hasSubstream(view)).toBeFalsy();
+ });
+ it('should not detect substream when main stream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera']]),
+ },
+ },
+ });
+ expect(hasSubstream(view)).toBeFalsy();
+ });
+});
+
+describe('createViewWithNextStream', () => {
+ it('should create new equal view with no dependencies', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.camera).toBe(view.camera);
+ expect(newView.view).toBe(view.view);
+ expect(newView.context).toEqual(view.context);
+ });
+ it('should create new view with next stream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
+ });
+ it('should create new view with next stream that cycles back', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera2']]),
+ },
+ },
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
+ });
+ it('should create new view with first stream with invalid substream', () => {
+ const view = new View({
+ view: 'live',
+ camera: 'camera',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera-that-does-not-exist']]),
+ },
+ },
+ });
+ vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
+ const cameraManager = mock();
+ const newView = createViewWithNextStream(cameraManager, view);
+ expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
+ });
+});
diff --git a/tests/utils/zoom.test.ts b/tests/utils/zoom.test.ts
new file mode 100644
index 00000000..4d9daf10
--- /dev/null
+++ b/tests/utils/zoom.test.ts
@@ -0,0 +1,183 @@
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { Zoom } from '../../src/utils/zoom/zoom';
+import { PanzoomObject, PanzoomEventDetail } from '@dermotduffy/panzoom';
+import Panzoom from '@dermotduffy/panzoom';
+import { mock } from 'vitest-mock-extended';
+
+vi.mock('@dermotduffy/panzoom');
+
+// https://github.com/jsdom/jsdom/issues/2527
+(window as any).PointerEvent = MouseEvent;
+
+// @vitest-environment jsdom
+describe('Zoom', () => {
+ const mediaMediSpy = vi.spyOn(window, 'matchMedia');
+
+ const createMockPanZoom = (): PanzoomObject => {
+ const panzoom = mock();
+ panzoom.getScale.mockReturnValue(1.0);
+ return panzoom;
+ };
+
+ const createAndRegisterZoom = (element: HTMLElement): Zoom => {
+ const zoom = new Zoom(element);
+ zoom.activate();
+ return zoom;
+ };
+
+ const createTouch = (target: HTMLElement): Touch => {
+ return {
+ clientX: 0,
+ clientY: 0,
+ force: 0,
+ identifier: 0,
+ pageX: 0,
+ pageY: 0,
+ radiusX: 0,
+ radiusY: 0,
+ rotationAngle: 0,
+ screenX: 0,
+ screenY: 0,
+ target: target,
+ };
+ };
+
+ beforeEach(() => {
+ mediaMediSpy.mockReturnValue({ matches: true });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should be creatable', () => {
+ const element = document.createElement('div');
+ const zoom = new Zoom(element);
+ expect(zoom).toBeTruthy();
+ });
+
+ it('should respond with pointer', () => {
+ const element = document.createElement('div');
+
+ const panzoom = createMockPanZoom();
+ vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
+
+ createAndRegisterZoom(element);
+
+ // Won't zoom without control key.
+ const ev_1 = new WheelEvent('wheel', { bubbles: false, deltaY: -120 });
+ element.dispatchEvent(ev_1);
+ expect(panzoom.zoomWithWheel).not.toBeCalled();
+
+ const ev_2 = new WheelEvent('wheel', {
+ bubbles: false,
+ deltaY: -120,
+ ctrlKey: true,
+ });
+ element.dispatchEvent(ev_2);
+ expect(panzoom.zoomWithWheel).toBeCalledWith(ev_2);
+
+ panzoom.getScale = vi.fn().mockReturnValue(1.2);
+
+ const ev_3 = new PointerEvent('pointerdown');
+ element.dispatchEvent(ev_3);
+ expect(panzoom.handleDown).toBeCalledWith(ev_3);
+
+ const ev_4 = new PointerEvent('pointermove');
+ element.dispatchEvent(ev_4);
+ expect(panzoom.handleMove).toBeCalledWith(ev_4);
+
+ const ev_5 = new PointerEvent('pointerup');
+ element.dispatchEvent(ev_5);
+ expect(panzoom.handleUp).toBeCalledWith(ev_5);
+ });
+
+ it('should respond with touch', () => {
+ mediaMediSpy.mockReturnValue({ matches: false });
+
+ const element = document.createElement('div');
+
+ const panzoom = createMockPanZoom();
+ vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
+
+ createAndRegisterZoom(element);
+
+ const ev_1 = new TouchEvent('touchstart', {
+ bubbles: false,
+ touches: [createTouch(element), createTouch(element)],
+ });
+ element.dispatchEvent(ev_1);
+ expect(panzoom.handleDown).toBeCalledWith(ev_1);
+
+ panzoom.getScale = vi.fn().mockReturnValue(1.2);
+
+ const ev_3 = new TouchEvent('touchstart');
+ element.dispatchEvent(ev_3);
+ expect(panzoom.handleDown).toBeCalledWith(ev_3);
+
+ const ev_4 = new TouchEvent('touchmove');
+ element.dispatchEvent(ev_4);
+ expect(panzoom.handleMove).toBeCalledWith(ev_4);
+
+ const ev_5 = new TouchEvent('touchend');
+ element.dispatchEvent(ev_5);
+ expect(panzoom.handleUp).toBeCalledWith(ev_5);
+ });
+
+ it('deactivate should remove event handlers', () => {
+ const element = document.createElement('div');
+
+ const panzoom = createMockPanZoom();
+ vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
+
+ createAndRegisterZoom(element).deactivate();
+
+ const ev_1 = new WheelEvent('wheel', {
+ bubbles: false,
+ deltaY: -120,
+ ctrlKey: true,
+ });
+ element.dispatchEvent(ev_1);
+ expect(panzoom.zoomWithWheel).not.toBeCalled();
+ });
+
+ it('should fire frigate cards on zoom/unzoom', () => {
+ const element = document.createElement('div');
+
+ const zoomedFunc = vi.fn();
+ const unzoomedFunc = vi.fn();
+
+ element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
+ element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
+
+ const panzoom = createMockPanZoom();
+ vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
+
+ createAndRegisterZoom(element);
+
+ const ev_1 = new CustomEvent('panzoomzoom', {
+ detail: {
+ x: 0,
+ y: 0,
+ scale: 1.2,
+ isSVG: false,
+ originalEvent: new PointerEvent('pointermove'),
+ },
+ });
+ element.dispatchEvent(ev_1);
+ expect(zoomedFunc).toBeCalled();
+ expect(unzoomedFunc).not.toBeCalled();
+
+ const ev_2 = new CustomEvent('panzoomzoom', {
+ detail: {
+ x: 0,
+ y: 0,
+ scale: 1,
+ isSVG: false,
+ originalEvent: new PointerEvent('pointermove'),
+ },
+ });
+ element.dispatchEvent(ev_2);
+ expect(unzoomedFunc).toBeCalled();
+ });
+});
diff --git a/tests/view/view.test.ts b/tests/view/view.test.ts
index dcc6c87a..bb4cb261 100644
--- a/tests/view/view.test.ts
+++ b/tests/view/view.test.ts
@@ -367,6 +367,60 @@ describe('View.adoptFromViewIfAppropriate', () => {
View.adoptFromViewIfAppropriate(next, current);
expect(next.view).toBe('clip');
});
+
+ it('should adopt live context overrides for substreams', () => {
+ const current = createView({
+ view: 'live',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera2']]),
+ },
+ },
+ });
+ const next = createView({
+ view: 'live',
+ });
+ View.adoptFromViewIfAppropriate(next, current);
+ expect(next.context?.live).toEqual(current.context?.live);
+ });
+
+ it('should not adopt live context overrides if there are new overrides', () => {
+ const current = createView({
+ view: 'live',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera2']]),
+ },
+ },
+ });
+ const next = createView({
+ view: 'live',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera3']]),
+ },
+ },
+ });
+ View.adoptFromViewIfAppropriate(next, current);
+ expect(next.context?.live?.overrides).toEqual(new Map([['camera', 'camera3']]));
+ });
+
+ it('should adopt live context overrides even if there is new context', () => {
+ const current = createView({
+ view: 'live',
+ context: {
+ live: {
+ overrides: new Map([['camera', 'camera2']]),
+ },
+ },
+ });
+ const next = createView({
+ view: 'live',
+ context: {},
+ });
+ View.adoptFromViewIfAppropriate(next, current);
+ expect(next.context?.live).toEqual(current.context?.live);
+ });
});
// @vitest-environment jsdom
diff --git a/yarn.lock b/yarn.lock
index 5f65843e..34f01e7f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -375,6 +375,13 @@ __metadata:
languageName: node
linkType: hard
+"@dermotduffy/panzoom@npm:^4.5.1":
+ version: 4.5.1
+ resolution: "@dermotduffy/panzoom@npm:4.5.1"
+ checksum: 4c826f910425e50d9155005947e14bf344ec6b6a68783feb5a62f6bd2d7261cc541ceb18462552be271539a284a24b423dca27201caf45e153002fc5bf1c188e
+ languageName: node
+ linkType: hard
+
"@duetds/date-picker@npm:^1.4.0":
version: 1.4.0
resolution: "@duetds/date-picker@npm:1.4.0"
@@ -3032,6 +3039,7 @@ __metadata:
"@babel/plugin-proposal-class-properties": ^7.18.6
"@babel/plugin-proposal-decorators": ^7.19.0
"@cycjimmy/jsmpeg-player": ^6.0.4
+ "@dermotduffy/panzoom": ^4.5.1
"@egjs/hammerjs": ^2.0.17
"@graphiteds/core": ^1.9.6
"@lit-labs/scoped-registry-mixin": ^1.0.1