diff --git a/project.inlang.json b/project.inlang.json new file mode 100644 index 00000000..9f3af7ab --- /dev/null +++ b/project.inlang.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://inlang.com/schema/project-settings", + "sourceLanguageTag": "en", + "languageTags": [ + "en", + "it", + "pt-BR", + "pt-PT" + ], + "modules": [ + "https://cdn.jsdelivr.net/npm/@inlang/plugin-json@4/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@1/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-identical-pattern@1/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-without-source@1/dist/index.js", + "https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@1/dist/index.js" + ], + "plugin.inlang.json": { + "pathPattern": "./src/localize/languages/{languageTag}.json", + "variableReferencePattern": [ + "{", + "}" + ] + } +} diff --git a/src/card-controller/view-manager.ts b/src/card-controller/view-manager.ts index 3465f710..39c95702 100644 --- a/src/card-controller/view-manager.ts +++ b/src/card-controller/view-manager.ts @@ -221,7 +221,7 @@ export class ViewManager { log( this._api.getConfigManager().getCardWideConfig(), `Frigate Card view change: `, - view.view, + view, ); this._view = view; diff --git a/src/card.ts b/src/card.ts index f7eca6c1..52ee0d1b 100644 --- a/src/card.ts +++ b/src/card.ts @@ -8,6 +8,9 @@ import { ViewContext } from 'view'; import 'web-dialog'; import pkg from '../package.json'; import { actionHandler } from './action-handler-directive.js'; +import { ConditionEvaluateRequestEvent } from './card-controller/conditions-manager.js'; +import { CardController } from './card-controller/controller'; +import { MenuButtonController } from './components-lib/menu-controller'; import './components/elements.js'; import { FrigateCardElements } from './components/elements.js'; import './components/menu.js'; @@ -21,15 +24,8 @@ import { FrigateCardConfig, MenuItem, RawFrigateCardConfig } from './config/type import { REPO_URL } from './const.js'; import { localize } from './localize/localize.js'; import cardStyle from './scss/card.scss'; -import { - ExtendedHomeAssistant, - MediaLoadedInfo, - Message, -} from './types.js'; +import { ExtendedHomeAssistant, MediaLoadedInfo, Message } from './types.js'; import { frigateCardHasAction } from './utils/action.js'; -import { ConditionEvaluateRequestEvent } from './card-controller/conditions-manager.js'; -import { CardController } from './card-controller/controller'; -import { MenuButtonController } from './utils/menu-controller'; import { View } from './view/view.js'; // *************************************************************************** diff --git a/src/cached-value-controller.ts b/src/components-lib/cached-value-controller.ts similarity index 98% rename from src/cached-value-controller.ts rename to src/components-lib/cached-value-controller.ts index 894d15c7..b1a87158 100644 --- a/src/cached-value-controller.ts +++ b/src/components-lib/cached-value-controller.ts @@ -1,5 +1,5 @@ import { ReactiveController, ReactiveControllerHost } from 'lit'; -import { Timer } from './utils/timer'; +import { Timer } from '../utils/timer'; export class CachedValueController implements ReactiveController { protected _value?: T; diff --git a/src/utils/media-grid-controller.ts b/src/components-lib/media-grid-controller.ts similarity index 99% rename from src/utils/media-grid-controller.ts rename to src/components-lib/media-grid-controller.ts index cdf0bba3..89a02b61 100644 --- a/src/utils/media-grid-controller.ts +++ b/src/components-lib/media-grid-controller.ts @@ -7,12 +7,12 @@ import { dispatchFrigateCardEvent, getChildrenFromElement, setOrRemoveAttribute, -} from './basic'; +} from '../utils/basic'; import { FrigateMediaLoadedEventTarget, dispatchExistingMediaLoadedInfoAsEvent, dispatchMediaUnloadedEvent, -} from './media-info'; +} from '../utils/media-info'; // The default minimum cell width: if the columns are not specified this value // is used to compute the number of columns, always trying to keep each cell as diff --git a/src/utils/menu-controller.ts b/src/components-lib/menu-controller.ts similarity index 98% rename from src/utils/menu-controller.ts rename to src/components-lib/menu-controller.ts index 23d43cb2..79efe5e6 100644 --- a/src/utils/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -13,12 +13,12 @@ import { MediaLoadedInfo, } from '../types'; import { View } from '../view/view'; -import { createFrigateCardCustomAction } from './action'; -import { getAllDependentCameras } from './camera'; +import { createFrigateCardCustomAction } from '../utils/action'; +import { getAllDependentCameras } from '../utils/camera'; import { MediaPlayerManager } from '../card-controller/media-player-manager'; import { MicrophoneManager } from '../card-controller/microphone-manager'; -import { getEntityIcon, getEntityTitle } from './ha'; -import { hasSubstream } from './substream'; +import { getEntityIcon, getEntityTitle } from '../utils/ha'; +import { hasSubstream } from '../utils/substream'; export interface MenuButtonControllerOptions { currentMediaLoadedInfo?: MediaLoadedInfo | null; showCameraUIButton?: boolean; diff --git a/src/utils/timeline-source.ts b/src/components-lib/timeline-source.ts similarity index 99% rename from src/utils/timeline-source.ts rename to src/components-lib/timeline-source.ts index 714356be..7e9d10c9 100644 --- a/src/utils/timeline-source.ts +++ b/src/components-lib/timeline-source.ts @@ -12,7 +12,7 @@ import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/ import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; import { ClipsOrSnapshotsOrAll } from '../types'; import { ViewMedia } from '../view/media'; -import { ModifyInterface, errorToConsole } from './basic.js'; +import { ModifyInterface, errorToConsole } from '../utils/basic.js'; // Allow timeline freshness to be at least this number of seconds out of date // (caching times in the data-engine may increase the effective delay). diff --git a/src/utils/zoom/zoom.ts b/src/components-lib/zoom-controller.ts similarity index 99% rename from src/utils/zoom/zoom.ts rename to src/components-lib/zoom-controller.ts index 08f1ce06..07557136 100644 --- a/src/utils/zoom/zoom.ts +++ b/src/components-lib/zoom-controller.ts @@ -1,8 +1,8 @@ import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom'; import round from 'lodash-es/round'; -import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic'; +import { dispatchFrigateCardEvent, isHoverableDevice } from '../utils/basic'; -export class Zoom { +export class ZoomController { constructor(element: HTMLElement) { this._element = element; } diff --git a/src/components/image.ts b/src/components/image.ts index e54b91e3..a222972e 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -12,7 +12,7 @@ import { customElement, property } from 'lit/decorators.js'; import { live } from 'lit/directives/live.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; import isEqual from 'lodash-es/isEqual'; -import { CachedValueController } from '../cached-value-controller.js'; +import { CachedValueController } from '../components-lib/cached-value-controller.js'; import { CameraConfig, ImageViewConfig } from '../config/types.js'; import defaultImage from '../images/frigate-bird-in-sky.jpg'; import { localize } from '../localize/localize.js'; diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts index 0ce54b49..603cc22c 100644 --- a/src/components/live/live-ha.ts +++ b/src/components/live/live-ha.ts @@ -3,12 +3,14 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit import { customElement, property } from 'lit/decorators.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraConfig } from '../../config/types'; +import { localize } from '../../localize/localize'; import '../../patches/ha-camera-stream'; import '../../patches/ha-hls-player.js'; import '../../patches/ha-web-rtc-player.ts'; import liveHAStyle from '../../scss/live-ha.scss'; import { FrigateCardMediaPlayer } from '../../types.js'; -import { getStateObjOrDispatchError } from './live.js'; +import { renderMessage } from '../message'; +import { getStateObjOrDispatchError } from '../../utils/get-state-obj'; @customElement('frigate-card-live-ha') export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPlayer { @@ -68,7 +70,14 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla if (!stateObj) { return; } - + if (stateObj.state === 'unavailable') { + return renderMessage({ + message: localize('error.live_camera_unavailable'), + type: 'error', + icon: 'mdi:connection', + context: this.cameraConfig, + }); + } return html` { - if (!cameraConfig?.camera_entity) { - dispatchErrorMessageEvent(element, localize('error.no_live_camera'), { - context: cameraConfig, - }); - return null; - } - - const stateObj = hass.states[cameraConfig.camera_entity]; - if (!stateObj) { - dispatchErrorMessageEvent(element, localize('error.live_camera_not_found'), { - context: cameraConfig, - }); - return null; - } - - if (stateObj.state === 'unavailable') { - dispatchMessageEvent(element, localize('error.live_camera_unavailable'), 'info', { - icon: 'mdi:connection', - context: cameraConfig, - }); - return null; - } - return stateObj; -}; - @customElement('frigate-card-live') export class FrigateCardLive extends LitElement { @property({ attribute: false }) @@ -977,6 +938,27 @@ export class FrigateCardLiveProvider hidden: showImageDuringLoading, }; + if (provider === 'ha' || provider === 'image') { + const stateObj = getStateObjOrDispatchError(this, this.hass, this.cameraConfig); + if (!stateObj) { + return; + } + if (stateObj.state === 'unavailable') { + // An unavailable camera gets a message rendered in place vs dispatched, + // as this may be a common occurrence (e.g. Frigate cameras that stop + // receiving frames). Otherwise a single temporarily unavailable camera + // would render a whole carousel inoperable. + return renderMessage({ + message: `${localize('error.live_camera_unavailable')}${ + this.label ? `: ${this.label}` : '' + }`, + type: 'info', + icon: 'mdi:cctv-off', + dotdotdot: true, + }); + } + } + return this._useZoomIfRequired(html` ${showImageDuringLoading || provider === 'image' ? html` ${localize('error.troubleshooting')}.`} - .icon=${'mdi:alert-circle'} + .icon=${this.message.icon ?? 'mdi:alert-circle'} .context=${this.message.context} .dotdotdot=${this.message.dotdotdot} > diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index 271656c9..d9c0a72e 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -30,6 +30,10 @@ import { CameraManager } from '../camera-manager/manager'; import { rangesOverlap } from '../camera-manager/range'; import { MediaQuery } from '../camera-manager/types'; import { convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; +import { + FrigateCardTimelineItem, + TimelineDataSource, +} from '../components-lib/timeline-source'; import { CameraConfig, CardWideConfig, @@ -54,7 +58,6 @@ import { executeMediaQueryForViewWithErrorDispatching, findBestMediaIndex, } from '../utils/media-to-view'; -import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; import { ViewMedia } from '../view/media'; import { ViewMediaClassifier } from '../view/media-classifier'; import { diff --git a/src/components/viewer.ts b/src/components/viewer.ts index db9b1107..6a217155 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -11,6 +11,7 @@ import { guard } from 'lit/directives/guard.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraManager } from '../camera-manager/manager.js'; +import { MediaGridSelected } from '../components-lib/media-grid-controller.js'; import { dispatchMessageEvent, renderMessage, @@ -47,7 +48,6 @@ import AutoMediaLoadedInfo from '../utils/embla/plugins/auto-media-loaded-info/a import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js'; import { canonicalizeHAURL } from '../utils/ha/index.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; -import { MediaGridSelected } from '../utils/media-grid-controller.js'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, diff --git a/src/components/zoomer.ts b/src/components/zoomer.ts index cf82844b..111c7bb5 100644 --- a/src/components/zoomer.ts +++ b/src/components/zoomer.ts @@ -7,12 +7,12 @@ import { TemplateResult, } from 'lit'; import { customElement, state } from 'lit/decorators.js'; +import { ZoomController } from '../components-lib/zoom-controller.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); + protected _zoom = new ZoomController(this); @state() protected _zoomed = false; diff --git a/src/utils/embla/reinit-controller.ts b/src/utils/embla/reinit-controller.ts index ff3f2287..fd15b94a 100644 --- a/src/utils/embla/reinit-controller.ts +++ b/src/utils/embla/reinit-controller.ts @@ -54,7 +54,7 @@ export class EmblaReInitController { this._shouldReInitOnScrollStop = false; this._emblaApi?.reInit(); }, - 200, + 500, { trailing: true }, ); } diff --git a/src/utils/get-state-obj.ts b/src/utils/get-state-obj.ts new file mode 100644 index 00000000..55e32326 --- /dev/null +++ b/src/utils/get-state-obj.ts @@ -0,0 +1,36 @@ +import { HomeAssistant } from "custom-card-helpers"; +import { HassEntity } from "home-assistant-js-websocket"; +import { CameraConfig } from "../config/types.js"; +import { dispatchErrorMessageEvent } from "../components/message.js"; +import { localize } from "../localize/localize.js"; + +/** + * Get the state object or dispatch an error. Used in `ha` and `image` live + * providers. + * @param element HTMLElement to dispatch errors from. + * @param hass Home Assistant object. + * @param cameraConfig Camera configuration. + * @returns + */ +export const getStateObjOrDispatchError = ( + element: HTMLElement, + hass: HomeAssistant, + cameraConfig?: CameraConfig, +): HassEntity | null => { + if (!cameraConfig?.camera_entity) { + dispatchErrorMessageEvent(element, localize('error.no_live_camera'), { + context: cameraConfig, + }); + return null; + } + + const stateObj = hass.states[cameraConfig.camera_entity]; + if (!stateObj) { + dispatchErrorMessageEvent(element, localize('error.live_camera_not_found'), { + context: cameraConfig, + }); + return null; + } + + return stateObj; +}; diff --git a/tests/cached-value-controller.test.ts b/tests/cached-value-controller.test.ts index 29714a7b..f3694cb1 100644 --- a/tests/cached-value-controller.test.ts +++ b/tests/cached-value-controller.test.ts @@ -1,7 +1,7 @@ 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'; +import { CachedValueController } from '../src/components-lib/cached-value-controller'; // @vitest-environment jsdom describe('CachedValueController', () => { diff --git a/tests/utils/media-grid-controller.test.ts b/tests/components-lib/media-grid-controller.test.ts similarity index 99% rename from tests/utils/media-grid-controller.test.ts rename to tests/components-lib/media-grid-controller.test.ts index 4f5b0092..bc760878 100644 --- a/tests/utils/media-grid-controller.test.ts +++ b/tests/components-lib/media-grid-controller.test.ts @@ -5,7 +5,7 @@ import { MediaLoadedInfo } from '../../src/types'; import { MediaGridConstructorOptions, MediaGridController, -} from '../../src/utils/media-grid-controller'; +} from '../../src/components-lib/media-grid-controller'; import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info'; import { MutationObserverMock, diff --git a/tests/utils/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts similarity index 99% rename from tests/utils/menu-controller.test.ts rename to tests/components-lib/menu-controller.test.ts index 1799c611..1711d4ec 100644 --- a/tests/utils/menu-controller.test.ts +++ b/tests/components-lib/menu-controller.test.ts @@ -4,15 +4,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { CameraManager } from '../../src/camera-manager/manager'; import { CameraManagerCameraMetadata } from '../../src/camera-manager/types'; -import { FrigateCardConfig, MenuItem, ViewDisplayMode } from '../../src/config/types'; -import { FrigateCardMediaPlayer } from '../../src/types'; -import { createFrigateCardCustomAction } from '../../src/utils/action'; import { MediaPlayerManager } from '../../src/card-controller/media-player-manager'; import { MicrophoneManager } from '../../src/card-controller/microphone-manager'; import { MenuButtonController, MenuButtonControllerOptions, -} from '../../src/utils/menu-controller'; +} from '../../src/components-lib/menu-controller'; +import { FrigateCardConfig, MenuItem, ViewDisplayMode } from '../../src/config/types'; +import { FrigateCardMediaPlayer } from '../../src/types'; +import { createFrigateCardCustomAction } from '../../src/utils/action'; import { ViewMedia } from '../../src/view/media'; import { MediaQueriesResults } from '../../src/view/media-queries-results'; import { View } from '../../src/view/view'; diff --git a/tests/utils/zoom.test.ts b/tests/components-lib/zoom-controller.test.ts similarity index 95% rename from tests/utils/zoom.test.ts rename to tests/components-lib/zoom-controller.test.ts index f8817e2c..e040e4d1 100644 --- a/tests/utils/zoom.test.ts +++ b/tests/components-lib/zoom-controller.test.ts @@ -1,8 +1,7 @@ -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 Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; +import { ZoomController } from '../../src/components-lib/zoom-controller'; vi.mock('@dermotduffy/panzoom'); @@ -10,7 +9,7 @@ vi.mock('@dermotduffy/panzoom'); (window as any).PointerEvent = MouseEvent; // @vitest-environment jsdom -describe('Zoom', () => { +describe('ZoomController', () => { const mediaMediSpy = vi.spyOn(window, 'matchMedia'); const createMockPanZoom = (): PanzoomObject => { @@ -19,8 +18,8 @@ describe('Zoom', () => { return panzoom; }; - const createAndRegisterZoom = (element: HTMLElement): Zoom => { - const zoom = new Zoom(element); + const createAndRegisterZoom = (element: HTMLElement): ZoomController => { + const zoom = new ZoomController(element); zoom.activate(); return zoom; }; @@ -52,7 +51,7 @@ describe('Zoom', () => { it('should be creatable', () => { const element = document.createElement('div'); - const zoom = new Zoom(element); + const zoom = new ZoomController(element); expect(zoom).toBeTruthy(); }); diff --git a/tests/utils/get-state-obj.ts b/tests/utils/get-state-obj.ts new file mode 100644 index 00000000..08b792f1 --- /dev/null +++ b/tests/utils/get-state-obj.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createCameraConfig, createHASS, createStateEntity } from '../test-utils'; +import { getStateObjOrDispatchError } from '../../src/utils/get-state-obj'; + +// @vitest-environment jsdom +describe('getStateObjOrDispatchError', () => { + it('should retrieve valid state object', () => { + const messageHandler = vi.fn(); + const element = document.createElement('div'); + element.addEventListener('frigate-card:message', messageHandler); + const state = createStateEntity(); + + expect( + getStateObjOrDispatchError( + element, + createHASS({ + 'camera.test': state, + }), + createCameraConfig({ + camera_entity: 'camera.test', + }), + ), + ).toBe(state); + + expect(messageHandler).not.toBeCalled(); + }); + + it('should dispatch unspecified entity', () => { + const messageHandler = vi.fn(); + const element = document.createElement('div'); + element.addEventListener('frigate-card:message', messageHandler); + + expect( + getStateObjOrDispatchError(element, createHASS(), createCameraConfig()), + ).toBeNull(); + + expect(messageHandler).toBeCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ + message: + 'The camera_entity parameter must be set and valid for this live provider', + type: 'error', + }), + }), + ); + }); + + it('should dispatch not found state', () => { + const messageHandler = vi.fn(); + const element = document.createElement('div'); + element.addEventListener('frigate-card:message', messageHandler); + + expect( + getStateObjOrDispatchError( + element, + createHASS(), + createCameraConfig({ + camera_entity: 'camera.will-not-be-found', + }), + ), + ).toBeNull(); + + expect(messageHandler).toBeCalledWith( + expect.objectContaining({ + detail: expect.objectContaining({ + message: 'The configured camera_entity was not found', + type: 'error', + }), + }), + ); + }); +});