Merge pull request #1488 from dermotduffy/update-trigger-action

Add new `update` trigger action to just update current view dynamically
This commit is contained in:
Dermot Duffy
2024-07-30 02:31:43 -07:00
committed by GitHub
113 changed files with 4823 additions and 4503 deletions
+1
View File
@@ -1 +1,2 @@
docs/js/
.devcontainer/
+1 -1
View File
@@ -28,7 +28,7 @@ Principles used in the selection of options set by `low-profile` profile mode:
- Get 'out of the box' performance similar to the basic "Home Assistant Picture Glance" card.
- Do not break the visual aesthetic of the card.
See the [source code](https://github.com/dermotduffy/frigate-hass-card/blob/dev/src/config/profiles/low-performance.ts) for an exhaustive list of options set by this profile.
See the [source code](https://github.com/dermotduffy/frigate-hass-card/blob/dev/src/config/profiles/low-performance.ts) for an exhaustive list of defaults set by this profile.
## `scrubbing`
+45 -22
View File
@@ -7,24 +7,45 @@ view:
# [...]
```
| Option | Default | Description |
| ------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions` | | [Actions](actions/README.md) to use for all views, individual actions may be overriden by view-specific actions. |
| `camera_select` | `current` | The [view](view.md?id=supported-views) to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. |
| `dark_mode` | `off` | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`. |
| `default` | `live` | The view to show in the card by default. The default camera is the first one listed. See [Supported Views](view.md?id=supported-views) below. |
| `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. `0` means no interactions are reported / acted upon. |
| `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. |
| `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags do not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). |
| `reset_after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. |
| `triggers` | | How to react when a camera is [triggered](cameras/README.md?id=triggers). |
| `update_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. |
| `update_entities` | | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use [`triggers`](cameras/README.md?id=triggers) for the selected camera instead. |
| `update_force` | `false` | Whether automated card updates should ignore user interaction. |
| `update_seconds` | `0` | A number of seconds after which to automatically update/refresh the default view. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality. |
| Option | Default | Description |
| ------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `actions` | | [Actions](actions/README.md) to use for all views, individual actions may be overriden by view-specific actions. |
| `camera_select` | `current` | The [view](view.md?id=supported-views) to show when a new camera is selected (e.g. in the camera menu). If `current` the view is unchanged when a new camera is selected. |
| `dark_mode` | `off` | Whether or not to turn dark mode `on`, `off` or `auto` to automatically turn on if the card `interaction_seconds` has expired (i.e. card has been left unattended for that period of time) or if dark mode is enabled in the HA profile theme setting. Dark mode dims the brightness by `25%`. |
| `default` | `live` | The view to show in the card by default. The default camera is the first one listed. See [Supported Views](view.md?id=supported-views) below. |
| `default_reset` | | The circumstances and behavior that cause the card to reset to the default view. See below. |
| `interaction_seconds` | `300` | After a mouse/touch interaction with the card, it will be considered "interacted with" until this number of seconds elapses without further interaction. May be used as part of an [interaction condition](conditions.md?id=interaction) or with `reset_after_interaction` to reset the view after the interaction is complete. `0` means no interactions are reported / acted upon. |
| `keyboard_shortcuts` | See [usage](../usage/keyboard-shortcuts.md) for defaults. | Configure keyboard shortcuts. See below. |
| `render_entities` | | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). |
| `reset_after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. |
| `triggers` | | How to react when a camera is [triggered](cameras/README.md?id=triggers). |
| `default_cycle_camera` | `false` | When set to `true` the selected camera is cycled on each default view change. |
## `default_reset`
Configure the circumstances and behavior that cause the card to reset to the default view. All configuration is under:
```yaml
view:
default_reset: [...]
```
| Option | Default | Description |
| ------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `after_interaction` | `true` | If `true` the card will reset to the default configured view (i.e. 'screensaver' functionality) after `interaction_seconds` has elapsed after user interaction. |
| `entities` | | A list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use [`triggers`](cameras/README.md?id=triggers) for the selected camera instead). |
| `interaction_mode` | `inactive` | Whether the default reset should happen when the card is being interacted with. If `all`, the reset will always happen regardless. If `inactive` the reset will only be taken if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` the reset will only be happen if the card _has_ had human interaction recently. This controls resets triggered by `entities` and `every_seconds`, but not `after_interaction` which by definition requires no interaction. |
| `every_seconds` | `0` | A number of seconds after which to automatically reset to the default view. `0` disables this functionality. |
## `keyboard_shortcuts`
All configuration is under:
```yaml
view:
keyboard_shortcuts: [...]
```
Configure the key-bindings for the builtin keyboard shortcuts. See [usage](../usage/keyboard-shortcuts.md) information for defaults on keyboard shortcuts.
| Option | Default | Description |
@@ -76,7 +97,7 @@ already occupied room will not trigger, but a newly occupied room will).
| Option | Default | Description |
| ------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `interaction_mode` | `inactive` | Whether actions should be taken when the card is being interacted with. If `all`, actions will always left be taken regardless. If `inactive` actions will only be taken if the card has _not_ had human interaction recently (as defined by `view.interaction_seconds`). If `active` actions will only be taken if the card _has_ had human interaction recently. This does not stop triggering itself (i.e. border will still pulse if `show_trigger_status` is true) but rather just prevents the actions being performed. |
| `trigger` | `default` | If set to `default` the default view of the card will be reloaded. If set to `live` the triggered camera will be selected in `live` view. If set to `media` the appropriate media view (e.g. `clip` or `snapshot`) will be chosen to match a newly available media item (please note that only some [camera engines](cameras/engine.md) support new media detection, e.g. `frigate`). If set to `none` no action is taken. |
| `trigger` | `update` | If set to `update` the current view is updated in place. If set to `default` the default view of the card will be reloaded. If set to `live` the triggered camera will be selected in `live` view. If set to `media` the appropriate media view (e.g. `clip` or `snapshot`) will be chosen to match a newly available media item (please note that only some [camera engines](cameras/engine.md) support new media detection, e.g. `frigate`). If set to `none` no action is taken. |
| `untrigger` | `none` | If set to `default` the the default view of the card will be reloaded. If set to `none` no action will be taken. |
## Supported views
@@ -106,11 +127,13 @@ view:
default: live
camera_select: current
interaction_seconds: 300
update_seconds: 0
update_force: false
update_cycle_camera: false
update_entities:
- binary_sensor.my_motion_sensor
default_cycle_camera: false
default_reset:
after_interaction: false
entities:
- binary_sensor.my_motion_sensor
every_seconds: 0
interaction_mode: inactive
render_entities:
- switch.render_card
dark_mode: 'off'
@@ -120,7 +143,7 @@ view:
untrigger_seconds: 0
actions:
interaction_mode: inactive
trigger: default
trigger: update
untrigger: none
keyboard_shortcuts:
enabled: true
+6 -5
View File
@@ -479,9 +479,9 @@ overrides:
This example changes the default card view from `live` to `image` depending on
the value of the `binary_sensor.alarm_armed` sensor. The override alone will
only change the _default_ when the card next is requested to change to the
default view. By also including the `update_entities` parameter, we ask the card
to trigger a card update based on that entity -- which causes it to use the new
overriden default immediately.
default view. By also including the `view.default_reset.entities` parameter, we
ask the card to trigger a card update based on that entity -- which causes it to
use the new overriden default immediately.
```yaml
type: custom:frigate-card
@@ -489,8 +489,9 @@ cameras:
- camera_entity: camera.office
view:
default: live
update_entities:
- binary_sensor.alarm_armed
default_reset:
entities:
- binary_sensor.alarm_armed
overrides:
- conditions:
- condition: state
+1 -1
View File
@@ -56,7 +56,7 @@ This could be for any number of reasons. Chromecast devices can be quite picky
on network, DNS and certificate issues, as well as audio and video codecs. Check
your Home Assistant log as there may be more information in there.
!>: In particular, for Frigate to support casting of clips, the default ffmpeg
!> In particular, for Frigate to support casting of clips, the default ffmpeg
settings for Frigate must be modified, i.e. Frigate does not encode clips in a
Chromecast compatible format out of the box (specifically: audio must be enabled
in the AAC codec, whether your camera supports audio or not). See the [Frigate
+1
View File
@@ -0,0 +1 @@
cache
+2
View File
@@ -50,6 +50,8 @@ const plugins = [
}),
typescript({
sourceMap: dev,
inlineSources: dev,
exclude: ['tests/**/*.test.ts'],
}),
json({ exclude: 'package.json' }),
replace({
+1 -2
View File
@@ -3,6 +3,7 @@ import { CameraConfig } from '../config/types';
import { localize } from '../localize/localize';
import { allPromises } from '../utils/basic';
import {
DestroyCallback,
isTriggeredState,
parseStateChangeTrigger,
subscribeToTrigger,
@@ -13,8 +14,6 @@ import { CameraManagerEngine } from './engine';
import { CameraNoIDError } from './error';
import { CameraEventCallback } from './types';
type DestroyCallback = () => Promise<void>;
export class Camera {
protected _config: CameraConfig;
protected _engine: CameraManagerEngine;
@@ -16,9 +16,11 @@ export class CameraSelectAction extends FrigateCardAction<CameraSelectActionConf
const viewOnCameraSelect = config?.view.camera_select ?? 'current';
const targetViewName =
viewOnCameraSelect === 'current' ? view.view : viewOnCameraSelect;
api.getViewManager().setViewByParameters({
viewName: targetViewName,
cameraID: selectCameraID,
await api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: targetViewName,
camera: selectCameraID,
},
failSafe: true,
});
}
@@ -4,6 +4,10 @@ import { FrigateCardAction } from './base';
export class DisplayModeSelectAction extends FrigateCardAction<DisplayModeActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
await api.getViewManager().setViewWithNewDisplayMode(this._action.display_mode);
await api.getViewManager().setViewByParametersWithNewQuery({
params: {
displayMode: this._action.display_mode,
},
});
}
}
@@ -1,9 +1,12 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { SubstreamOffViewModifier } from '../../view/modifiers/substream-off';
import { FrigateCardAction } from './base';
export class SubstreamOffAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithoutSubstream();
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamOffViewModifier()],
});
}
}
@@ -1,9 +1,12 @@
import { GeneralActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { SubstreamOnViewModifier } from '../../view/modifiers/substream-on';
import { FrigateCardAction } from './base';
export class SubstreamOnAction extends FrigateCardAction<GeneralActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithSubstream();
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamOnViewModifier(api)],
});
}
}
@@ -1,9 +1,12 @@
import { SubstreamSelectActionConfig } from '../../../config/types';
import { CardActionsAPI } from '../../types';
import { SubstreamSelectViewModifier } from '../../view/modifiers/substream-select';
import { FrigateCardAction } from './base';
export class SubstreamSelectAction extends FrigateCardAction<SubstreamSelectActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewWithSubstream(this._action.camera);
api.getViewManager().setViewByParameters({
modifiers: [new SubstreamSelectViewModifier(this._action.camera)],
});
}
}
+4 -7
View File
@@ -4,13 +4,10 @@ import { FrigateCardAction } from './base';
export class ViewAction extends FrigateCardAction<ViewActionConfig> {
public async execute(api: CardActionsAPI): Promise<void> {
api.getViewManager().setViewByParameters({
viewName: this._action.frigate_card_action,
// Note: This function needs to process (view-related) commands even when
// _view has not yet been initialized (since it may be used to set a view
// via the querystring).
cameraID: api.getViewManager().getView()?.camera,
api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: this._action.frigate_card_action,
},
});
}
}
@@ -1,42 +0,0 @@
import { Timer } from '../utils/timer';
import { CardAutoRefreshAPI } from './types';
export class AutoUpdateManager {
protected _timer = new Timer();
protected _api: CardAutoRefreshAPI;
constructor(api: CardAutoRefreshAPI) {
this._api = api;
}
/**
* Set the update timer to trigger an update refresh every
* `view.update_seconds`.
*/
public startDefaultViewTimer(): void {
this._timer.stop();
const updateSeconds = this._api.getConfigManager().getConfig()?.view.update_seconds;
if (updateSeconds) {
this._timer.start(updateSeconds, () => {
if (this._isAutomatedUpdateAllowed()) {
this._api.getViewManager().setViewDefault();
} else {
// Not allowed to update this time around, but try again at the next
// interval.
this.startDefaultViewTimer();
}
});
}
}
protected _isAutomatedUpdateAllowed(): boolean {
const triggers = this._api.getTriggersManager();
const config = this._api.getConfigManager().getConfig();
const interactionManager = this._api.getInteractionManager();
return (
!triggers.isTriggered() &&
(config?.view.update_force || !interactionManager.hasInteraction())
);
}
}
+15 -5
View File
@@ -67,6 +67,7 @@ export class CardElementManager {
this._api.getMediaLoadedInfoManager().initialize();
this._api.getMicrophoneManager().initialize();
this._api.getKeyboardStateManager().initialize();
this._api.getDefaultManager().initialize();
// Whether or not the card is in panel mode on the dashboard.
setOrRemoveAttribute(this._element, isCardInPanel(this._element), 'panel');
@@ -78,6 +79,10 @@ export class CardElementManager {
'mousemove',
this._api.getInteractionManager().reportInteraction,
);
this._element.addEventListener(
'wheel',
this._api.getInteractionManager().reportInteraction,
);
this._element.addEventListener(
'll-custom',
this._api.getActionsManager().handleCustomActionEvent,
@@ -123,15 +128,20 @@ export class CardElementManager {
this._api.getFullscreenManager().disconnect();
this._api.getKeyboardStateManager().uninitialize();
this._api.getActionsManager().uninitialize();
this._api.getDefaultManager().uninitialize();
// Uninitialize cameras to cause them to reinitialize on
// reconnection, to ensure the state subscription/unsubscription works
// correctly for triggers.
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS),
this._element.removeEventListener(
'mousemove',
this._api.getInteractionManager().reportInteraction,
);
this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS);
this._element.removeEventListener(
'mousemove',
this._api.getInteractionManager().reportInteraction,
);
this._element.removeEventListener(
'wheel',
this._api.getInteractionManager().reportInteraction,
);
this._element.removeEventListener(
'll-custom',
this._api.getActionsManager().handleCustomActionEvent,
@@ -142,6 +142,18 @@ export class ConfigManager {
.uninitialize(InitializationAspect.MICROPHONE_CONNECT);
}
if (
previousConfig &&
!isEqual(
previousConfig?.view.default_reset,
this._overriddenConfig?.view.default_reset,
)
) {
this._api
.getInitializationManager()
.uninitialize(InitializationAspect.DEFAULT_RESET);
}
this._api.getCardElementManager().update();
}
}
+10 -9
View File
@@ -6,7 +6,7 @@ import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { EntityCache } from '../utils/ha/entity-registry/cache';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { ActionsManager } from './actions/actions-manager';
import { AutoUpdateManager } from './auto-update-manager';
import { DefaultManager } from './default-manager';
import { AutomationsManager } from './automations-manager';
import { CameraURLManager } from './camera-url-manager';
import {
@@ -32,12 +32,12 @@ import { StyleManager } from './style-manager';
import { TriggersManager } from './triggers-manager';
import {
CardActionsManagerAPI,
CardAutoRefreshAPI,
CardAutomationsAPI,
CardCameraAPI,
CardCameraURLAPI,
CardConditionAPI,
CardConfigAPI,
CardDefaultManagerAPI,
CardDownloadAPI,
CardElementAPI,
CardExpandAPI,
@@ -55,18 +55,18 @@ import {
CardTriggersAPI,
CardViewAPI,
} from './types';
import { ViewManager } from './view-manager';
import { ViewManager } from './view/view-manager';
import { KeyboardStateManager } from './keyboard-state-manager';
export class CardController
implements
CardActionsManagerAPI,
CardAutomationsAPI,
CardAutoRefreshAPI,
CardCameraAPI,
CardCameraURLAPI,
CardConditionAPI,
CardConfigAPI,
CardDefaultManagerAPI,
CardDownloadAPI,
CardElementAPI,
CardExpandAPI,
@@ -92,12 +92,12 @@ export class CardController
protected _actionsManager = new ActionsManager(this);
protected _automationsManager = new AutomationsManager(this);
protected _autoUpdateManager = new AutoUpdateManager(this);
protected _cameraManager = new CameraManager(this);
protected _cameraURLManager = new CameraURLManager(this);
protected _cardElementManager: CardElementManager;
protected _conditionsManager: ConditionsManager;
protected _configManager = new ConfigManager(this);
protected _defaultManager = new DefaultManager(this);
protected _downloadManager = new DownloadManager(this);
protected _expandManager = new ExpandManager(this);
protected _fullscreenManager = new FullscreenManager(this);
@@ -143,10 +143,6 @@ export class CardController
return this._automationsManager;
}
public getAutoUpdateManager(): AutoUpdateManager {
return this._autoUpdateManager;
}
public getCameraManager(): CameraManager {
return this._cameraManager;
}
@@ -171,6 +167,11 @@ export class CardController
public getConfigManager(): ConfigManager {
return this._configManager;
}
public getDefaultManager(): DefaultManager {
return this._defaultManager;
}
public getDownloadManager(): DownloadManager {
return this._downloadManager;
}
+110
View File
@@ -0,0 +1,110 @@
import PQueue from 'p-queue';
import { DestroyCallback, subscribeToTrigger } from '../utils/ha';
import { isActionAllowedBasedOnInteractionState } from '../utils/interaction-mode';
import { Timer } from '../utils/timer';
import { CardDefaultManagerAPI } from './types';
import { createGeneralAction } from '../utils/action';
/**
* Manages automated resetting to the default view.
*/
export class DefaultManager {
protected _timer = new Timer();
protected _api: CardDefaultManagerAPI;
protected _unsubscribeCallback: DestroyCallback | null = null;
protected _initializationLimit = new PQueue({ concurrency: 1 });
constructor(api: CardDefaultManagerAPI) {
this._api = api;
}
/**
* Initialize the default manager. Requires both hass and configuration to be
* effective (so cannot be called from just the configuration manager, as hass
* will not be available yet)
*/
public async initialize(): Promise<boolean> {
const result = await this._initializationLimit.add(() => this._reconfigure());
this._startTimer();
if (this._api.getConfigManager().getConfig()?.view.default_reset.after_interaction) {
this._api.getAutomationsManager().addAutomations([
{
actions: [createGeneralAction('default')],
conditions: [
{
condition: 'interaction' as const,
interaction: false,
},
],
tag: this,
},
]);
}
return !!result;
}
public uninitialize(): void {
this._timer.stop();
this._unsubscribeCallback?.();
this._unsubscribeCallback = null;
this._api.getAutomationsManager().deleteAutomations(this);
}
protected async _reconfigure(): Promise<boolean> {
const hass = this._api.getHASSManager().getHASS();
const config = this._api.getConfigManager().getConfig()?.view.default_reset;
if (!hass || !config) {
return false;
}
if (this._unsubscribeCallback) {
await this._unsubscribeCallback();
}
this._unsubscribeCallback = await subscribeToTrigger(
hass,
() => this._setToDefaultIfAllowed(),
{
entityID: config.entities,
platform: 'state',
stateOnly: true,
},
);
// If the timer is running, restart it with the newly configured timer.
if (this._timer.isRunning()) {
this._timer.stop();
this._startTimer();
}
return true;
}
protected _setToDefaultIfAllowed(): void {
if (this._isAutomatedUpdateAllowed()) {
this._api.getViewManager().setViewDefault();
}
}
protected _isAutomatedUpdateAllowed(): boolean {
const interactionMode = this._api.getConfigManager().getConfig()?.view
.default_reset.interaction_mode;
return (
!!interactionMode &&
isActionAllowedBasedOnInteractionState(
interactionMode,
this._api.getInteractionManager().hasInteraction(),
)
);
}
protected _startTimer(): void {
const timerSeconds = this._api.getConfigManager().getConfig()?.view
.default_reset.every_seconds;
if (timerSeconds) {
this._timer.startRepeated(timerSeconds, () => this._setToDefaultIfAllowed());
}
}
}
-24
View File
@@ -37,23 +37,6 @@ export class HASSManager {
this._hass = hass;
if (
// Home Assistant pumps a lot of updates through. Re-rendering the card is
// necessary at times (e.g. to update the 'clip' view as new clips
// arrive), but also is a jarring experience for the user (e.g. if they
// are browsing the mini-gallery). Do not allow re-rendering from a Home
// Assistant update if there's been recent interaction (e.g. clicks on the
// card) or if there is media active playing.
this._isAutomatedViewUpdateAllowed() &&
isHassDifferent(
this._hass,
oldHass,
this._api.getConfigManager().getConfig()?.view.update_entities ?? [],
)
) {
// If entities being monitored have changed then reset the view to the
// default.
this._api.getViewManager().setViewDefault();
} else if (
isHassDifferent(this._hass, oldHass, [
...(this._api.getConfigManager().getConfig()?.view.render_entities ?? []),
@@ -75,11 +58,4 @@ export class HASSManager {
// Dark mode may depend on HASS.
this._api.getStyleManager().setLightOrDarkMode();
}
protected _isAutomatedViewUpdateAllowed(): boolean {
return (
this._api.getConfigManager().getConfig()?.view.update_force ||
!this._api.getInteractionManager().hasInteraction()
);
}
}
@@ -9,6 +9,7 @@ export enum InitializationAspect {
MEDIA_PLAYERS = 'media-players',
CAMERAS = 'cameras',
MICROPHONE_CONNECT = 'microphone-connect',
DEFAULT_RESET = 'default-reset',
}
export class InitializationManager {
@@ -99,10 +100,10 @@ export class InitializationManager {
if (hasViewRelatedActions) {
this._api.getQueryStringManager().executeViewRelated();
} else {
this._api.getViewManager().setViewDefault({ failSafe: true });
this._api.getViewManager().setViewDefaultWithNewQuery({ failSafe: true });
}
} else {
// If we already have a view something (e.g. cameras) may have been
// If we already have a view, something (e.g. cameras) may have been
// reinitialized, be sure to ask for an update.
this._api.getCardElementManager().update();
}
@@ -125,6 +126,7 @@ export class InitializationManager {
if (
this._initializer.isInitializedMultiple([
InitializationAspect.DEFAULT_RESET,
...(config.menu.buttons.media_player.enabled
? [InitializationAspect.MEDIA_PLAYERS]
: []),
@@ -135,6 +137,8 @@ export class InitializationManager {
if (
!(await this._initializer.initializeMultipleIfNecessary({
[InitializationAspect.DEFAULT_RESET]: async () =>
await this._api.getDefaultManager().initialize(),
...(config.menu.buttons.media_player.enabled && {
[InitializationAspect.MEDIA_PLAYERS]: async () =>
await this._api.getMediaPlayerManager().initialize(),
+1 -7
View File
@@ -39,13 +39,7 @@ export class InteractionManager {
this._timer.start(timeoutSeconds, () => {
this._api.getConditionsManager().setState({ interaction: false });
if (!this._api.getTriggersManager().isTriggered()) {
if (this._api.getConfigManager().getConfig()?.view.reset_after_interaction) {
this._api.getViewManager().setViewDefault();
}
this._api.getStyleManager().setLightOrDarkMode();
}
this._api.getStyleManager().setLightOrDarkMode();
});
}
}
@@ -1,5 +1,5 @@
import { CardKeyboardStateAPI, KeysState } from './types';
import isEqual from 'lodash/isEqual';
import isEqual from 'lodash-es/isEqual';
export class KeyboardStateManager {
protected _api: CardKeyboardStateAPI;
+23 -13
View File
@@ -1,11 +1,13 @@
import { FrigateCardCustomAction, ViewActionConfig } from '../config/types';
import { createCameraAction, createGeneralAction } from '../utils/action.js';
import { ViewParameters } from '../view/view';
import { CardQueryStringAPI } from './types';
import { ViewManagerSetViewParameters } from './view-manager';
import { SubstreamSelectViewModifier } from './view/modifiers/substream-select';
interface QueryStringViewIntent {
view?: ViewManagerSetViewParameters & {
view?: Partial<ViewParameters> & {
default?: boolean;
substream?: string;
};
other?: FrigateCardCustomAction[];
}
@@ -35,18 +37,26 @@ export class QueryStringManager {
this._executeNonViewRelated(intent);
};
protected _executeViewRelated(intent: QueryStringViewIntent): void {
protected async _executeViewRelated(intent: QueryStringViewIntent): Promise<void> {
if (intent.view) {
if (intent.view.default) {
this._api.getViewManager().setViewDefault({
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
...(intent.view.substream && { substream: intent.view.substream }),
await this._api.getViewManager().setViewDefaultWithNewQuery({
params: {
camera: intent.view.camera,
},
...(intent.view.substream && {
modifiers: [new SubstreamSelectViewModifier(intent.view.substream)],
}),
});
} else {
this._api.getViewManager().setViewByParameters({
...(intent.view.viewName && { viewName: intent.view.viewName }),
...(intent.view.cameraID && { cameraID: intent.view.cameraID }),
...(intent.view.substream && { substream: intent.view.substream }),
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
...(intent.view.view && { view: intent.view.view }),
...(intent.view.camera && { camera: intent.view.camera }),
},
...(intent.view.substream && {
modifiers: [new SubstreamSelectViewModifier(intent.view.substream)],
}),
});
}
}
@@ -68,13 +78,13 @@ export class QueryStringManager {
const result: QueryStringViewIntent = {};
for (const action of this._getActions()) {
if (this._isViewAction(action)) {
(result.view ??= {}).viewName = action.frigate_card_action;
(result.view ??= {}).view = action.frigate_card_action;
(result.view ??= {}).default = undefined;
} else if (action.frigate_card_action === 'default') {
(result.view ??= {}).default = true;
(result.view ??= {}).viewName = undefined;
(result.view ??= {}).view = undefined;
} else if (action.frigate_card_action === 'camera_select') {
(result.view ??= {}).cameraID = action.camera;
(result.view ??= {}).camera = action.camera;
} else if (action.frigate_card_action === 'live_substream_select') {
(result.view ??= {}).substream = action.camera;
} else {
+28 -17
View File
@@ -36,7 +36,7 @@ export class TriggersManager {
return sorted.length ? sorted[0][0] : null;
}
public handleCameraEvent(ev: CameraEvent): void {
public async handleCameraEvent(ev: CameraEvent): Promise<void> {
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
const selectedCameraID = this._api.getViewManager().getView()?.camera;
@@ -60,7 +60,7 @@ export class TriggersManager {
this._triggeredCameras.set(ev.cameraID, new Date());
this._setConditionStateIfNecessary();
this._throttledTriggerAction(ev);
await this._throttledTriggerAction(ev);
}
protected _hasAllowableInteractionStateForAction(): boolean {
@@ -75,14 +75,15 @@ export class TriggersManager {
);
}
protected _triggerAction(ev: CameraEvent): void {
protected async _triggerAction(ev: CameraEvent): Promise<void> {
const triggerAction = this._api.getConfigManager().getConfig()?.view.triggers
.actions.trigger;
const defaultView = this._api.getConfigManager().getConfig()?.view.default;
// If this is a high-fidelity event where we are certain about new media,
// don't take action unless it's to change to live (Frigate engine may pump
// out events where there's no new media to show).
// out events where there's no new media to show). Other trigger actions
// (e.g. media, update) do not make sense without having some new media.
if (
ev.fidelity === 'high' &&
!ev.snapshot &&
@@ -96,19 +97,29 @@ export class TriggersManager {
}
if (this._hasAllowableInteractionStateForAction()) {
if (triggerAction === 'live') {
this._api.getViewManager().setViewByParameters({
viewName: 'live',
cameraID: ev.cameraID,
if (triggerAction === 'update') {
await this._api.getViewManager().setViewByParametersWithNewQuery({
queryExecutorOptions: { useCache: false },
});
} else if (triggerAction === 'live') {
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: 'live',
camera: ev.cameraID,
},
});
} else if (triggerAction === 'default') {
this._api.getViewManager().setViewDefault({
cameraID: ev.cameraID,
await this._api.getViewManager().setViewDefaultWithNewQuery({
params: {
camera: ev.cameraID,
},
});
} else if (ev.fidelity === 'high' && triggerAction === 'media') {
this._api.getViewManager().setViewByParameters({
viewName: ev.clip ? 'clip' : 'snapshot',
cameraID: ev.cameraID,
await this._api.getViewManager().setViewByParametersWithNewQuery({
params: {
view: ev.clip ? 'clip' : 'snapshot',
camera: ev.cameraID,
},
});
}
}
@@ -130,12 +141,12 @@ export class TriggersManager {
}
}
protected _untriggerAction(cameraID: string): void {
protected async _untriggerAction(cameraID: string): Promise<void> {
const action = this._api.getConfigManager().getConfig()?.view.triggers
.actions.untrigger;
if (action === 'default' && this._hasAllowableInteractionStateForAction()) {
this._api.getViewManager().setViewDefault();
await this._api.getViewManager().setViewDefaultWithNewQuery();
}
this._triggeredCameras.delete(cameraID);
this._deleteTimer(cameraID);
@@ -155,8 +166,8 @@ export class TriggersManager {
reached, as there's no way to have the untrigger call happen without
a config. -- @preserve */
this._api.getConfigManager().getConfig()?.view.triggers.untrigger_seconds ?? 0,
() => {
this._untriggerAction(cameraID);
async () => {
await this._untriggerAction(cameraID);
},
);
}
+15 -10
View File
@@ -3,7 +3,7 @@ import type { ConditionsManager } from './conditions-manager';
import type { EntityRegistryManager } from '../utils/ha/entity-registry';
import type { ResolvedMediaCache } from '../utils/ha/resolved-media';
import type { ActionsManager } from './actions/actions-manager';
import type { AutoUpdateManager } from './auto-update-manager';
import type { DefaultManager } from './default-manager';
import type { AutomationsManager } from './automations-manager';
import type { CameraURLManager } from './camera-url-manager';
import type { CardElementManager } from './card-element-manager';
@@ -20,7 +20,7 @@ import type { MessageManager } from './message-manager';
import type { MicrophoneManager } from './microphone-manager';
import type { StyleManager } from './style-manager';
import type { TriggersManager } from './triggers-manager';
import type { ViewManager } from './view-manager';
import type { ViewManager } from './view/view-manager';
import type { QueryStringManager } from './query-string-manager';
import { KeyboardStateManager } from './keyboard-state-manager';
import { Automation } from '../config/types';
@@ -60,13 +60,6 @@ export interface CardAutomationsAPI {
getMessageManager(): MessageManager;
}
export interface CardAutoRefreshAPI {
getConfigManager(): ConfigManager;
getInteractionManager(): InteractionManager;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
}
export interface CardCameraAPI {
getActionsManager(): ActionsManager;
getConfigManager(): ConfigManager;
@@ -92,6 +85,7 @@ export interface CardConfigAPI {
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getDefaultManager(): DefaultManager;
getInitializationManager(): InitializationManager;
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
getMessageManager(): MessageManager;
@@ -104,6 +98,15 @@ export interface CardConfigLoaderAPI {
getAutomationsManager(): AutomationsManager;
}
export interface CardDefaultManagerAPI {
getAutomationsManager(): AutomationsManager;
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
}
export interface CardDownloadAPI {
getCameraManager(): CameraManager;
getHASSManager(): HASSManager;
@@ -115,6 +118,7 @@ export interface CardDownloadAPI {
export interface CardElementAPI {
getActionsManager(): ActionsManager;
getCameraManager(): CameraManager;
getDefaultManager(): DefaultManager;
getExpandManager(): ExpandManager;
getFullscreenManager(): FullscreenManager;
getInitializationManager(): InitializationManager;
@@ -143,6 +147,7 @@ export interface CardHASSAPI {
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
getConfigManager(): ConfigManager;
getDefaultManager(): DefaultManager;
getInteractionManager(): InteractionManager;
getMediaPlayerManager(): MediaPlayerManager;
getMessageManager(): MessageManager;
@@ -155,6 +160,7 @@ export interface CardInitializerAPI {
getCameraManager(): CameraManager;
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
getDefaultManager(): DefaultManager;
getEntityRegistryManager(): EntityRegistryManager;
getHASSManager(): HASSManager;
getMediaPlayerManager(): MediaPlayerManager;
@@ -233,7 +239,6 @@ export interface CardTriggersAPI {
}
export interface CardViewAPI {
getAutoUpdateManager(): AutoUpdateManager;
getCameraManager(): CameraManager;
getCardElementManager(): CardElementManager;
getConditionsManager(): ConditionsManager;
-327
View File
@@ -1,327 +0,0 @@
import isEqual from 'lodash-es/isEqual';
import { ViewContext } from 'view';
import {
FRIGATE_CARD_VIEW_DEFAULT,
FrigateCardConfig,
FrigateCardView,
ViewDisplayMode,
} from '../config/types';
import { localize } from '../localize/localize';
import { log } from '../utils/debug';
import { executeMediaQueryForView } from '../utils/media-to-view';
import { View } from '../view/view';
import { getCameraIDsForViewName } from '../view/view-to-cameras';
import { CardViewAPI } from './types';
interface ViewManagerSetViewDefaultParameters {
cameraID?: string;
substream?: string;
// When failSafe is true, the view will be changed to the default view, or the
// `live` view if the default view is not supported, or failing that an error
// message is shown. Without `failSafe` the view will just not be changed if
// unsupported.
failSafe?: boolean;
}
export interface ViewManagerSetViewParameters
extends ViewManagerSetViewDefaultParameters {
viewName?: FrigateCardView;
}
export class ViewManager {
protected _view: View | null = null;
protected _api: CardViewAPI;
constructor(api: CardViewAPI) {
this._api = api;
}
public getView(): View | null {
return this._view;
}
public hasView(): boolean {
return !!this.getView();
}
public setView(view: View): void {
this._setView(view);
}
public setViewDefault(params?: ViewManagerSetViewDefaultParameters): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
let forceCameraID: string | null = params?.cameraID ?? null;
const viewName = config.view.default;
if (!forceCameraID && this._view?.camera && config.view.update_cycle_camera) {
const cameraIDs = [
...getCameraIDsForViewName(this._api.getCameraManager(), viewName),
];
const currentIndex = cameraIDs.indexOf(this._view.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
forceCameraID = cameraIDs[targetIndex];
}
this.setViewByParameters({
...params,
viewName: viewName,
...(forceCameraID && { cameraID: forceCameraID }),
});
// Restart the refresh timer, so the default view is refreshed at a fixed
// interval from now (if so configured).
this._api.getAutoUpdateManager().startDefaultViewTimer();
}
}
public setViewByParameters(params: ViewManagerSetViewParameters): void {
const config = this._api.getConfigManager().getConfig();
if (config) {
let cameraID: string | null = null;
let viewName = params?.viewName ?? this._view?.view ?? config.view.default;
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
if (params?.cameraID && allCameraIDs.has(params.cameraID)) {
cameraID = params.cameraID;
} else {
const viewCameraIDs = getCameraIDsForViewName(
this._api.getCameraManager(),
viewName,
);
// Reset to the default camera.
cameraID = viewCameraIDs.keys().next().value;
}
if (!cameraID) {
if (params.failSafe) {
const camerasToCapabilities = [
...this._api.getCameraManager().getStore().getCameras(),
].reduce((acc, [cameraID, camera]) => {
const capabilities = camera.getCapabilities()?.getRawCapabilities();
if (capabilities) {
acc[cameraID] = capabilities;
}
return acc;
}, {});
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
message: localize('error.no_supported_cameras'),
context: {
view: viewName,
cameras_capabilities: camerasToCapabilities,
},
});
}
return;
}
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
if (params.failSafe) {
if (this.isViewSupportedByCamera(cameraID, FRIGATE_CARD_VIEW_DEFAULT)) {
viewName = FRIGATE_CARD_VIEW_DEFAULT;
} else {
const capabilities = this._api
.getCameraManager()
.getStore()
.getCamera(cameraID)
?.getCapabilities()
?.getRawCapabilities();
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
message: localize('error.no_supported_camera'),
context: {
view: viewName,
camera: cameraID,
...(capabilities && { camera_capabilities: capabilities }),
},
});
return;
}
} else {
return;
}
}
const displayMode =
this._view?.displayMode ?? this._getDefaultDisplayModeForView(viewName, config);
let view: View = new View({
view: viewName,
camera: cameraID,
displayMode: displayMode,
});
if (params.substream) {
view = this._createViewWithSelectedSubstream(view, params.substream);
}
this._setView(view);
}
}
public setViewWithMergedContext(context: ViewContext | null): void {
if (this._view) {
return this._setView(this._view?.clone().mergeInContext(context));
}
}
public reset(): void {
this._view = null;
}
protected _getCameraIDsInvolvedInView(view: View): Set<string> {
return view.supportsMultipleDisplayModes() && view.isGrid()
? getCameraIDsForViewName(this._api.getCameraManager(), view.view)
: getCameraIDsForViewName(this._api.getCameraManager(), view.view, view.camera);
}
public async setViewWithNewDisplayMode(displayMode: ViewDisplayMode): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (this._view && hass) {
const view = this._view.evolve({
displayMode: displayMode,
});
const expectedCameraIDs = this._getCameraIDsInvolvedInView(view);
const queryCameraIDs = view.query?.getQueryCameraIDs();
if (!isEqual(expectedCameraIDs, queryCameraIDs) && view && view.query) {
// If the user requests a grid but the current query does not have a
// query for more than one camera, reset the query results, change the
// existing query to refer to all cameras and execute it to fetch new
// results.
let viewWithNewQuery: View | null = null;
try {
viewWithNewQuery = await executeMediaQueryForView(
this._api.getCameraManager(),
view,
view.query.clone().setQueryCameraIDs(expectedCameraIDs),
);
} catch (e: unknown) {
this._api.getMessageManager().setErrorIfHigherPriority(e);
}
if (viewWithNewQuery) {
return this._setView(viewWithNewQuery);
}
} else {
return this._setView(view);
}
}
}
public setViewWithSubstream(substream?: string): void {
if (!this._view) {
return;
}
this._setView(
substream
? this._createViewWithSelectedSubstream(this._view, substream)
: this._createViewWithNextStream(this._view),
);
}
public setViewWithoutSubstream(): void {
const view = this._createViewWithoutSubstream();
if (view) {
return this._setView(view);
}
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
protected _getDefaultDisplayModeForView(
viewName: FrigateCardView,
config?: FrigateCardConfig,
): ViewDisplayMode {
let mode: ViewDisplayMode | null = null;
switch (viewName) {
case 'media':
case 'clip':
case 'recording':
case 'snapshot':
mode = config?.media_viewer.display?.mode ?? null;
break;
case 'live':
mode = config?.live.display?.mode ?? null;
break;
}
return mode ?? 'single';
}
protected _setView(view: View): void {
const oldView = this._view;
View.adoptFromViewIfAppropriate(view, oldView);
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card view change: `,
view,
);
this._view = view;
if (View.isMajorMediaChange(oldView, view)) {
this._api.getMediaLoadedInfoManager().clear();
}
if (oldView?.view !== view.view) {
this._api.getCardElementManager().scrollReset();
}
this._api.getMessageManager().reset();
this._api.getStyleManager().setExpandedMode();
this._api.getConditionsManager()?.setState({
view: view.view,
camera: view.camera,
displayMode: view.displayMode ?? undefined,
});
this._api.getCardElementManager().update();
}
protected _createViewWithSelectedSubstream(baseView: View, substreamID: string): View {
const overrides: Map<string, string> =
baseView?.context?.live?.overrides ?? new Map();
overrides.set(baseView.camera, substreamID);
return baseView.clone().mergeInContext({
live: { overrides: overrides },
});
}
protected _createViewWithNextStream(baseView: View): View {
const dependencies = [
...this._api.getCameraManager().getStore().getAllDependentCameras(baseView.camera),
];
if (dependencies.length <= 1) {
return baseView.clone();
}
const view = baseView.clone();
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
const currentOverride = overrides.get(view.camera) ?? view.camera;
const currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
overrides.set(view.camera, dependencies[newIndex]);
view.mergeInContext({ live: { overrides: overrides } });
return view;
}
protected _createViewWithoutSubstream(): View | null {
if (!this._view) {
return null;
}
const view = this._view.clone();
const overrides: Map<string, string> | undefined = view.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) {
view.context?.live?.overrides?.delete(view.camera);
}
return view;
}
}
+373
View File
@@ -0,0 +1,373 @@
import { sub } from 'date-fns';
import {
FRIGATE_CARD_VIEW_DEFAULT,
FrigateCardConfig,
FrigateCardView,
ViewDisplayMode,
} from '../../config/types';
import { localize } from '../../localize/localize';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { View, ViewParameters } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import { CardViewAPI } from '../types';
import { QueryExecutor } from './query-executor';
import {
QueryExecutorOptions,
QueryWithResults,
ViewFactoryOptions,
ViewIncompatible,
ViewNoCameraError,
} from './types';
export class ViewFactory {
protected _api: CardViewAPI;
protected _executor: QueryExecutor;
constructor(api: CardViewAPI, executor?: QueryExecutor) {
this._api = api;
this._executor = executor ?? new QueryExecutor(api);
}
public getViewDefault(options?: ViewFactoryOptions): View | null {
const config = this._api.getConfigManager().getConfig();
if (!config) {
return null;
}
let forceCameraID: string | null = options?.params?.camera ?? null;
const viewName = options?.params?.view ?? config.view.default;
if (
!forceCameraID &&
options?.baseView?.camera &&
config.view.default_cycle_camera
) {
const cameraIDs = [
...getCameraIDsForViewName(this._api.getCameraManager(), viewName),
];
const currentIndex = cameraIDs.indexOf(options?.baseView?.camera);
const targetIndex = currentIndex + 1 >= cameraIDs.length ? 0 : currentIndex + 1;
forceCameraID = cameraIDs[targetIndex];
}
return this.getViewByParameters({
params: {
...options?.params,
view: viewName,
...(forceCameraID && { camera: forceCameraID }),
},
baseView: options?.baseView,
});
}
public getViewByParameters(options?: ViewFactoryOptions): View | null {
const config = this._api.getConfigManager().getConfig();
if (!config) {
return null;
}
let cameraID: string | null =
options?.params?.camera ?? options?.baseView?.camera ?? null;
let viewName =
options?.params?.view ?? options?.baseView?.view ?? config.view.default;
const allCameraIDs = this._api.getCameraManager().getStore().getCameraIDs();
if (!cameraID || !allCameraIDs.has(cameraID)) {
const viewCameraIDs = getCameraIDsForViewName(
this._api.getCameraManager(),
viewName,
);
// Reset to the default camera.
cameraID = viewCameraIDs.keys().next().value;
}
if (!cameraID) {
const camerasToCapabilities = [
...this._api.getCameraManager().getStore().getCameras(),
].reduce((acc, [cameraID, camera]) => {
const capabilities = camera.getCapabilities()?.getRawCapabilities();
if (capabilities) {
acc[cameraID] = capabilities;
}
return acc;
}, {});
throw new ViewNoCameraError(localize('error.no_supported_cameras'), {
view: viewName,
cameras_capabilities: camerasToCapabilities,
});
}
if (!this.isViewSupportedByCamera(cameraID, viewName)) {
if (
options?.failSafe &&
this.isViewSupportedByCamera(cameraID, FRIGATE_CARD_VIEW_DEFAULT)
) {
viewName = FRIGATE_CARD_VIEW_DEFAULT;
} else {
const capabilities = this._api
.getCameraManager()
.getStore()
.getCamera(cameraID)
?.getCapabilities()
?.getRawCapabilities();
throw new ViewIncompatible(localize('error.no_supported_camera'), {
view: viewName,
camera: cameraID,
...(capabilities && { camera_capabilities: capabilities }),
});
}
}
const displayMode =
options?.params?.displayMode ??
options?.baseView?.displayMode ??
this._getDefaultDisplayModeForView(viewName, config);
const viewParameters: ViewParameters = {
...options?.params,
view: viewName,
camera: cameraID,
displayMode: displayMode,
};
const view = options?.baseView
? options.baseView.evolve(viewParameters)
: new View(viewParameters);
if (options?.modifiers) {
options.modifiers.forEach((modifier) => modifier.modify(view));
}
return view;
}
public async getViewDefaultWithNewQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
return this._executeNewQuery(this.getViewDefault(options), {
...options,
queryExecutorOptions: {
useCache: false,
...options?.queryExecutorOptions,
},
});
}
public async getViewByParametersWithNewQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
return this._executeNewQuery(this.getViewByParameters(options), {
...options,
queryExecutorOptions: {
useCache: false,
...options?.queryExecutorOptions,
},
});
}
public async getViewByParametersWithExistingQuery(
options?: ViewFactoryOptions,
): Promise<View | null> {
const view = this.getViewByParameters(options);
if (view?.query) {
view.queryResults = await this._executor.execute(
view.query,
options?.queryExecutorOptions,
);
}
return view;
}
protected async _executeNewQuery(
view: View | null,
options?: ViewFactoryOptions,
): Promise<View | null> {
const config = this._api.getConfigManager().getConfig();
if (
!config ||
/* istanbul ignore next: this path cannot be reached as the only way for
view to be null here, is if the config is also null -- @preserve */
!view
) {
return null;
}
const executeMediaQuery = async (
mediaType: ClipsOrSnapshotsOrAll | 'recordings' | null,
): Promise<boolean> => {
/* istanbul ignore if: this path cannot be reached -- @preserve */
if (!mediaType) {
return false;
}
return await this._executeMediaQuery(
view,
mediaType === 'recordings' ? 'recordings' : 'events',
{
eventsMediaType: mediaType === 'recordings' ? undefined : mediaType,
executorOptions: options?.queryExecutorOptions,
},
);
};
// Implementation note: For new queries, if the query itself fails that is
// just ignored and the view is returned anyway (e.g. if the user changes to
// live but the thumbnail fetch fails, it is better to change to live and
// show no thumbnails than not change to live).
const mediaType = view.getDefaultMediaType();
const baseView = options?.baseView;
const switchingToGalleryFromViewer =
baseView?.isViewerView() && view.isGalleryView();
if (switchingToGalleryFromViewer && baseView?.query && baseView?.queryResults) {
// If the user is currently using the viewer, and then switches to the
// gallery we make an attempt to keep the query/queryResults the same so
// the gallery can be used to click back and forth to the viewer, and the
// selected media can be centered in the gallery. See the matching code in
// `updated()` in `gallery.ts`. We specifically must ensure that the new
// target media of the gallery (e.g. clips, snapshots or recordings) is
// equal to the queries that are currently used in the viewer.
//
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
view.query = baseView.query;
view.queryResults = baseView.queryResults;
} else {
switch (view.view) {
case 'live':
this._setTimelineWindowToLive(view);
if (config.live.controls.thumbnails.mode !== 'none') {
await executeMediaQuery(
config.live.controls.thumbnails.media_type === 'recordings'
? 'recordings'
: config.live.controls.thumbnails.events_media_type,
);
}
break;
case 'media':
// If the user is looking at media in the `media` view and then
// changes camera (via the menu) it should default to showing clips
// for the new camera.
if (baseView && view.camera !== baseView.camera) {
await executeMediaQuery('clips');
}
break;
// Gallery views:
case 'clips':
case 'snapshots':
case 'recordings':
await executeMediaQuery(mediaType);
break;
// Viewer views:
case 'clip':
case 'snapshot':
case 'recording':
if (config.media_viewer.controls.thumbnails.mode !== 'none') {
await executeMediaQuery(mediaType);
}
break;
}
}
this._setOrRemoveSeekTime(
view,
options?.queryExecutorOptions?.selectResult?.time?.time,
);
return view;
}
protected _setTimelineWindowToLive(view: View): void {
const now = new Date();
const liveConfig = this._api.getConfigManager().getConfig()?.live;
/* istanbul ignore if: this if branch cannot be reached as if the config is
empty this function is never called -- @preserve */
if (!liveConfig) {
return;
}
view.mergeInContext({
// Force the window to start at the most recent time, not
// necessarily when the most recent event/recording was:
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
timeline: {
window: {
start: sub(now, {
seconds: liveConfig.controls.timeline.window_seconds,
}),
end: now,
},
},
});
}
protected _setOrRemoveSeekTime(view: View, time?: Date): void {
if (time) {
view.mergeInContext({
mediaViewer: {
seek: time,
},
});
} else {
view.removeContextProperty('mediaViewer', 'seek');
}
}
protected async _executeMediaQuery(
view: View,
mediaType: 'events' | 'recordings',
options?: {
eventsMediaType?: ClipsOrSnapshotsOrAll;
executorOptions?: QueryExecutorOptions;
},
): Promise<boolean> {
const queryWithResults: QueryWithResults | null =
mediaType === 'events'
? await this._executor.executeDefaultEventQuery({
...(!view.isGrid() && { cameraID: view.camera }),
eventsMediaType: options?.eventsMediaType,
executorOptions: options?.executorOptions,
})
: mediaType === 'recordings'
? await this._executor.executeDefaultRecordingQuery({
...(!view.isGrid() && { cameraID: view.camera }),
executorOptions: options?.executorOptions,
})
: /* istanbul ignore next -- @preserve */
null;
if (!queryWithResults) {
return false;
}
view.query = queryWithResults.query;
view.queryResults = queryWithResults.queryResults;
return true;
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
protected _getDefaultDisplayModeForView(
viewName: FrigateCardView,
config?: FrigateCardConfig,
): ViewDisplayMode {
let mode: ViewDisplayMode | null = null;
switch (viewName) {
case 'media':
case 'clip':
case 'recording':
case 'snapshot':
mode = config?.media_viewer.display?.mode ?? null;
break;
case 'live':
mode = config?.live.display?.mode ?? null;
break;
}
return mode ?? 'single';
}
}
@@ -0,0 +1,15 @@
import { ViewContext } from 'view';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class MergeContextViewModifier implements ViewModifier {
protected _context?: ViewContext | null;
constructor(context?: ViewContext | null) {
this._context = context;
}
public modify(view: View): void {
view.mergeInContext(this._context);
}
}
@@ -0,0 +1,17 @@
import { ViewContext } from 'view';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class RemoveContextPropertyViewModifier implements ViewModifier {
protected _key: keyof ViewContext;
protected _property: PropertyKey;
constructor(key: keyof ViewContext, property: PropertyKey) {
this._key = key;
this._property = property;
}
public modify(view: View): void {
view.removeContextProperty(this._key, this._property);
}
}
@@ -0,0 +1,15 @@
import { ViewContext } from 'view';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class RemoveContextViewModifier implements ViewModifier {
protected _keys: (keyof ViewContext)[];
constructor(keys: (keyof ViewContext)[]) {
this._keys = keys;
}
public modify(view: View): void {
this._keys.forEach((key) => view.removeContext(key));
}
}
@@ -0,0 +1,9 @@
import { removeSubstream } from '../../../utils/substream';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class SubstreamOffViewModifier implements ViewModifier {
public modify(view: View): void {
removeSubstream(view);
}
}
@@ -0,0 +1,35 @@
import { CameraManager } from '../../../camera-manager/manager';
import { getStreamCameraID, setSubstream } from '../../../utils/substream';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
interface SubstreamOnViewModifierAPI {
getCameraManager(): CameraManager;
}
export class SubstreamOnViewModifier implements ViewModifier {
protected _api: SubstreamOnViewModifierAPI;
constructor(api: SubstreamOnViewModifierAPI) {
this._api = api;
}
public modify(view: View): void {
const dependencies = [
...this._api
.getCameraManager()
.getStore()
.getAllDependentCameras(view.camera, 'substream'),
];
if (dependencies.length <= 1) {
return;
}
const currentOverride = getStreamCameraID(view);
const currentIndex = dependencies.indexOf(currentOverride);
const newIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % dependencies.length;
setSubstream(view, dependencies[newIndex]);
}
}
@@ -0,0 +1,15 @@
import { setSubstream } from '../../../utils/substream';
import { View } from '../../../view/view';
import { ViewModifier } from '../types';
export class SubstreamSelectViewModifier implements ViewModifier {
protected _substreamID: string;
constructor(substreamID: string) {
this._substreamID = substreamID;
}
public modify(view: View): void {
setSubstream(view, this._substreamID);
}
}
+131
View File
@@ -0,0 +1,131 @@
import { CapabilitySearchOptions, MediaQuery } from '../../camera-manager/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../../const';
import { ClipsOrSnapshotsOrAll } from '../../types';
import { findBestMediaIndex } from '../../utils/find-best-media-index';
import {
EventMediaQueries,
MediaQueries,
RecordingMediaQueries,
} from '../../view/media-queries';
import { MediaQueriesResults } from '../../view/media-queries-results';
import { CardViewAPI } from '../types';
import { QueryExecutorOptions, QueryWithResults } from './types';
export class QueryExecutor {
protected _api: CardViewAPI;
constructor(api: CardViewAPI) {
this._api = api;
}
public async executeDefaultEventQuery(options?: {
cameraID?: string;
eventsMediaType?: ClipsOrSnapshotsOrAll;
executorOptions?: QueryExecutorOptions;
}): Promise<QueryWithResults | null> {
const capabilitySearch: CapabilitySearchOptions =
!options?.eventsMediaType || options?.eventsMediaType === 'all'
? {
anyCapabilities: ['clips', 'snapshots'],
}
: options.eventsMediaType;
const cameraManager = this._api.getCameraManager();
const cameraIDs = options?.cameraID
? cameraManager
.getStore()
.getAllDependentCameras(options.cameraID, capabilitySearch)
: cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch);
if (!cameraIDs.size) {
return null;
}
const rawQueries = cameraManager.generateDefaultEventQueries(cameraIDs, {
limit: this._getChunkLimit(),
...(options?.eventsMediaType === 'clips' && { hasClip: true }),
...(options?.eventsMediaType === 'snapshots' && { hasSnapshot: true }),
});
if (!rawQueries) {
return null;
}
const queries = new EventMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results
? {
query: queries,
queryResults: results,
}
: null;
}
public async executeDefaultRecordingQuery(options?: {
cameraID?: string;
executorOptions?: QueryExecutorOptions;
}): Promise<QueryWithResults | null> {
const cameraManager = this._api.getCameraManager();
const cameraIDs = options?.cameraID
? cameraManager.getStore().getAllDependentCameras(options.cameraID, 'recordings')
: cameraManager.getStore().getCameraIDsWithCapability('recordings');
if (!cameraIDs.size) {
return null;
}
const rawQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
limit: this._getChunkLimit(),
});
if (!rawQueries) {
return null;
}
const queries = new RecordingMediaQueries(rawQueries);
const results = await this.execute(queries, options?.executorOptions);
return results ? { query: queries, queryResults: results } : null;
}
public async execute(
query: MediaQueries,
executorOptions?: QueryExecutorOptions,
): Promise<MediaQueriesResults | null> {
const queries = query.getQueries();
if (!queries) {
return null;
}
const mediaArray = await this._api
.getCameraManager()
.executeMediaQueries<MediaQuery>(queries, {
useCache: executorOptions?.useCache,
});
if (!mediaArray) {
return null;
}
const queryResults = new MediaQueriesResults({ results: mediaArray });
if (executorOptions?.rejectResults?.(queryResults)) {
return null;
}
if (executorOptions?.selectResult?.id) {
queryResults.selectBestResult((media) =>
media.findIndex((m) => m.getID() === executorOptions.selectResult?.id),
);
} else if (executorOptions?.selectResult?.func) {
queryResults.selectResultIfFound(executorOptions.selectResult.func);
} else if (executorOptions?.selectResult?.time) {
queryResults.selectBestResult((media) =>
findBestMediaIndex(
media,
executorOptions.selectResult?.time?.time as Date,
executorOptions.selectResult?.time?.favorCameraID,
),
);
}
return queryResults;
}
protected _getChunkLimit(): number {
const cardWideConfig = this._api.getConfigManager().getCardWideConfig();
return (
cardWideConfig?.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT
);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { ViewContext } from 'view';
import { FrigateCardView } from '../../config/types.js';
import { FrigateCardError } from '../../types.js';
import { MediaQueriesResults } from '../../view/media-queries-results.js';
import { MediaQueries } from '../../view/media-queries.js';
import { ViewMedia } from '../../view/media.js';
import { View, ViewParameters } from '../../view/view.js';
export interface ViewModifier {
modify(view: View): void;
}
export interface QueryExecutorOptions {
// Select the result of a query, based on time, an id match or an arbitrary
// function. If no parameter is specified, the latest media will be selected
// by default.
selectResult?: {
time?: {
time: Date;
favorCameraID?: string;
};
id?: string;
func?: (media: ViewMedia) => boolean;
};
rejectResults?: (results: MediaQueriesResults) => boolean;
useCache?: boolean;
}
export interface QueryWithResults {
query: MediaQueries;
queryResults: MediaQueriesResults;
}
export interface ViewFactoryOptions {
// An existing view to evolve from.
baseView?: View | null;
// View parameters to set/evolve.
params?: Partial<ViewParameters>;
// Modifiers to the view once created.
modifiers?: ViewModifier[];
// When failSafe is true the view will be changed to the default view, or the
// `live` view if the configured default view is not supported.
failSafe?: boolean;
// Options for the query executor that control how a query is executed and the
// result selected.
queryExecutorOptions?: QueryExecutorOptions;
}
export interface ViewManagerEpoch {
manager: ViewManagerInterface;
oldView?: View;
}
export interface ViewManagerInterface {
getEpoch(): ViewManagerEpoch;
getView(): View | null;
hasView(): boolean;
reset(): void;
setViewDefault(options?: ViewFactoryOptions): void;
setViewByParameters(options?: ViewFactoryOptions): void;
setViewDefaultWithNewQuery(options?: ViewFactoryOptions): Promise<void>;
setViewByParametersWithNewQuery(options?: ViewFactoryOptions): Promise<void>;
setViewByParametersWithExistingQuery(options?: ViewFactoryOptions): Promise<void>;
setViewWithMergedContext(context: ViewContext | null): void;
isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean;
hasMajorMediaChange(oldView?: View | null): boolean;
}
export class ViewNoCameraError extends FrigateCardError {}
export class ViewIncompatible extends FrigateCardError {}
+167
View File
@@ -0,0 +1,167 @@
import { ViewContext } from 'view';
import { FrigateCardView } from '../../config/types';
import { log } from '../../utils/debug';
import { getStreamCameraID } from '../../utils/substream';
import { View } from '../../view/view';
import { getCameraIDsForViewName } from '../../view/view-to-cameras';
import { CardViewAPI } from '../types';
import { ViewFactory } from './factory';
import { ViewFactoryOptions, ViewManagerEpoch, ViewManagerInterface } from './types';
export class ViewManager implements ViewManagerInterface {
protected _view: View | null = null;
protected _factory: ViewFactory;
protected _api: CardViewAPI;
protected _epoch: ViewManagerEpoch = this._createEpoch();
constructor(api: CardViewAPI, factory?: ViewFactory) {
this._api = api;
this._factory = factory ?? new ViewFactory(api);
}
public getEpoch(): ViewManagerEpoch {
return this._epoch;
}
protected _createEpoch(oldView?: View | null): ViewManagerEpoch {
return {
manager: this,
...(oldView && { oldView }),
};
}
public getView(): View | null {
return this._view;
}
public hasView(): boolean {
return !!this.getView();
}
public reset(): void {
if (this._view) {
this._setView(null);
}
}
setViewDefault = (options?: ViewFactoryOptions): void =>
this._setViewGeneric(this._factory.getViewDefault.bind(this._factory), options);
setViewByParameters = (options?: ViewFactoryOptions): void =>
this._setViewGeneric(this._factory.getViewByParameters.bind(this._factory), options);
setViewDefaultWithNewQuery = async (options?: ViewFactoryOptions): Promise<void> =>
await this._setViewGenericAsync(
this._factory.getViewDefaultWithNewQuery.bind(this._factory),
options,
);
setViewByParametersWithNewQuery = async (
options?: ViewFactoryOptions,
): Promise<void> =>
await this._setViewGenericAsync(
this._factory.getViewByParametersWithNewQuery.bind(this._factory),
options,
);
setViewByParametersWithExistingQuery = async (
options?: ViewFactoryOptions,
): Promise<void> =>
await this._setViewGenericAsync(
this._factory.getViewByParametersWithExistingQuery.bind(this._factory),
options,
);
protected _setViewGeneric(
factoryFunc: (options?: ViewFactoryOptions) => View | null,
options?: ViewFactoryOptions,
): void {
let view: View | null = null;
try {
view = factoryFunc({
baseView: this._view,
...options,
});
} catch (e) {
return this._api.getMessageManager().setErrorIfHigherPriority(e);
}
view && this._setView(view);
}
protected async _setViewGenericAsync(
factoryFunc: (options?: ViewFactoryOptions) => Promise<View | null>,
options?: ViewFactoryOptions,
): Promise<void> {
let view: View | null = null;
try {
view = await factoryFunc({
baseView: this._view,
...options,
});
} catch (e) {
return this._api.getMessageManager().setErrorIfHigherPriority(e);
}
view && this._setView(view);
}
public setViewWithMergedContext(context: ViewContext | null): void {
if (this._view) {
return this._setView(this._view?.clone().mergeInContext(context));
}
}
public isViewSupportedByCamera(cameraID: string, view: FrigateCardView): boolean {
return !!getCameraIDsForViewName(this._api.getCameraManager(), view, cameraID).size;
}
/**
* Detect if the current view has a major "media change" for the given previous view.
* @param oldView The previous view.
* @returns True if the view change is a real media change.
*/
public hasMajorMediaChange(oldView?: View | null): boolean {
return (
!!oldView !== !!this._view ||
oldView?.view !== this._view?.view ||
oldView?.camera !== this._view?.camera ||
// When in live mode, take overrides (substreams) into account in deciding
// if this is a major media change.
(this._view?.view === 'live' &&
oldView &&
getStreamCameraID(oldView) !== getStreamCameraID(this._view)) ||
// When in the live view, the queryResults contain the events that
// happened in the past -- not reflective of the actual live media viewer
// the user is seeing.
(this._view?.view !== 'live' && oldView?.queryResults !== this._view?.queryResults)
);
}
protected _setView(view: View | null): void {
const oldView = this._view;
log(
this._api.getConfigManager().getCardWideConfig(),
`Frigate Card view change: `,
view,
);
this._view = view;
this._epoch = this._createEpoch(oldView);
if (this.hasMajorMediaChange(oldView)) {
this._api.getMediaLoadedInfoManager().clear();
}
if (oldView?.view !== view?.view) {
this._api.getCardElementManager().scrollReset();
}
this._api.getMessageManager().reset();
this._api.getStyleManager().setExpandedMode();
this._api.getConditionsManager()?.setState({
view: view?.view,
camera: view?.camera,
displayMode: view?.displayMode ?? undefined,
});
this._api.getCardElementManager().update();
}
}
+1 -7
View File
@@ -4,7 +4,6 @@ import { customElement } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { styleMap } from 'lit/directives/style-map.js';
import { ViewContext } from 'view';
import 'web-dialog';
import pkg from '../package.json';
import { actionHandler } from './action-handler-directive.js';
@@ -26,7 +25,6 @@ import { localize } from './localize/localize.js';
import cardStyle from './scss/card.scss';
import { ExtendedHomeAssistant, MediaLoadedInfo, Message } from './types.js';
import { frigateCardHasAction } from './utils/action.js';
import { View } from './view/view.js';
// ***************************************************************************
// General Card-Wide Notes
@@ -268,10 +266,6 @@ class FrigateCard extends LitElement {
style="${styleMap(this._controller.getStyleManager().getAspectRatioStyle())}"
@frigate-card:message=${(ev: CustomEvent<Message>) =>
this._controller.getMessageManager().setMessageIfHigherPriority(ev.detail)}
@frigate-card:view:change=${(ev: CustomEvent<View>) =>
this._controller.getViewManager().setView(ev.detail)}
@frigate-card:view:change-context=${(ev: CustomEvent<ViewContext | null>) =>
this._controller.getViewManager().setViewWithMergedContext(ev.detail)}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) =>
this._controller.getMediaLoadedInfoManager().set(ev.detail)}
@frigate-card:media:unloaded=${() =>
@@ -299,7 +293,7 @@ class FrigateCard extends LitElement {
html`<frigate-card-views
${ref(this._refViews)}
.hass=${this._hass}
.view=${this._controller.getViewManager().getView()}
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
.cameraManager=${cameraManager}
.resolvedMediaCache=${this._controller.getResolvedMediaCache()}
.nonOverriddenConfig=${this._controller
+2 -92
View File
@@ -1,26 +1,15 @@
import { sub } from 'date-fns';
import { LitElement, ReactiveController } from 'lit';
import { ViewContext } from 'view';
import { CameraManager } from '../../camera-manager/manager.js';
import { FrigateCardMessageEventTarget } from '../../components/message.js';
import { CardWideConfig, LiveConfig } from '../../config/types.js';
import { MediaLoadedInfo, Message } from '../../types.js';
import {
FrigateCardMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent,
} from '../../utils/media-info.js';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
} from '../../utils/media-to-view.js';
import { FrigateCardViewChangeEventTarget, View } from '../../view/view.js';
interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different
// camera to be live rather than the camera selected in the view).
overrides?: Map<string, string>;
fetchThumbnails?: boolean;
}
declare module 'view' {
@@ -36,8 +25,7 @@ interface LastMediaLoadedInfo {
type LiveControllerHost = LitElement &
FrigateCardMediaLoadedEventTarget &
FrigateCardMessageEventTarget &
FrigateCardViewChangeEventTarget;
FrigateCardMessageEventTarget;
export class LiveController implements ReactiveController {
protected _host: LiveControllerHost;
@@ -71,7 +59,7 @@ export class LiveController implements ReactiveController {
// Don't process updates if it's in the background and a message was
// received (otherwise an error message thrown by the background live
// component may continually be re-spammed hitting performance).
return !this._inBackground || !this._messageReceived;
return !(this._inBackground && this._messageReceived);
}
public hostConnected(): void {
@@ -79,7 +67,6 @@ export class LiveController implements ReactiveController {
this._host.addEventListener('frigate-card:media:loaded', this._handleMediaLoaded);
this._host.addEventListener('frigate-card:message', this._handleMessage);
this._host.addEventListener('frigate-card:view:change', this._handleViewChange);
}
public hostDisconnected(): void {
@@ -87,7 +74,6 @@ export class LiveController implements ReactiveController {
this._host.removeEventListener('frigate-card:media:loaded', this._handleMediaLoaded);
this._host.removeEventListener('frigate-card:message', this._handleMessage);
this._host.removeEventListener('frigate-card:view:change', this._handleViewChange);
}
public clearMessageReceived(): void {
@@ -124,12 +110,6 @@ export class LiveController implements ReactiveController {
}
};
protected _handleViewChange = (ev: CustomEvent<View>): void => {
if (this._inBackground) {
ev.stopPropagation();
}
};
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
const wasInBackground = this._inBackground;
this._inBackground = !entries.some((entry) => entry.isIntersecting);
@@ -150,74 +130,4 @@ export class LiveController implements ReactiveController {
this._host.requestUpdate();
}
}
/**
* Fetch thumbnail media when a target is not already specified in the view
* (e.g. first time live is visited).
*/
public async fetchMediaInBackgroundIfNecessary(
view: View,
cameraManager: CameraManager,
cardWideConfig: CardWideConfig,
overriddenLiveConfig: LiveConfig,
): Promise<void> {
if (
this._inBackground ||
// Only fetch media if there isn't any already.
view.query ||
overriddenLiveConfig.controls.thumbnails.mode === 'none' ||
view.context?.live?.fetchThumbnails === false
) {
return;
}
const mediaType = overriddenLiveConfig.controls.thumbnails.media_type;
const now = new Date();
const viewContext: ViewContext = {
// Force the window to start at the most recent time, not
// necessarily when the most recent event/recording was:
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
timeline: {
window: {
start: sub(now, {
seconds: overriddenLiveConfig.controls.timeline.window_seconds,
}),
end: now,
},
},
};
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (mediaType === 'events') {
await changeViewToRecentEventsForCameraAndDependents(
this._host,
cameraManager,
cardWideConfig,
view,
{
allCameras: view.isGrid(),
targetView: view.view,
eventsMediaType: overriddenLiveConfig.controls.thumbnails.events_media_type,
select: 'latest',
// Force the window to start at the most recent time, not
// necessarily when the most recent event was:
// https://github.com/dermotduffy/frigate-hass-card/issues/1301
viewContext: viewContext,
},
);
} else if (mediaType === 'recordings') {
await changeViewToRecentRecordingForCameraAndDependents(
this._host,
cameraManager,
cardWideConfig,
view,
{
allCameras: view.isGrid(),
targetView: view.view,
select: 'latest',
viewContext: viewContext,
},
);
}
}
}
+31 -41
View File
@@ -21,10 +21,9 @@ import { SelectOption, SelectValues } from '../components/select';
import { CardWideConfig } from '../config/types';
import { localize } from '../localize/localize';
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
import { executeMediaQueryForViewWithErrorDispatching } from '../utils/media-to-view';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { View } from '../view/view';
import { ViewManagerInterface } from '../card-controller/view/types';
interface MediaFilterControls {
events: boolean;
@@ -77,6 +76,7 @@ export class MediaFilterController {
protected _favoriteOptions: SelectOption[];
protected _defaults: MediaFilterCoreDefaults | null = null;
protected _viewManager: ViewManagerInterface | null = null;
constructor(host: LitElement) {
this._host = host;
@@ -154,10 +154,12 @@ export class MediaFilterController {
public getDefaults(): MediaFilterCoreDefaults | null {
return this._defaults;
}
public setViewManager(viewManager: ViewManagerInterface | null): void {
this._viewManager = viewManager;
}
public async valueChangeHandler(
cameraManager: CameraManager,
view: View,
cardWideConfig: CardWideConfig,
values: {
camera?: string | string[];
@@ -234,20 +236,15 @@ export class MediaFilterController {
},
]);
(
await executeMediaQueryForViewWithErrorDispatching(
this._host,
cameraManager,
view,
queries,
{
// See 'A note on views' above for these two arguments.
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
targetView:
values.mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
},
)
)?.dispatchChangeEvent(this._host);
this._viewManager?.setViewByParametersWithExistingQuery({
params: {
query: queries,
// See 'A note on views' above for these two arguments
...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }),
view: values.mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
},
});
} else {
const queries = new RecordingMediaQueries([
{
@@ -262,19 +259,15 @@ export class MediaFilterController {
},
]);
(
await executeMediaQueryForViewWithErrorDispatching(
this._host,
cameraManager,
view,
queries,
{
// See 'A note on views' above for these two arguments.
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
targetView: 'recordings',
},
)
)?.dispatchChangeEvent(this._host);
this._viewManager?.setViewByParametersWithExistingQuery({
params: {
query: queries,
// See 'A note on views' above for these two arguments
...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }),
view: 'recordings',
},
});
}
// Need to ensure we update the element as the date-picker selections may
@@ -288,10 +281,11 @@ export class MediaFilterController {
});
}
public computeInitialDefaultsFromView(cameraManager: CameraManager, view: View): void {
const queries = view.query?.getQueries();
public computeInitialDefaultsFromView(cameraManager: CameraManager): void {
const view = this._viewManager?.getView();
const queries = view?.query?.getQueries();
const allCameraIDs = this._getAllCameraIDs(cameraManager);
if (!queries || !allCameraIDs.size) {
if (!view || !queries || !allCameraIDs.size) {
return;
}
@@ -444,14 +438,10 @@ export class MediaFilterController {
this._host.requestUpdate();
}
public getControlsToShow(
cameraManager: CameraManager,
view: View,
): MediaFilterControls {
const events = !!(view.query && MediaQueriesClassifier.areEventQueries(view.query));
const recordings = !!(
view.query && MediaQueriesClassifier.areRecordingQueries(view.query)
);
public getControlsToShow(cameraManager: CameraManager): MediaFilterControls {
const view = this._viewManager?.getView();
const events = MediaQueriesClassifier.areEventQueries(view?.query);
const recordings = MediaQueriesClassifier.areRecordingQueries(view?.query);
const managerCapabilities = cameraManager.getAggregateCameraCapabilities();
return {
+1 -1
View File
@@ -3,7 +3,7 @@ import { StyleInfo } from 'lit/directives/style-map';
import { CameraManager } from '../camera-manager/manager';
import { MediaPlayerManager } from '../card-controller/media-player-manager';
import { MicrophoneManager } from '../card-controller/microphone-manager';
import { ViewManager } from '../card-controller/view-manager';
import { ViewManager } from '../card-controller/view/view-manager';
import {
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FrigateCardConfig,
+16 -12
View File
@@ -1,6 +1,7 @@
import { ViewContext } from 'view';
import { dispatchViewContextChangeEvent } from '../../view/view.js';
import { ZoomSettingsObserved, PartialZoomSettings } from './types.js';
import { MergeContextViewModifier } from '../../card-controller/view/modifiers/merge-context.js';
import { ViewManagerInterface } from '../../card-controller/view/types.js';
import { PartialZoomSettings, ZoomSettingsObserved } from './types.js';
interface ZoomViewContext {
observed?: ZoomSettingsObserved;
@@ -38,19 +39,22 @@ export const generateViewContextForZoom = (
};
/**
* Convenience wrapper to convert zoom settings into a dispatched view context
* change.
* Convenience wrapper to convert zoom settings into a view change
*/
export const handleZoomSettingsObservedEvent = (
element: EventTarget,
ev: CustomEvent<ZoomSettingsObserved>,
viewManager?: ViewManagerInterface,
targetID?: string,
): void => {
targetID &&
dispatchViewContextChangeEvent(
element,
generateViewContextForZoom(targetID, {
observed: ev.detail,
}),
);
viewManager &&
targetID &&
viewManager.setViewByParameters({
modifiers: [
new MergeContextViewModifier(
generateViewContextForZoom(targetID, {
observed: ev.detail,
}),
),
],
});
};
+37 -62
View File
@@ -24,20 +24,16 @@ import galleryStyle from '../scss/gallery.scss';
import { ExtendedHomeAssistant } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { errorToConsole, sleep } from '../utils/basic';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
} from '../utils/media-to-view.js';
import { ViewMedia } from '../view/media';
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view.js';
import './media-filter';
import { renderMessage, renderProgressIndicator } from './message.js';
import './surround-basic';
import './thumbnail.js';
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
const GALLERY_MEDIA_FILTER_MENU_ICONS = {
closed: 'mdi:filter-cog-outline',
@@ -52,7 +48,7 @@ export class FrigateCardGallery extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public galleryConfig?: GalleryConfig;
@@ -68,43 +64,16 @@ export class FrigateCardGallery extends LitElement {
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
const view = this.viewManagerEpoch?.manager.getView();
if (
!this.hass ||
!this.view ||
!this.view.isGalleryView() ||
!view?.isGalleryView() ||
!this.cameraManager ||
!this.cardWideConfig
) {
return;
}
if (!this.view.query) {
if (this.view.is('recordings')) {
changeViewToRecentRecordingForCameraAndDependents(
this,
this.cameraManager,
this.cardWideConfig,
this.view,
);
} else {
const eventsMediaType = this.view.is('snapshots')
? 'snapshots'
: this.view.is('clips')
? 'clips'
: null;
changeViewToRecentEventsForCameraAndDependents(
this,
this.cameraManager,
this.cardWideConfig,
this.view,
{
...(eventsMediaType && { eventsMediaType: eventsMediaType }),
},
);
}
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
}
return html`
<frigate-card-surround-basic
.drawerIcons=${{
@@ -118,7 +87,7 @@ export class FrigateCardGallery extends LitElement {
? html` <frigate-card-media-filter
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.cardWideConfig=${this.cardWideConfig}
slot=${this.galleryConfig.controls.filter.mode}
>
@@ -126,7 +95,7 @@ export class FrigateCardGallery extends LitElement {
: ''}
<frigate-card-gallery-core
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.galleryConfig=${this.galleryConfig}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
@@ -147,7 +116,7 @@ export class FrigateCardGalleryCore extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public galleryConfig?: GalleryConfig;
@@ -328,13 +297,15 @@ export class FrigateCardGalleryCore extends LitElement {
direction: 'earlier' | 'later',
useCache = true,
): Promise<void> {
if (!this.cameraManager || !this.hass || !this.view) {
const view = this.viewManagerEpoch?.manager.getView();
if (!this.cameraManager || !this.hass || !view) {
return;
}
const query = this.view?.query;
const query = view.query;
const rawQueries = query?.getQueries() ?? null;
const existingMedia = this.view.queryResults?.getResults();
const existingMedia = view.queryResults?.getResults();
if (!query || !rawQueries || !existingMedia) {
return;
}
@@ -362,16 +333,17 @@ export class FrigateCardGalleryCore extends LitElement {
: null;
if (newMediaQueries) {
this.view
?.evolve({
this.viewManagerEpoch?.manager.setViewByParameters({
baseView: view,
params: {
query: newMediaQueries,
queryResults: new MediaQueriesResults({
results: extension.results,
}).selectResultIfFound(
(media) => media === this.view?.queryResults?.getSelectedResult(),
(media) => media === view.queryResults?.getSelectedResult(),
),
})
.dispatchChangeEvent(this);
},
});
}
}
}
@@ -395,19 +367,21 @@ export class FrigateCardGalleryCore extends LitElement {
);
}
}
if (changedProps.has('view')) {
if (changedProps.has('viewManagerEpoch')) {
// If the view changes, always render the bottom loader to allow for the
// view to be extended once the bottom loader becomes visible.
this._showLoaderBottom = true;
const oldView: View | undefined = changedProps.get('view');
const view = this.viewManagerEpoch?.manager.getView();
const oldView = this.viewManagerEpoch?.oldView;
if (
oldView?.queryResults?.getResults() !== this.view?.queryResults?.getResults()
!this._media ||
oldView?.queryResults?.getResults() !== view?.queryResults?.getResults()
) {
// Gallery places the most recent media at the top (the query results place
// the most recent media at the end for use in the viewer). This is copied
// to a new array to avoid reversing the query results in place.
this._media = [...(this.view?.queryResults?.getResults() ?? [])].reverse();
this._media = [...(view?.queryResults?.getResults() ?? [])].reverse();
}
}
}
@@ -417,11 +391,12 @@ export class FrigateCardGalleryCore extends LitElement {
* @returns A rendered template.
*/
protected render(): TemplateResult | void {
if (!this._media || !this.hass || !this.view || !this.view.isGalleryView()) {
if (!this._media || !this.hass) {
return html``;
}
if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) {
const view = this.viewManagerEpoch?.manager.getView();
if (!view?.queryResults || view.queryResults.getResultsCount() === 0) {
// Note that this is not throwing up an error message for the card to
// handle (as typical), but rather directly rendering the message into the
// gallery. This is to allow the filter to still be available when a given
@@ -433,7 +408,7 @@ export class FrigateCardGalleryCore extends LitElement {
});
}
const selected = this.view?.queryResults?.getSelectedResult();
const selected = view.queryResults.getSelectedResult();
return html` <div class="grid">
${this._showLoaderTop
? html`${renderProgressIndicator({
@@ -454,7 +429,7 @@ export class FrigateCardGalleryCore extends LitElement {
.hass=${this.hass}
.cameraManager=${this.cameraManager}
.media=${media}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
.show_favorite_control}
@@ -463,17 +438,17 @@ export class FrigateCardGalleryCore extends LitElement {
?show_download_control=${!!this.galleryConfig?.controls.thumbnails
.show_download_control}
@click=${(ev: Event) => {
if (this.view && this._media) {
this.view
.evolve({
if (this._media) {
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
view: 'media',
queryResults: this.view.queryResults?.clone().selectIndex(
queryResults: view.queryResults?.clone().selectIndex(
// Media in the gallery is reversed vs the queryResults (see
// note above).
this._media.length - index - 1,
),
})
.dispatchChangeEvent(this);
},
});
}
stopEventFromActivatingCardWideActions(ev);
}}
@@ -504,9 +479,9 @@ export class FrigateCardGalleryCore extends LitElement {
// See: https://github.com/dermotduffy/frigate-hass-card/issues/885
if (
// If this update cycle updated the view ...
changedProps.has('view') &&
changedProps.has('viewManagerEpoch') &&
// ... and it wasn't set at all prior ...
!changedProps.get('view') &&
!changedProps.get('viewManagerEpoch') &&
// ... and there is a thumbnail rendered that is selected.
this._refSelected.value
) {
+63 -87
View File
@@ -19,8 +19,14 @@ import {
getOverriddenConfig,
} from '../../card-controller/conditions-manager.js';
import { ReadonlyMicrophoneManager } from '../../card-controller/microphone-manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { LiveController } from '../../components-lib/live/live-controller.js';
import { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
import {
PartialZoomSettings,
ZoomSettingsObserved,
} from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
import {
CameraConfig,
CardWideConfig,
@@ -48,7 +54,8 @@ import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js';
import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { playMediaMutingIfNecessary } from '../../utils/media.js';
import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
import { getStreamCameraID } from '../../utils/substream.js';
import { View } from '../../view/view.js';
import { EmblaCarouselPlugins } from '../carousel.js';
import { renderMessage } from '../message.js';
import '../next-prev-control.js';
@@ -60,12 +67,6 @@ import {
FrigateCardTitleControl,
getDefaultTitleConfigForView,
} from '../title-control.js';
import {
PartialZoomSettings,
ZoomSettingsObserved,
} from '../../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-view-context.js';
import { getStreamCameraID } from '../../utils/substream.js';
const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider';
@@ -78,7 +79,7 @@ export class FrigateCardLive extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public nonOverriddenLiveConfig?: LiveConfig;
@@ -108,38 +109,16 @@ export class FrigateCardLive extends LitElement {
return this._controller.shouldUpdate();
}
protected willUpdate(changedProperties: PropertyValues): void {
if (
['view', 'cameraManager', 'cardWideConfig', 'overriddenLiveConfig'].some((prop) =>
changedProperties.has(prop),
) &&
this.view &&
this.cameraManager &&
this.cardWideConfig &&
this.overriddenLiveConfig
) {
this._controller.fetchMediaInBackgroundIfNecessary(
this.view,
this.cameraManager,
this.cardWideConfig,
this.overriddenLiveConfig,
);
}
protected willUpdate(): void {
this._controller.clearMessageReceived();
}
protected render(): TemplateResult | void {
if (
!this.hass ||
!this.nonOverriddenLiveConfig ||
!this.cameraManager ||
!this.view
) {
if (!this.hass || !this.nonOverriddenLiveConfig || !this.cameraManager) {
return;
}
// Notes:
// Implementation notes:
// - See use of liveConfig and not config below -- the underlying carousel
// will independently override the liveConfig to reflect the camera in the
// carousel (not necessarily the selected camera).
@@ -153,7 +132,7 @@ export class FrigateCardLive extends LitElement {
html`
<frigate-card-live-grid
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
.inBackground=${this._controller.isInBackground()}
@@ -180,7 +159,7 @@ export class FrigateCardLiveGrid extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public nonOverriddenLiveConfig?: LiveConfig;
@@ -207,13 +186,14 @@ export class FrigateCardLiveGrid extends LitElement {
public triggeredCameraIDs?: Set<string>;
protected _renderCarousel(cameraID?: string): TemplateResult {
const triggeredCameraID = cameraID ?? this.view?.camera;
const view = this.viewManagerEpoch?.manager.getView();
const triggeredCameraID = cameraID ?? view?.camera;
return html`
<frigate-card-live-carousel
grid-id=${ifDefined(cameraID)}
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewFilterCameraID=${cameraID}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
@@ -229,26 +209,27 @@ export class FrigateCardLiveGrid extends LitElement {
`;
}
protected _gridSelectCamera(cameraID: string, view?: View): void {
(view ?? this.view)
?.evolve({
protected _gridSelectCamera(cameraID: string): void {
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
camera: cameraID,
})
.dispatchChangeEvent(this);
},
});
}
protected _needsGrid(): boolean {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
const view = this.viewManagerEpoch?.manager.getView();
return (
!!this.view?.isGrid() &&
!!this.view?.supportsMultipleDisplayModes() &&
!!view?.isGrid() &&
!!view?.supportsMultipleDisplayModes() &&
!!cameraIDs &&
cameraIDs.size > 1
);
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('view') && this._needsGrid()) {
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
import('../media-grid.js');
}
}
@@ -261,16 +242,13 @@ export class FrigateCardLiveGrid extends LitElement {
if (!cameraIDs?.size || !this._needsGrid()) {
return this._renderCarousel();
}
return html`
<frigate-card-media-grid
.selected=${this.view?.camera}
.selected=${this.viewManagerEpoch?.manager.getView()?.camera}
.displayConfig=${this.overriddenLiveConfig?.display}
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
this._gridSelectCamera(ev.detail.selected)}
@frigate-card:view:change=${(ev: CustomEvent<View>) => {
ev.stopPropagation();
this._gridSelectCamera(ev.detail.camera, ev.detail);
}}
>
${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))}
</frigate-card-media-grid>
@@ -288,7 +266,7 @@ export class FrigateCardLiveCarousel extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public nonOverriddenLiveConfig?: LiveConfig;
@@ -331,12 +309,13 @@ export class FrigateCardLiveCarousel extends LitElement {
protected _getSelectedCameraIndex(): number {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
if (!cameraIDs?.size || !this.view || this.viewFilterCameraID) {
const view = this.viewManagerEpoch?.manager.getView();
if (!cameraIDs?.size || !view || this.viewFilterCameraID) {
// If the carousel is limited to a single cameraID, the first (only)
// element is always the selected one.
return 0;
}
return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera));
return Math.max(0, Array.from(cameraIDs).indexOf(view.camera));
}
protected _getPlugins(): EmblaCarouselPlugins {
@@ -393,6 +372,7 @@ export class FrigateCardLiveCarousel extends LitElement {
return [[], {}];
}
const view = this.viewManagerEpoch?.manager.getView();
const cameraIDs = this.viewFilterCameraID
? new Set([this.viewFilterCameraID])
: this.cameraManager?.getStore().getCameraIDsWithCapability('live');
@@ -403,8 +383,7 @@ export class FrigateCardLiveCarousel extends LitElement {
for (const [cameraID, cameraConfig] of this.cameraManager
.getStore()
.getCameraConfigEntries(cameraIDs)) {
const liveCameraID =
this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
const liveCameraID = this._getSubstreamCameraID(cameraID, view);
const liveCameraConfig =
cameraID === liveCameraID
? cameraConfig
@@ -430,17 +409,11 @@ export class FrigateCardLiveCarousel extends LitElement {
protected _setViewCameraID(cameraID?: string | null): void {
if (cameraID) {
this.view
?.evolve({
this.viewManagerEpoch?.manager.setViewByParametersWithNewQuery({
params: {
camera: cameraID,
// Reset the query and query results.
query: null,
queryResults: null,
})
// Don't yet fetch thumbnails (they will be fetched when the carousel
// settles).
.mergeInContext({ live: { fetchThumbnails: false } })
.dispatchChangeEvent(this);
},
});
}
}
@@ -490,12 +463,13 @@ export class FrigateCardLiveCarousel extends LitElement {
).live as LiveConfig;
const cameraMetadata = this.cameraManager.getCameraMetadata(cameraID);
const view = this.viewManagerEpoch?.manager.getView();
return html`
<div class="embla__slide">
<frigate-card-live-provider
?load=${!liveConfig.lazy_load}
.microphoneStream=${this.view?.camera === cameraID
.microphoneStream=${view?.camera === cameraID
? this.microphoneManager?.getStream()
: undefined}
.cameraConfig=${cameraConfig}
@@ -507,9 +481,13 @@ export class FrigateCardLiveCarousel extends LitElement {
.liveConfig=${liveConfig}
.hass=${this.hass}
.cardWideConfig=${this.cardWideConfig}
.zoomSettings=${this.view?.context?.zoom?.[cameraID]?.requested}
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
handleZoomSettingsObservedEvent(this, ev, cameraID)}
handleZoomSettingsObservedEvent(
ev,
this.viewManagerEpoch?.manager,
cameraID,
)}
>
</frigate-card-live-provider>
</div>
@@ -520,11 +498,13 @@ export class FrigateCardLiveCarousel extends LitElement {
const cameraIDs = this.cameraManager
? [...this.cameraManager?.getStore().getCameraIDsWithCapability('live')]
: [];
if (this.viewFilterCameraID || cameraIDs.length <= 1 || !this.view || !this.hass) {
const view = this.viewManagerEpoch?.manager.getView();
if (this.viewFilterCameraID || cameraIDs.length <= 1 || !view || !this.hass) {
return [null, null];
}
const cameraID = this.viewFilterCameraID ?? this.view.camera;
const cameraID = this.viewFilterCameraID ?? view.camera;
const currentIndex = cameraIDs.indexOf(cameraID);
if (currentIndex < 0) {
@@ -537,8 +517,13 @@ export class FrigateCardLiveCarousel extends LitElement {
];
}
protected _getSubstreamCameraID(cameraID: string, view?: View | null): string {
return view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
}
protected render(): TemplateResult | void {
if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) {
const view = this.viewManagerEpoch?.manager.getView();
if (!this.overriddenLiveConfig || !this.hass || !view || !this.cameraManager) {
return;
}
@@ -551,23 +536,19 @@ export class FrigateCardLiveCarousel extends LitElement {
const hasMultipleCameras = slides.length > 1;
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
const getOverrideCameraID = (cameraID: string): string => {
return this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
};
const cameraMetadataPrevious = prevID
? this.cameraManager.getCameraMetadata(getOverrideCameraID(prevID))
? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(prevID, view))
: null;
const cameraID = this.viewFilterCameraID ?? this.view.camera;
const cameraID = this.viewFilterCameraID ?? view.camera;
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
getOverrideCameraID(cameraID),
this._getSubstreamCameraID(cameraID, view),
);
const cameraMetadataNext = nextID
? this.cameraManager.getCameraMetadata(getOverrideCameraID(nextID))
? this.cameraManager.getCameraMetadata(this._getSubstreamCameraID(nextID, view))
: null;
const titleConfig = getDefaultTitleConfigForView(
this.view,
view,
this.overriddenLiveConfig?.controls.title,
);
@@ -592,10 +573,6 @@ export class FrigateCardLiveCarousel extends LitElement {
.selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()}
@frigate-card:carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:carousel:settle=${() => {
// Fetch the thumbnails after the carousel has settled.
dispatchViewContextChangeEvent(this, { live: { fetchThumbnails: true } });
}}
@frigate-card:media:loaded=${() => {
if (this._refTitleControl.value) {
this._refTitleControl.value.show();
@@ -639,9 +616,8 @@ export class FrigateCardLiveCarousel extends LitElement {
<frigate-card-ptz
.config=${this.overriddenLiveConfig.controls.ptz}
.cameraManager=${this.cameraManager}
.cameraID=${getStreamCameraID(this.view, cameraID)}
.forceVisibility=${this._mediaHasLoaded &&
this.view.context?.ptzControls?.enabled}
.cameraID=${getStreamCameraID(view, cameraID)}
.forceVisibility=${this._mediaHasLoaded && view.context?.ptzControls?.enabled}
>
</frigate-card-ptz>
${cameraMetadataCurrent && titleConfig
+23 -23
View File
@@ -21,11 +21,11 @@ import {
import { CardWideConfig } from '../config/types';
import { localize } from '../localize/localize';
import mediaFilterStyle from '../scss/media-filter.scss';
import { View } from '../view/view';
import { FrigateCardDatePicker } from './date-picker';
import './date-picker.js';
import { FrigateCardSelect } from './select';
import './select.js';
import { ViewManagerEpoch } from '../card-controller/view/types';
@customElement('frigate-card-media-filter')
class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
@@ -36,7 +36,7 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
public cameraManager?: CameraManager;
@property({ attribute: false })
public view?: View;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@@ -59,46 +59,49 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
protected _refTags: Ref<FrigateCardSelect> = createRef();
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('viewManagerEpoch')) {
this._mediaFilterController.setViewManager(this.viewManagerEpoch?.manager ?? null);
}
if (changedProps.has('cameraManager') && this.cameraManager) {
this._mediaFilterController.computeCameraOptions(this.cameraManager);
this._mediaFilterController.computeMetadataOptions(this.cameraManager);
}
// The first time the viewManager is set, compute the initial default selections.
if (
changedProps.has('view') &&
!changedProps.get('view') &&
this.view &&
!changedProps.get('viewManager') &&
this.viewManagerEpoch &&
this.cameraManager
) {
this._mediaFilterController.computeInitialDefaultsFromView(
this.cameraManager,
this.view,
);
this._mediaFilterController.computeInitialDefaultsFromView(this.cameraManager);
}
}
protected render(): TemplateResult | void {
const valueChange = async () => {
if (!this.cameraManager || !this.view || !this.cardWideConfig) {
if (!this.cameraManager || !this.viewManagerEpoch || !this.cardWideConfig) {
return;
}
await this._mediaFilterController.valueChangeHandler(
this.cameraManager,
this.view,
this.cardWideConfig,
{
camera: this._refCamera.value?.value,
mediaType: this._refMediaType.value?.value as MediaFilterMediaType | undefined,
camera: this._refCamera.value?.value ?? undefined,
mediaType: (this._refMediaType.value?.value ?? undefined) as
| MediaFilterMediaType
| undefined,
when: {
selected: this._refWhen.value?.value,
selected: this._refWhen.value?.value ?? undefined,
from: this._refWhenFrom.value?.value,
to: this._refWhenTo.value?.value,
},
favorite: this._refFavorite.value?.value as
favorite: (this._refFavorite.value?.value ?? undefined) as
| MediaFilterCoreFavoriteSelection
| undefined,
where: this._refWhere.value?.value,
what: this._refWhat.value?.value,
tags: this._refTags.value?.value,
where: this._refWhere.value?.value ?? undefined,
what: this._refWhat.value?.value ?? undefined,
tags: this._refTags.value?.value ?? undefined,
},
);
};
@@ -119,14 +122,11 @@ class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
await valueChange();
};
if (!this.cameraManager || !this.view) {
if (!this.cameraManager || !this.viewManagerEpoch) {
return;
}
const controls = this._mediaFilterController.getControlsToShow(
this.cameraManager,
this.view,
);
const controls = this._mediaFilterController.getControlsToShow(this.cameraManager);
const defaults = this._mediaFilterController.getDefaults();
const whatOptions = this._mediaFilterController.getWhatOptions();
const tagsOptions = this._mediaFilterController.getTagsOptions();
+10 -3
View File
@@ -31,7 +31,7 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
public options?: SelectOption[];
@property({ attribute: false, hasChanged: contentsChanged })
public value?: SelectValues;
public value: SelectValues | null = null;
@property({ attribute: false, hasChanged: contentsChanged })
public initialValue?: SelectValues;
@@ -56,7 +56,7 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
};
public reset(): void {
this.value = undefined;
this.value = null;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -66,8 +66,15 @@ export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
// the change event even if the value has not actually changed. Prevent that
// from propagating upwards.
if (value !== undefined && !isEqual(this.value, value)) {
const initialValueSet = this.value === null;
this.value = value;
dispatchFrigateCardEvent(this, 'select:change', value);
// The underlying gr-select element will call on the first first value set
// (even when the user has not interacted with the control). Do not
// dispatch events for this.
if (!initialValueSet) {
dispatchFrigateCardEvent(this, 'select:change', value);
}
}
}
+32 -28
View File
@@ -8,6 +8,8 @@ import {
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../camera-manager/manager.js';
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import {
CardWideConfig,
MiniTimelineControlConfig,
@@ -16,7 +18,6 @@ import {
import basicBlockStyle from '../scss/basic-block.scss';
import { ExtendedHomeAssistant } from '../types.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import { View } from '../view/view.js';
import './surround-basic.js';
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
@@ -26,7 +27,7 @@ export class FrigateCardSurround extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false, hasChanged: contentsChanged })
public thumbnailConfig?: ThumbnailsControlConfig;
@@ -60,42 +61,47 @@ export class FrigateCardSurround extends LitElement {
// Only reset the timeline cameraIDs when the media or display mode
// materially changes (and not on every view change, since the view will
// change frequently when the user is scrubbing video).
const oldView = changedProperties.get('view');
const view = this.viewManagerEpoch?.manager.getView();
if (
changedProperties.has('view') &&
(View.isMajorMediaChange(oldView, this.view) ||
oldView.displayMode !== this.view?.displayMode)
changedProperties.has('viewManagerEpoch') &&
(this.viewManagerEpoch?.manager.hasMajorMediaChange(
this.viewManagerEpoch?.oldView,
) ||
this.viewManagerEpoch?.oldView?.displayMode !== view?.displayMode)
) {
this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined;
}
}
protected _getCameraIDsForTimeline(): Set<string> | null {
if (!this.view || !this.cameraManager) {
const view = this.viewManagerEpoch?.manager.getView();
if (!view || !this.cameraManager) {
return null;
}
if (this.view.is('live')) {
if (view.is('live')) {
const capabilitySearch = {
anyCapabilities: ['clips' as const, 'snapshots' as const, 'recordings' as const],
};
if (this.view.supportsMultipleDisplayModes() && this.view.isGrid()) {
if (view.supportsMultipleDisplayModes() && view.isGrid()) {
return this.cameraManager
.getStore()
.getCameraIDsWithCapability(capabilitySearch);
} else {
return this.cameraManager
.getStore()
.getAllDependentCameras(this.view.camera, capabilitySearch);
.getAllDependentCameras(view.camera, capabilitySearch);
}
}
if (this.view.isViewerView()) {
return this.view.query?.getQueryCameraIDs() ?? null;
if (view.isViewerView()) {
return view.query?.getQueryCameraIDs() ?? null;
}
return null;
}
protected render(): TemplateResult | void {
if (!this.hass || !this.view) {
const view = this.viewManagerEpoch?.manager.getView();
if (!this.hass || !view) {
return;
}
@@ -122,27 +128,25 @@ export class FrigateCardSurround extends LitElement {
.hass=${this.hass}
.config=${this.thumbnailConfig}
.cameraManager=${this.cameraManager}
.fadeThumbnails=${this.view.isViewerView()}
.view=${this.view}
.selected=${this.view.queryResults?.getSelectedIndex() ?? undefined}
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
.fadeThumbnails=${view.isViewerView()}
.viewManagerEpoch=${this.viewManagerEpoch}
.selected=${view.queryResults?.getSelectedIndex() ?? undefined}
@frigate-card:thumbnail-carousel:tap=${(
ev: CustomEvent<ThumbnailCarouselTap>,
) => {
const media = ev.detail.queryResults.getSelectedResult();
if (media) {
this.view
?.evolve({
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
view: 'media',
queryResults: ev.detail.queryResults,
...(media.getCameraID() && { camera: media.getCameraID() }),
})
.removeContext('timeline')
.removeContext('mediaViewer')
// Send the view change from the source of the tap event, so
// the view change will be caught by the handler above (to
// close the drawer).
.dispatchChangeEvent(ev.composedPath()[0]);
},
modifiers: [
new RemoveContextViewModifier(['timeline', 'mediaViewer']),
],
});
changeDrawer(ev, 'close');
}
}}
>
@@ -152,8 +156,8 @@ export class FrigateCardSurround extends LitElement {
? html` <frigate-card-timeline-core
slot=${this.timelineConfig.mode}
.hass=${this.hass}
.view=${this.view}
.itemClickAction=${this.view.isViewerView() ||
.viewManagerEpoch=${this.viewManagerEpoch}
.itemClickAction=${view.isViewerView() ||
!this.thumbnailConfig ||
this.thumbnailConfig?.mode === 'none'
? 'play'
+15 -11
View File
@@ -17,9 +17,9 @@ import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { CarouselDirection } from '../utils/embla/carousel-controller.js';
import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js';
import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view.js';
import './carousel.js';
import './thumbnail.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
export interface ThumbnailCarouselTap {
queryResults: MediaQueriesResults;
@@ -31,7 +31,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public cameraManager?: CameraManager;
@@ -62,13 +62,13 @@ export class FrigateCardThumbnailCarousel extends LitElement {
'cameraManager',
'config',
'transitionEffect',
'view',
'viewManagerEpoch',
] as const;
if (renderProperties.some((prop) => changedProps.has(prop))) {
this._thumbnailSlides = this._renderSlides();
}
if (changedProps.has('view')) {
if (changedProps.has('viewManagerEpoch')) {
this.style.setProperty(
'--frigate-card-carousel-thumbnail-opacity',
!this.fadeThumbnails || this._getSelectedSlide() === null ? '1.0' : '0.4',
@@ -76,16 +76,19 @@ export class FrigateCardThumbnailCarousel extends LitElement {
}
}
protected _getSelectedSlide(view?: View): number | null {
return (view ?? this.view)?.queryResults?.getSelectedIndex() ?? null;
protected _getSelectedSlide(): number | null {
return (
this.viewManagerEpoch?.manager.getView()?.queryResults?.getSelectedIndex() ?? null
);
}
protected _renderSlides(): TemplateResult[] {
const slides: TemplateResult[] = [];
const seekTarget = this.view?.context?.mediaViewer?.seek;
const view = this.viewManagerEpoch?.manager.getView();
const seekTarget = view?.context?.mediaViewer?.seek;
const selectedIndex = this._getSelectedSlide();
for (const media of this.view?.queryResults?.getResults() ?? []) {
for (const media of view?.queryResults?.getResults() ?? []) {
const index = slides.length;
const classes = {
embla__slide: true,
@@ -98,19 +101,20 @@ export class FrigateCardThumbnailCarousel extends LitElement {
.cameraManager=${this.cameraManager}
.hass=${this.hass}
.media=${media}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
?details=${!!this.config?.show_details}
?show_favorite_control=${this.config?.show_favorite_control}
?show_timeline_control=${this.config?.show_timeline_control}
?show_download_control=${this.config?.show_download_control}
@click=${(ev: Event) => {
if (this.view && this.view.queryResults) {
const view = this.viewManagerEpoch?.manager.getView();
if (view && view.queryResults) {
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
this,
'thumbnail-carousel:tap',
{
queryResults: this.view.queryResults.clone().selectIndex(index),
queryResults: view.queryResults.clone().selectIndex(index),
},
);
}
+23 -22
View File
@@ -1,3 +1,4 @@
import { Task, TaskStatus } from '@lit-labs/task';
import { format } from 'date-fns';
import {
CSSResult,
@@ -9,11 +10,14 @@ import {
} 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 { ViewManagerEpoch } from '../card-controller/view/types.js';
import { localize } from '../localize/localize.js';
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
import thumbnailStyle from '../scss/thumbnail.scss';
import type { ExtendedHomeAssistant } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import {
errorToConsole,
@@ -21,17 +25,13 @@ import {
getDurationString,
prettifyTitle,
} from '../utils/basic.js';
import { downloadMedia } from '../utils/download.js';
import { renderTask } from '../utils/task.js';
import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumbnail.js';
import { View } from '../view/view.js';
import { Task, TaskStatus } from '@lit-labs/task';
import type { ExtendedHomeAssistant } from '../types.js';
import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js';
import { CameraManager } from '../camera-manager/manager.js';
import { ViewMediaClassifier } from '../view/media-classifier.js';
import { downloadMedia } from '../utils/download.js';
import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js';
import { dispatchFrigateCardErrorEvent } from './message.js';
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
// The minimum width of a thumbnail with details enabled.
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
@@ -335,22 +335,22 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
@customElement('frigate-card-thumbnail')
export class FrigateCardThumbnail extends LitElement {
// Performance: During timeline scrubbing, hass may be updated
// continuously. As it is not needed for the thumbnail rendering itself, it
// does not trigger a re-render. The HomeAssistant object may be required for
// thumbnail signing (after initial signing the thumbnail is stored in a data
// URL, so the signing will not expire).
// Performance: During timeline scrubbing, hass may be updated continuously.
// As it is not needed for the thumbnail rendering itself, it does not trigger
// a re-render. The HomeAssistant object may be required for thumbnail signing
// (after initial signing the thumbnail is stored in a data URL, so the
// signing will not expire).
public hass?: ExtendedHomeAssistant;
// Performance: During timeline scrubbing, the view will be updated
// continuously. As it is not needed for the thumbnail rendering itself, it
// does not trigger a re-render.
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: true })
@property({ attribute: false })
public media?: ViewMedia;
@property({ attribute: true, type: Boolean })
@@ -467,18 +467,19 @@ export class FrigateCardThumbnail extends LitElement {
title=${localize('thumbnail.timeline')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (!this.view || !this.media) {
if (!this.viewManagerEpoch || !this.media) {
return;
}
this.view
.evolve({
this.viewManagerEpoch.manager.setViewByParameters({
params: {
view: 'timeline',
queryResults: this.view.queryResults
?.clone()
queryResults: this.viewManagerEpoch?.manager
.getView()
?.queryResults?.clone()
.selectResultIfFound((media) => media === this.media),
})
.removeContext('timeline')
.dispatchChangeEvent(this);
},
modifiers: [new RemoveContextViewModifier(['timeline'])],
});
}}
></ha-icon>`
: ''}
+133 -138
View File
@@ -1,14 +1,14 @@
import { add, differenceInSeconds, sub } from 'date-fns';
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
html,
unsafeCSS,
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import isEqual from 'lodash-es/isEqual';
import throttle from 'lodash-es/throttle';
import { ViewContext } from 'view';
@@ -26,6 +26,8 @@ import { CameraManager } from '../camera-manager/manager';
import { rangesOverlap } from '../camera-manager/range';
import { MediaQuery } from '../camera-manager/types';
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/utils/range-to-cache-friendly';
import { MergeContextViewModifier } from '../card-controller/view/modifiers/merge-context';
import { ViewManagerEpoch } from '../card-controller/view/types';
import {
FrigateCardTimelineItem,
TimelineDataSource,
@@ -33,11 +35,11 @@ import {
import {
CameraConfig,
CardWideConfig,
frigateCardConfigDefaults,
FrigateCardView,
ThumbnailsControlBaseConfig,
TimelineCoreConfig,
TimelinePanMode,
frigateCardConfigDefaults,
} from '../config/types';
import { localize } from '../localize/localize';
import timelineCoreStyle from '../scss/timeline-core.scss';
@@ -51,10 +53,7 @@ import {
isTruthy,
setOrRemoveAttribute,
} from '../utils/basic';
import {
executeMediaQueryForViewWithErrorDispatching,
findBestMediaIndex,
} from '../utils/media-to-view';
import { findBestMediaIndex } from '../utils/find-best-media-index';
import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier';
import {
@@ -67,7 +66,7 @@ import {
MediaQueriesType,
} from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view';
import { mergeViewContext } from '../view/view';
import './date-picker.js';
import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js';
import './thumbnail.js';
@@ -107,7 +106,7 @@ interface ThumbnailDataRequest {
cameraManager?: CameraManager;
cameraConfig?: CameraConfig;
media?: ViewMedia;
view?: View;
viewManagerEpoch?: ViewManagerEpoch;
}
class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
@@ -115,7 +114,7 @@ class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
const TIMELINE_TARGET_BAR_ID = 'target_bar';
/**
* A simgple thumbnail wrapper class for use in the timeline where LIT data
* A simgple thumbnail wrapper class for use in the timeline where Lit data
* bindings are not available.
*/
@customElement('frigate-card-timeline-thumbnail')
@@ -160,7 +159,7 @@ export class FrigateCardTimelineThumbnail extends LitElement {
!dataRequest.cameraManager ||
!dataRequest.cameraConfig ||
!dataRequest.media ||
!dataRequest.view
!dataRequest.viewManagerEpoch
) {
return html``;
}
@@ -169,7 +168,7 @@ export class FrigateCardTimelineThumbnail extends LitElement {
.hass=${dataRequest.hass}
.cameraManager=${dataRequest.cameraManager}
.media=${dataRequest.media}
.view=${dataRequest.view}
.viewManagerEpoch=${dataRequest.viewManagerEpoch}
?details=${this.details}
>
</frigate-card-thumbnail>`;
@@ -182,12 +181,12 @@ export class FrigateCardTimelineCore extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false, hasChanged: contentsChanged })
public timelineConfig?: TimelineCoreConfig;
@property({ attribute: true, type: Boolean })
@property({ attribute: false })
public thumbnailConfig?: ThumbnailsControlBaseConfig;
// Whether or not this is a mini-timeline (in mini-mode the component takes a
@@ -269,11 +268,11 @@ export class FrigateCardTimelineCore extends LitElement {
request.detail.cameraConfig = cameraConfig;
request.detail.cameraManager = this.cameraManager;
request.detail.media = media;
request.detail.view = this.view;
request.detail.viewManagerEpoch = this.viewManagerEpoch;
}
protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this.timelineConfig || !this.cameraIDs?.size) {
if (!this.hass || !this.timelineConfig || !this.cameraIDs?.size) {
return;
}
@@ -383,6 +382,7 @@ export class FrigateCardTimelineCore extends LitElement {
return;
}
const view = this.viewManagerEpoch?.manager.getView();
const panMode = this._getEffectivePanMode();
const targetBarOn =
this._shouldSupportSeeking() &&
@@ -392,7 +392,7 @@ export class FrigateCardTimelineCore extends LitElement {
const item = this._timelineSource?.dataset?.get(id);
return (
panMode !== 'seek-in-camera' ||
item?.media?.getCameraID() === this.view?.camera,
item?.media?.getCameraID() === view?.camera,
item &&
item.start &&
item.end &&
@@ -450,14 +450,15 @@ export class FrigateCardTimelineCore extends LitElement {
targetTime: Date,
properties: TimelineRangeChange,
): Promise<void> {
const results = this.view?.queryResults;
const view = this.viewManagerEpoch?.manager.getView();
const results = view?.queryResults;
const media = results?.getResults();
const panMode = this._getEffectivePanMode();
if (
!media ||
!results ||
!this._timeline ||
!this.view ||
!view ||
!this.hass ||
!this.cameraManager ||
panMode === 'pan'
@@ -473,7 +474,7 @@ export class FrigateCardTimelineCore extends LitElement {
.clone()
.resetSelectedResult()
.selectBestResult(
(mediaArray) => findBestMediaIndex(mediaArray, targetTime, this.view?.camera),
(mediaArray) => findBestMediaIndex(mediaArray, targetTime, view?.camera),
{
allCameras: true,
main: true,
@@ -484,9 +485,9 @@ export class FrigateCardTimelineCore extends LitElement {
.clone()
.resetSelectedResult()
.selectBestResult((mediaArray) => findBestMediaIndex(mediaArray, targetTime), {
cameraID: this.view.camera,
cameraID: view.camera,
})
.promoteCameraSelectionToMainSelection(this.view.camera);
.promoteCameraSelectionToMainSelection(view.camera);
} else if (panMode === 'seek-in-media') {
newResults = results;
}
@@ -495,20 +496,23 @@ export class FrigateCardTimelineCore extends LitElement {
? targetTime >= new Date()
? 'live'
: 'media'
: this.view.view;
: view.view;
const selectedCamera = newResults?.getSelectedResult()?.getCameraID();
this.view
.evolve({
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
...(selectedCamera && { camera: selectedCamera }),
view: desiredView,
queryResults: newResults,
}) // Whether or not to set the timeline window.
.mergeInContext({
...(canSeek && { mediaViewer: { seek: targetTime } }),
...this._getTimelineContext({ start: properties.start, end: properties.end }),
})
.dispatchChangeEvent(this);
},
modifiers: [
new MergeContextViewModifier({
...(canSeek && { mediaViewer: { seek: targetTime } }),
...this._getTimelineContext({ start: properties.start, end: properties.end }),
}),
],
});
}
protected _getEffectivePanMode(): TimelinePanMode {
@@ -533,22 +537,18 @@ export class FrigateCardTimelineCore extends LitElement {
stopEventFromActivatingCardWideActions(properties.event);
}
const view = this.viewManagerEpoch?.manager.getView();
if (
this._ignoreClick ||
!this.hass ||
!this._timeline ||
!this.view ||
!this.cameraManager ||
!this.cardWideConfig ||
!this.cameraIDs ||
!this.cameraIDs.size ||
!view ||
!this.viewManagerEpoch ||
!this._timelineSource ||
!properties.what
) {
return;
}
let view: View | null = null;
let drawerAction: 'open' | 'close' = 'close';
if (
@@ -558,29 +558,35 @@ export class FrigateCardTimelineCore extends LitElement {
) {
const query = this._createMediaQueries('recording');
if (query) {
view = await executeMediaQueryForViewWithErrorDispatching(
this,
this.cameraManager,
this.view,
query,
{
targetView: 'recording',
targetTime: properties.time,
select: 'time',
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
baseView: view,
params: { view: 'recording', query: query },
queryExecutorOptions: {
selectResult: {
time: {
time: properties.time,
},
},
},
);
});
}
} else if (properties.item && properties.what === 'item') {
const cameraID = String(properties.group);
const id = String(properties.item);
const criteria = {
main: true,
...(cameraID && this.view.isGrid() && { cameraID: cameraID }),
...(cameraID && view.isGrid() && { cameraID: cameraID }),
};
const newResults = this.view.queryResults
const newResults = view.queryResults
?.clone()
.resetSelectedResult()
.selectResultIfFound((media) => media.getID() === properties.item, criteria);
const context: ViewContext = mergeViewContext(this._getTimelineContext(), {
mediaViewer: { seek: properties.time },
});
if (!newResults || !newResults.hasSelectedResult()) {
// This can happen in a few situations:
// - If this is a recording query (with recorded hours) and an event is
@@ -589,36 +595,34 @@ export class FrigateCardTimelineCore extends LitElement {
// gallery (i.e. any case where the thumbnails may not be match the
// events on the timeline, e.g. in the snapshots viewer but
// mini-timeline showing all media).
const fullEventView = await this._createViewWithMediaQueries(
this._createMediaQueries('event'),
{
selectedItem: properties.item,
targetView: 'media',
},
);
if (fullEventView?.queryResults?.hasResults()) {
view = fullEventView;
const query = this._createMediaQueries('event');
if (query) {
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: { view: 'media', query: query },
queryExecutorOptions: {
selectResult: {
id: id,
},
rejectResults: (results) => !results.hasResults(),
},
modifiers: [new MergeContextViewModifier(context)],
});
}
} else {
view = this.view.evolve({
queryResults: newResults,
view: this.itemClickAction === 'play' ? 'media' : this.view.view,
this.viewManagerEpoch.manager.setViewByParameters({
params: {
queryResults: newResults,
view: this.itemClickAction === 'play' ? 'media' : view.view,
},
modifiers: [new MergeContextViewModifier(context)],
});
}
if (view?.queryResults?.hasResults()) {
view.mergeInContext({ mediaViewer: { seek: properties.time } });
}
view?.mergeInContext(this._getTimelineContext());
if (this.itemClickAction === 'select' && view) {
if (this.itemClickAction === 'select') {
drawerAction = 'open';
}
}
if (view) {
view.dispatchChangeEvent(this);
}
dispatchFrigateCardEvent(this, `thumbnails:${drawerAction}`);
this._ignoreClick = false;
@@ -648,10 +652,11 @@ export class FrigateCardTimelineCore extends LitElement {
event: Event & { additionalEvent: string };
}): Promise<void> {
this._removeTargetBar();
const view = this.viewManagerEpoch?.manager.getView();
if (
!this._timeline ||
!this.view ||
!view ||
// When in mini mode, something else is in charge of the primary media
// population (e.g. the live view), in this case only act when the user
// themselves are interacting with the timeline.
@@ -662,23 +667,34 @@ export class FrigateCardTimelineCore extends LitElement {
await this._timelineSource?.refresh(this._getPrefetchWindow(properties));
const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
const queryType = MediaQueriesClassifier.getQueriesType(view.query);
if (!queryType) {
return;
}
const mediaQuery = this._createMediaQueries(queryType);
const newView = await this._createViewWithMediaQueries(mediaQuery);
// Specifically avoid dispatching new results on range change unless there
// is something to be gained by doing so. Example usecase: On initial view
// load in mini timeline mode, the first 50 events are fetched -- the
// first drag of the timeline should not dispatch new results unless
// something is actually useful (as otherwise it creates a visible
// 'flicker' for the user as the viewer reloads all the media).
const newResults = newView?.queryResults;
if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) {
newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this);
}
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
query: mediaQuery,
},
queryExecutorOptions: {
// Reject the new results unless there is something to be gained (i.e. they
// are not a subset of the existing results). Example usecase: On initial
// view load in mini timeline mode, the first 50 events are fetched -- the
// first drag of the timeline should not dispatch new results unless
// something is actually useful (as otherwise it creates a visible 'flicker'
// for the user as the viewer reloads all the media).
rejectResults: (results) => !!view.queryResults?.isSupersetOf(results),
selectResult: {
id:
this.viewManagerEpoch?.manager
.getView()
?.queryResults?.getSelectedResult()
?.getID() ?? undefined,
},
},
modifiers: [new MergeContextViewModifier(this._getTimelineContext())],
});
}
protected _createMediaQueries(
@@ -706,46 +722,6 @@ export class FrigateCardTimelineCore extends LitElement {
return null;
}
protected async _createViewWithMediaQueries(
query: MediaQueries | null,
options?: {
targetView?: FrigateCardView;
selectedItem?: IdType;
},
): Promise<View | null> {
if (!this.hass || !this.cameraManager || !this.view || !query) {
return null;
}
const view = await executeMediaQueryForViewWithErrorDispatching(
this,
this.cameraManager,
this.view,
query,
{
targetView: options?.targetView,
select: 'latest',
},
);
if (!view) {
return null;
}
if (options?.selectedItem) {
view.queryResults?.selectResultIfFound(
(media) => media.getID() === options.selectedItem,
);
} else {
// If not asked to select a new item, persist the currently selected item
// if possible.
const currentlySelectedResult = this.view.queryResults?.getSelectedResult();
if (currentlySelectedResult) {
view.queryResults?.selectResultIfFound(
(media) => media.getID() === currentlySelectedResult.getID(),
);
}
}
return view;
}
/**
* Build the visjs dataset to render on the timeline.
* @returns The dataset.
@@ -937,10 +913,11 @@ export class FrigateCardTimelineCore extends LitElement {
}
protected _getAllSelectedMediaIDsFromView(): IdType[] {
const view = this.viewManagerEpoch?.manager.getView();
return (
this.view?.queryResults?.getMultipleSelectedResults({
view?.queryResults?.getMultipleSelectedResults({
main: true,
...(this.view.isGrid() && { allCameras: true }),
...(view.isGrid() && { allCameras: true }),
}) ?? []
)
.filter((media) => ViewMediaClassifier.isEvent(media))
@@ -952,7 +929,8 @@ export class FrigateCardTimelineCore extends LitElement {
* Update the timeline from the view object.
*/
protected async _updateTimelineFromView(): Promise<void> {
if (!this.view || !this.timelineConfig || !this._timelineSource || !this._timeline) {
const view = this.viewManagerEpoch?.manager.getView();
if (!view || !this.timelineConfig || !this._timelineSource || !this._timeline) {
return;
}
@@ -965,7 +943,7 @@ export class FrigateCardTimelineCore extends LitElement {
// perfectly center on the media.
let desiredWindow = timelineWindow;
const media = this.view.queryResults?.getSelectedResult();
const media = view.queryResults?.getSelectedResult();
const mediaStartTime = media?.getStartTime() ?? null;
const mediaEndTime = media?.getEndTime() ?? null;
const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false;
@@ -976,7 +954,7 @@ export class FrigateCardTimelineCore extends LitElement {
// range effectively starts/ends at the same time.
{ start: mediaStartTime, end: mediaEndTime ?? mediaStartTime }
: null;
const context = this.view.context?.timeline;
const context = view.context?.timeline;
if (context && context.window) {
desiredWindow = context.window;
@@ -1048,7 +1026,7 @@ export class FrigateCardTimelineCore extends LitElement {
// Also don't generate thumbnails in mini-timelines (they will already have
// been generated).
const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
const queryType = MediaQueriesClassifier.getQueriesType(view.query);
if (!queryType) {
return;
}
@@ -1062,15 +1040,31 @@ export class FrigateCardTimelineCore extends LitElement {
freshMediaQuery &&
!this._alreadyHasAcceptableMediaQuery(freshMediaQuery)
) {
(await this._createViewWithMediaQueries(freshMediaQuery))
?.mergeInContext(this._getTimelineContext(desiredWindow))
.dispatchChangeEvent(this);
const currentlySelectedResult = this.viewManagerEpoch?.manager
.getView()
?.queryResults?.getSelectedResult();
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
query: freshMediaQuery,
},
queryExecutorOptions: {
selectResult: {
id: currentlySelectedResult?.getID() ?? undefined,
},
},
modifiers: [
new MergeContextViewModifier(this._getTimelineContext(desiredWindow)),
],
});
}
}
protected _alreadyHasAcceptableMediaQuery(freshMediaQuery: MediaQueries): boolean {
const currentQueries = this.view?.query?.getQueries();
const currentResultTimestamp = this.view?.queryResults?.getResultsTimestamp();
const view = this.viewManagerEpoch?.manager.getView();
const currentQueries = view?.query?.getQueries();
const currentResultTimestamp = view?.queryResults?.getResultsTimestamp();
return (
!!this.cameraManager &&
@@ -1089,10 +1083,11 @@ export class FrigateCardTimelineCore extends LitElement {
* @returns The TimelineViewContext object.
*/
protected _getTimelineContext(window?: TimelineWindow): ViewContext {
const view = this.viewManagerEpoch?.manager.getView();
const newWindow = window ?? this._timeline?.getWindow();
return {
timeline: {
...this.view?.context?.timeline,
...view?.context?.timeline,
...(newWindow && { window: newWindow }),
},
};
@@ -1225,7 +1220,7 @@ export class FrigateCardTimelineCore extends LitElement {
// `this._timeline.setwindow()` being entirely ignored. Example case:
// Clicking the timeline control on a recording thumbnail.
window.requestAnimationFrame(this._updateTimelineFromView.bind(this));
} else if (changedProperties.has('view')) {
} else if (changedProperties.has('viewManagerEpoch')) {
this._updateTimelineFromView();
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { CameraManager } from '../camera-manager/manager';
import { ViewManagerEpoch } from '../card-controller/view/types';
import { CardWideConfig, TimelineConfig } from '../config/types';
import basicBlockStyle from '../scss/basic-block.scss';
import { ExtendedHomeAssistant } from '../types';
import { View } from '../view/view';
import './surround.js';
import './timeline-core.js';
@@ -14,7 +14,7 @@ export class FrigateCardTimeline extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public timelineConfig?: TimelineConfig;
@@ -33,7 +33,7 @@ export class FrigateCardTimeline extends LitElement {
return html`
<frigate-card-timeline-core
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.timelineConfig=${this.timelineConfig}
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
.cameraManager=${this.cameraManager}
+1 -1
View File
@@ -10,7 +10,7 @@ type PaperToast = HTMLElement & {
};
export const getDefaultTitleConfigForView = (
view?: Readonly<View>,
view?: Readonly<View> | null,
baseConfig?: TitleControlConfig,
): TitleControlConfig | null => {
if (!baseConfig && view?.isGrid()) {
+120 -167
View File
@@ -11,6 +11,8 @@ 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 { RemoveContextPropertyViewModifier } from '../card-controller/view/modifiers/remove-context-property.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { MediaGridSelected } from '../components-lib/media-grid-controller.js';
import { ZoomSettingsObserved } from '../components-lib/zoom/types.js';
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context.js';
@@ -41,7 +43,6 @@ import { mayHaveAudio } from '../utils/audio.js';
import {
aspectRatioToString,
contentsChanged,
errorToConsole,
setOrRemoveAttribute,
} from '../utils/basic.js';
import { CarouselSelected } from '../utils/embla/carousel-controller.js';
@@ -58,10 +59,6 @@ import {
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,
@@ -71,9 +68,7 @@ import {
import { screenshotMedia } from '../utils/screenshot.js';
import { ViewMediaClassifier } from '../view/media-classifier';
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results.js';
import { VideoContentType, ViewMedia } from '../view/media.js';
import { View } from '../view/view.js';
import type { EmblaCarouselPlugins } from './carousel.js';
import './next-prev-control.js';
import './ptz';
@@ -110,7 +105,7 @@ export class FrigateCardViewer extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public viewerConfig?: ViewerConfig;
@@ -127,7 +122,7 @@ export class FrigateCardViewer extends LitElement {
protected render(): TemplateResult | void {
if (
!this.hass ||
!this.view ||
!this.viewManagerEpoch ||
!this.viewerConfig ||
!this.cameraManager ||
!this.cardWideConfig
@@ -135,55 +130,20 @@ export class FrigateCardViewer extends LitElement {
return;
}
if (!this.view.queryResults?.hasResults()) {
// If the query is not specified, the view must tell us which mediaType to
// search for. When the query *is* specified, the view is not required to
// indicate the media type (e.g. the mixed 'media' view from the
// timeline).
const mediaType = this.view.getDefaultMediaType();
if (!mediaType) {
// Directly render an error message (instead of dispatching it upwards)
// to preserve the mini-timeline if the user pans into an area with no
// media.
return renderMessage({
type: 'info',
message: localize('common.no_media'),
icon: 'mdi:multimedia',
});
}
if (mediaType === 'recordings') {
changeViewToRecentRecordingForCameraAndDependents(
this,
this.cameraManager,
this.cardWideConfig,
this.view,
{
allCameras: this.view.isGrid(),
targetView: 'recording',
useCache: false,
},
);
} else {
changeViewToRecentEventsForCameraAndDependents(
this,
this.cameraManager,
this.cardWideConfig,
this.view,
{
allCameras: this.view.isGrid(),
targetView: 'media',
eventsMediaType: mediaType,
useCache: false,
},
);
}
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
if (!this.viewManagerEpoch.manager.getView()?.queryResults?.hasResults()) {
// Directly render an error message (instead of dispatching it upwards)
// to preserve the mini-timeline if the user pans into an area with no
// media.
return renderMessage({
type: 'info',
message: localize('common.no_media'),
icon: 'mdi:multimedia',
});
}
return html` <frigate-card-viewer-grid
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
@@ -205,7 +165,7 @@ export class FrigateCardViewerCarousel extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public viewFilterCameraID?: string;
@@ -227,6 +187,9 @@ export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public showControls = true;
@state()
protected _selected = 0;
@@ -241,12 +204,14 @@ export class FrigateCardViewerCarousel extends LitElement {
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has('view')) {
const oldView = changedProperties.get('view') as View | undefined;
if (changedProperties.has('viewManagerEpoch')) {
// Seek into the video if the seek time has changed (this is also called
// on media load, since the media may or may not have been loaded at
// this point).
if (this.view?.context?.mediaViewer !== oldView?.context?.mediaViewer) {
if (
this.viewManagerEpoch?.manager.getView()?.context?.mediaViewer !==
this.viewManagerEpoch?.oldView?.context?.mediaViewer
) {
this._seekHandler();
}
}
@@ -324,7 +289,9 @@ export class FrigateCardViewerCarousel extends LitElement {
}
protected _setViewSelectedIndex(index: number): void {
if (!this._media) {
const view = this.viewManagerEpoch?.manager.getView();
if (!this._media || !view) {
return;
}
@@ -335,7 +302,7 @@ export class FrigateCardViewerCarousel extends LitElement {
return;
}
const newResults = this.view?.queryResults
const newResults = view?.queryResults
?.clone()
.selectIndex(index, this.viewFilterCameraID);
if (!newResults) {
@@ -345,15 +312,14 @@ export class FrigateCardViewerCarousel extends LitElement {
.getSelectedResult(this.viewFilterCameraID)
?.getCameraID();
this.view
?.evolve({
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
queryResults: newResults,
// Always change the camera to the owner of the selected media.
...(cameraID && { camera: cameraID }),
})
.removeContextProperty('mediaViewer', 'seek')
.dispatchChangeEvent(this);
},
modifiers: [new RemoveContextPropertyViewModifier('mediaViewer', 'seek')],
});
}
/**
@@ -395,17 +361,13 @@ export class FrigateCardViewerCarousel extends LitElement {
return slides;
}
/**
* Called when an update will occur.
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('view')) {
const newMedia =
this.view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
if (changedProps.has('viewManagerEpoch')) {
const view = this.viewManagerEpoch?.manager.getView();
const newMedia = view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
const newSelected =
this.view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0;
const newSeek = this.view?.context?.mediaViewer?.seek;
view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0;
const newSeek = view?.context?.mediaViewer?.seek;
if (newMedia !== this._media || newSelected !== this._selected || !newSeek) {
setOrRemoveAttribute(this, false, 'unseekable');
@@ -449,8 +411,9 @@ export class FrigateCardViewerCarousel extends LitElement {
selectedMedia.getCameraID(),
);
const view = this.viewManagerEpoch?.manager.getView();
const titleConfig = getDefaultTitleConfigForView(
this.view,
view,
this.viewerConfig?.controls.title,
);
@@ -474,38 +437,42 @@ export class FrigateCardViewerCarousel extends LitElement {
this._player = null;
}}
>
<frigate-card-next-previous-control
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${neighbors?.previous?.media.getThumbnail() ?? undefined}
.label=${neighbors?.previous?.media.getTitle() ?? ''}
?disabled=${!neighbors?.previous}
@click=${(ev: Event) => {
scroll('previous');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
${guard([this._media, this.view], () => this._getSlides())}
<frigate-card-next-previous-control
slot="next"
.hass=${this.hass}
.direction=${'next'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${neighbors?.next?.media.getThumbnail() ?? undefined}
.label=${neighbors?.next?.media.getTitle() ?? ''}
?disabled=${!neighbors?.next}
@click=${(ev: Event) => {
scroll('next');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>
${this.showControls
? html` <frigate-card-next-previous-control
slot="previous"
.hass=${this.hass}
.direction=${'previous'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${neighbors?.previous?.media.getThumbnail() ?? undefined}
.label=${neighbors?.previous?.media.getTitle() ?? ''}
?disabled=${!neighbors?.previous}
@click=${(ev: Event) => {
scroll('previous');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>`
: ''}
${guard([this._media, view], () => this._getSlides())}
${this.showControls
? html` <frigate-card-next-previous-control
slot="next"
.hass=${this.hass}
.direction=${'next'}
.controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${neighbors?.next?.media.getThumbnail() ?? undefined}
.label=${neighbors?.next?.media.getTitle() ?? ''}
?disabled=${!neighbors?.next}
@click=${(ev: Event) => {
scroll('next');
stopEventFromActivatingCardWideActions(ev);
}}
></frigate-card-next-previous-control>`
: ''}
</frigate-card-carousel>
${this.view
${view
? html` <frigate-card-ptz
.config=${this.viewerConfig?.controls.ptz}
.forceVisibility=${this.view?.context?.ptzControls?.enabled}
.forceVisibility=${view?.context?.ptzControls?.enabled}
>
</frigate-card-ptz>`
: ''}
@@ -530,7 +497,8 @@ export class FrigateCardViewerCarousel extends LitElement {
* Fire a media show event when a slide is selected.
*/
protected async _seekHandler(): Promise<void> {
const seek = this.view?.context?.mediaViewer?.seek;
const view = this.viewManagerEpoch?.manager.getView();
const seek = view?.context?.mediaViewer?.seek;
if (!this.hass || !seek || !this._media || !this._player) {
return;
}
@@ -556,14 +524,15 @@ export class FrigateCardViewerCarousel extends LitElement {
}
protected _renderMediaItem(media: ViewMedia): TemplateResult | null {
if (!this.hass || !this.view || !this.viewerConfig) {
const view = this.viewManagerEpoch?.manager.getView();
if (!this.hass || !view || !this.viewerConfig) {
return null;
}
return html` <div class="embla__slide">
<frigate-card-viewer-provider
.hass=${this.hass}
.view=${this.view}
.view=${view}
.media=${media}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
@@ -585,7 +554,7 @@ export class FrigateCardViewerGrid extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public viewerConfig?: ViewerConfig;
@@ -600,66 +569,64 @@ export class FrigateCardViewerGrid extends LitElement {
public cameraManager?: CameraManager;
protected _renderCarousel(filterCamera?: string): TemplateResult {
const selectedCameraID = this.viewManagerEpoch?.manager.getView()?.camera;
return html`
<frigate-card-viewer-carousel
grid-id=${ifDefined(filterCamera)}
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewFilterCameraID=${filterCamera}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
.showControls=${!filterCamera || selectedCameraID === filterCamera}
>
</frigate-card-viewer-carousel>
`;
}
protected _gridSelectCamera(cameraID: string, view?: View): void {
const newView = view ?? this.view;
const promotedQueryResults = newView?.queryResults
?.clone()
.promoteCameraSelectionToMainSelection(cameraID);
newView
?.evolve({
camera: cameraID,
queryResults: promotedQueryResults,
})
.dispatchChangeEvent(this);
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('view') && this._needsGrid()) {
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
import('./media-grid.js');
}
}
protected _needsGrid(): boolean {
const cameraIDs = this.view?.queryResults?.getCameraIDs();
const view = this.viewManagerEpoch?.manager.getView();
const cameraIDs = view?.queryResults?.getCameraIDs();
return (
!!this.view?.isGrid() &&
!!this.view?.supportsMultipleDisplayModes() &&
!!view?.isGrid() &&
!!view?.supportsMultipleDisplayModes() &&
(cameraIDs?.size ?? 0) > 1
);
}
protected _gridSelectCamera(cameraID: string): void {
const view = this.viewManagerEpoch?.manager.getView();
this.viewManagerEpoch?.manager.setViewByParameters({
params: {
camera: cameraID,
queryResults: view?.queryResults
?.clone()
.promoteCameraSelectionToMainSelection(cameraID),
},
});
}
protected render(): TemplateResult {
const cameraIDs = this.view?.queryResults?.getCameraIDs();
const view = this.viewManagerEpoch?.manager.getView();
const cameraIDs = view?.queryResults?.getCameraIDs();
if (!cameraIDs || !this._needsGrid()) {
return this._renderCarousel();
}
return html`
<frigate-card-media-grid
.selected=${this.view?.camera}
.selected=${view?.camera}
.displayConfig=${this.viewerConfig?.display}
@frigate-card:media-grid:selected=${(ev: CustomEvent<MediaGridSelected>) =>
this._gridSelectCamera(ev.detail.selected)}
@frigate-card:view:change=${(ev: CustomEvent<View>) => {
ev.stopPropagation();
const childView = ev.detail;
this._gridSelectCamera(childView.camera, childView);
}}
>
${[...cameraIDs].map((cameraID) => this._renderCarousel(cameraID))}
</frigate-card-media-grid>
@@ -680,7 +647,7 @@ export class FrigateCardViewerProvider
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public media?: ViewMedia;
@@ -788,21 +755,22 @@ export class FrigateCardViewerProvider
* Dispatch a clip view that matches the current (snapshot) query.
*/
protected async _dispatchRelatedClipView(): Promise<void> {
const view = this.viewManagerEpoch?.manager.getView();
if (
!this.hass ||
!this.view ||
!view ||
!this.cameraManager ||
!this.media ||
// If this specific media item has no clip, then do nothing (even if all
// the other media items do).
!ViewMediaClassifier.isEvent(this.media) ||
!MediaQueriesClassifier.areEventQueries(this.view.query)
!MediaQueriesClassifier.areEventQueries(view.query)
) {
return;
}
// Convert the query to a clips equivalent.
const clipQuery = this.view.query.clone();
const clipQuery = view.query.clone();
clipQuery.convertToClipsQueries();
const queries = clipQuery.getQueries();
@@ -810,32 +778,18 @@ export class FrigateCardViewerProvider
return;
}
let mediaArray: ViewMedia[] | null;
try {
mediaArray = await this.cameraManager.executeMediaQueries(queries);
} catch (e) {
errorToConsole(e as Error);
return;
}
if (!mediaArray) {
return;
}
const results = new MediaQueriesResults({ results: mediaArray });
results.selectResultIfFound(
(clipMedia) => clipMedia.getID() === this.media?.getID(),
);
if (!results.hasSelectedResult()) {
return;
}
this.view
.evolve({
await this.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
params: {
view: 'media',
query: clipQuery,
queryResults: results,
})
.dispatchChangeEvent(this);
},
queryExecutorOptions: {
selectResult: {
id: this.media.getID() ?? undefined,
},
rejectResults: (results) => !results.hasSelectedResult(),
},
});
}
protected willUpdate(changedProps: PropertyValues): void {
@@ -881,6 +835,7 @@ export class FrigateCardViewerProvider
const cameraID = this.media.getCameraID();
const mediaID = this.media.getID() ?? undefined;
const cameraConfig = this.cameraManager?.getStore().getCameraConfig(cameraID);
const view = this.viewManagerEpoch?.manager.getView();
return this.viewerConfig?.zoomable
? html` <frigate-card-zoomer
@@ -892,13 +847,11 @@ export class FrigateCardViewerProvider
}
: undefined,
)}
.settings=${mediaID
? this.view?.context?.zoom?.[mediaID]?.requested
: undefined}
.settings=${mediaID ? view?.context?.zoom?.[mediaID]?.requested : undefined}
@frigate-card:zoom:zoomed=${() => this.setControls(false)}
@frigate-card:zoom:unzoomed=${() => this.setControls()}
@frigate-card:zoom:change=${(ev: CustomEvent<ZoomSettingsObserved>) =>
handleZoomSettingsObservedEvent(this, ev, mediaID)}
handleZoomSettingsObservedEvent(ev, this.viewManagerEpoch?.manager, mediaID)}
>
${template}
</frigate-card-zoomer>`
@@ -906,7 +859,7 @@ export class FrigateCardViewerProvider
}
protected render(): TemplateResult | void {
if (!this.load || !this.media || !this.hass || !this.view || !this.viewerConfig) {
if (!this.load || !this.media || !this.hass || !this.viewerConfig) {
return;
}
+33 -30
View File
@@ -11,6 +11,7 @@ import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { ConditionsManagerEpoch } from '../card-controller/conditions-manager.js';
import { ReadonlyMicrophoneManager } from '../card-controller/microphone-manager.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import {
CardWideConfig,
FrigateCardConfig,
@@ -19,8 +20,6 @@ import {
import viewsStyle from '../scss/views.scss';
import { ExtendedHomeAssistant } from '../types.js';
import { ResolvedMediaCache } from '../utils/ha/resolved-media.js';
import { View } from '../view/view.js';
import './surround.js';
// As a special case: Diagnostics is not dynamically loaded in case something goes wrong.
import './diagnostics.js';
@@ -31,7 +30,7 @@ export class FrigateCardViews extends LitElement {
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
public viewManagerEpoch?: ViewManagerEpoch;
@property({ attribute: false })
public cameraManager?: CameraManager;
@@ -64,17 +63,18 @@ export class FrigateCardViews extends LitElement {
public triggeredCameraIDs?: Set<string>;
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('view') || changedProps.has('config')) {
if (this.view?.is('live') || this._shouldLivePreload()) {
if (changedProps.has('viewManagerEpoch') || changedProps.has('config')) {
const view = this.viewManagerEpoch?.manager.getView();
if (view?.is('live') || this._shouldLivePreload()) {
import('./live/live.js');
}
if (this.view?.isGalleryView()) {
if (view?.isGalleryView()) {
import('./gallery.js');
} else if (this.view?.isViewerView()) {
} else if (view?.isViewerView()) {
import('./viewer.js');
} else if (this.view?.is('image')) {
} else if (view?.is('image')) {
import('./image.js');
} else if (this.view?.is('timeline')) {
} else if (view?.is('timeline')) {
import('./timeline.js');
}
}
@@ -108,10 +108,11 @@ export class FrigateCardViews extends LitElement {
}
protected _shouldLivePreload(): boolean {
const view = this.viewManagerEpoch?.manager.getView();
return (
// Special case: Never preload for diagnostics -- we want that to be as
// minimal as possible.
!!this.overriddenConfig?.live.preload && !this.view?.is('diagnostics')
!!this.overriddenConfig?.live.preload && !view?.is('diagnostics')
);
}
@@ -129,67 +130,69 @@ export class FrigateCardViews extends LitElement {
return html``;
}
const view = this.viewManagerEpoch?.manager.getView();
// Render but hide the live view if there's a message, or if it's preload
// mode and the view is not live.
const liveClasses = {
hidden: this._shouldLivePreload() && !this.view?.is('live'),
hidden: this._shouldLivePreload() && !view?.is('live'),
};
const overallClasses = {
hidden: !!this.hide,
};
const thumbnailConfig = this.view?.is('live')
const thumbnailConfig = view?.is('live')
? this.overriddenConfig.live.controls.thumbnails
: this.view?.isViewerView()
: view?.isViewerView()
? this.overriddenConfig.media_viewer.controls.thumbnails
: this.view?.is('timeline')
: view?.is('timeline')
? this.overriddenConfig.timeline.controls.thumbnails
: undefined;
const miniTimelineConfig = this.view?.is('live')
const miniTimelineConfig = view?.is('live')
? this.overriddenConfig.live.controls.timeline
: this.view?.isViewerView()
: view?.isViewerView()
? this.overriddenConfig.media_viewer.controls.timeline
: undefined;
const cameraConfig = this.view
? this.cameraManager?.getStore().getCameraConfig(this.view.camera) ?? null
const cameraConfig = view
? this.cameraManager?.getStore().getCameraConfig(view.camera) ?? null
: null;
return html` <frigate-card-surround
class="${classMap(overallClasses)}"
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.thumbnailConfig=${!this.hide ? thumbnailConfig : undefined}
.timelineConfig=${!this.hide ? miniTimelineConfig : undefined}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
${!this.hide && this.view?.is('image') && cameraConfig
${!this.hide && view?.is('image') && cameraConfig
? html` <frigate-card-image
.imageConfig=${this.overriddenConfig.image}
.view=${this.view}
.view=${view}
.hass=${this.hass}
.cameraConfig=${cameraConfig}
.cameraManager=${this.cameraManager}
>
</frigate-card-image>`
: ``}
${!this.hide && this.view?.isGalleryView()
${!this.hide && view?.isGalleryView()
? html` <frigate-card-gallery
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.galleryConfig=${this.overriddenConfig.media_gallery}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-gallery>`
: ``}
${!this.hide && this.view?.isViewerView()
${!this.hide && view?.isViewerView()
? html`
<frigate-card-viewer
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.viewerConfig=${this.overriddenConfig.media_viewer}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
@@ -198,17 +201,17 @@ export class FrigateCardViews extends LitElement {
</frigate-card-viewer>
`
: ``}
${!this.hide && this.view?.is('timeline')
${!this.hide && view?.is('timeline')
? html` <frigate-card-timeline
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.timelineConfig=${this.overriddenConfig.timeline}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</frigate-card-timeline>`
: ``}
${!this.hide && this.view?.is('diagnostics')
${!this.hide && view?.is('diagnostics')
? html` <frigate-card-diagnostics
.hass=${this.hass}
.rawConfig=${this.rawConfig}
@@ -222,11 +225,11 @@ export class FrigateCardViews extends LitElement {
// Note: <frigate-card-live> uses nonOverriddenConfig rather than the
// overriden config as it does it's own overriding as part of the camera
// carousel.
this._shouldLivePreload() || (!this.hide && this.view?.is('live'))
this._shouldLivePreload() || (!this.hide && view?.is('live'))
? html`
<frigate-card-live
.hass=${this.hass}
.view=${this.view}
.viewManagerEpoch=${this.viewManagerEpoch}
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
.overriddenLiveConfig=${this.overriddenConfig.live}
.conditionsManagerEpoch=${this.conditionsManagerEpoch}
+17
View File
@@ -25,7 +25,11 @@ import {
CONF_OVERRIDES,
CONF_PROFILES,
CONF_TIMELINE_EVENTS_MEDIA_TYPE,
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
CONF_VIEW_INTERACTION_SECONDS,
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
CONF_VIEW_DEFAULT_RESET_ENTITIES,
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
CONF_VIEW_TRIGGERS,
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
@@ -805,4 +809,17 @@ const UPGRADES = [
keepOriginal: true,
}),
upgradeWithOverrides('live.controls.ptz', ptzControlSettingsTransform),
upgradeMoveToWithOverrides('view.update_cycle_camera', CONF_VIEW_DEFAULT_CYCLE_CAMERA),
upgradeMoveToWithOverrides(
'view.update_force',
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
{
transform: (val) => (val === true ? 'all' : null),
},
),
upgradeMoveToWithOverrides(
'view.update_seconds',
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
),
upgradeMoveToWithOverrides('view.update_entities', CONF_VIEW_DEFAULT_RESET_ENTITIES),
];
+4
View File
@@ -46,6 +46,7 @@ import {
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
CONF_TIMELINE_SHOW_RECORDINGS,
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
} from '../../const.js';
export const LOW_PERFORMANCE_PROFILE = {
@@ -134,4 +135,7 @@ export const LOW_PERFORMANCE_PROFILE = {
// Refresh the live camera image every 10 seconds (same as stock Home
// Assistant Picture Glance).
[CONF_CAMERAS_GLOBAL_IMAGE_REFRESH_SECONDS]: 10,
// No trigger actions.
[CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER]: 'none',
};
+34 -28
View File
@@ -863,10 +863,6 @@ const imageBaseConfigSchema = z.object({
const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const;
const imageConfigDefault = {
mode: 'url' as const,
zoomable: true,
controls: {
ptz: ptzControlsDefaults,
},
...imageBaseConfigDefault,
};
@@ -1396,17 +1392,19 @@ const viewConfigDefault = {
default: FRIGATE_CARD_VIEW_DEFAULT,
camera_select: 'current' as const,
interaction_seconds: 300,
reset_after_interaction: true,
update_seconds: 0,
update_force: false,
update_cycle_camera: false,
default_reset: {
every_seconds: 0,
after_interaction: false,
entities: [],
interaction_mode: 'inactive' as const,
},
default_cycle_camera: false,
dark_mode: 'off' as const,
triggers: {
show_trigger_status: false,
filter_selected_camera: true,
actions: {
interaction_mode: 'inactive' as const,
trigger: 'default' as const,
trigger: 'update' as const,
untrigger: 'none' as const,
},
untrigger_seconds: 0,
@@ -1414,27 +1412,27 @@ const viewConfigDefault = {
keyboard_shortcuts: keyboardShortcutsDefault,
};
export const triggersSchema = z.object({
filter_selected_camera: z
.boolean()
.default(viewConfigDefault.triggers.filter_selected_camera),
show_trigger_status: z
.boolean()
.default(viewConfigDefault.triggers.show_trigger_status),
const interactionModeSchema = z.enum(['all', 'inactive', 'active']).default('inactive');
export type InteractionMode = z.infer<typeof interactionModeSchema>;
export const triggersSchema = z.object({
actions: z
.object({
interaction_mode: z
.enum(['all', 'inactive', 'active'])
.default(viewConfigDefault.triggers.actions.interaction_mode),
interaction_mode: interactionModeSchema,
trigger: z
.enum(['live', 'default', 'media', 'none'])
.enum(['default', 'live', 'media', 'none', 'update'])
.default(viewConfigDefault.triggers.actions.trigger),
untrigger: z
.enum(['default', 'none'])
.default(viewConfigDefault.triggers.actions.untrigger),
})
.default(viewConfigDefault.triggers.actions),
filter_selected_camera: z
.boolean()
.default(viewConfigDefault.triggers.filter_selected_camera),
show_trigger_status: z
.boolean()
.default(viewConfigDefault.triggers.show_trigger_status),
untrigger_seconds: z.number().default(viewConfigDefault.triggers.untrigger_seconds),
});
export type TriggersOptions = z.infer<typeof triggersSchema>;
@@ -1448,13 +1446,21 @@ const viewConfigSchema = z
.enum([...FRIGATE_CARD_VIEWS_USER_SPECIFIED, 'current'])
.default(viewConfigDefault.camera_select),
interaction_seconds: z.number().default(viewConfigDefault.interaction_seconds),
reset_after_interaction: z
.boolean()
.default(viewConfigDefault.reset_after_interaction),
update_seconds: z.number().default(viewConfigDefault.update_seconds),
update_force: z.boolean().default(viewConfigDefault.update_force),
update_cycle_camera: z.boolean().default(viewConfigDefault.update_cycle_camera),
update_entities: z.string().array().optional(),
default_cycle_camera: z.boolean().default(viewConfigDefault.default_cycle_camera),
default_reset: z
.object({
after_interaction: z
.boolean()
.default(viewConfigDefault.default_reset.after_interaction),
every_seconds: z.number().default(viewConfigDefault.default_reset.every_seconds),
entities: z.string().array().default(viewConfigDefault.default_reset.entities),
interaction_mode: interactionModeSchema.default(
viewConfigDefault.default_reset.interaction_mode,
),
})
.default(viewConfigDefault.default_reset),
render_entities: z.string().array().optional(),
dark_mode: z.enum(['on', 'off', 'auto']).optional(),
triggers: triggersSchema.default(viewConfigDefault.triggers),
+11 -6
View File
@@ -123,11 +123,17 @@ export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT =
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_zoom_out` as const;
export const CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME =
`${CONF_VIEW_KEYBOARD_SHORTCUTS}.ptz_home` as const;
export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` as const;
export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const;
export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const;
export const CONF_VIEW_RESET_AFTER_INTERACTION =
`${CONF_VIEW}.reset_after_interaction` as const;
export const CONF_VIEW_DEFAULT_CYCLE_CAMERA =
`${CONF_VIEW}.default_cycle_camera` as const;
export const CONF_VIEW_DEFAULT_RESET = `${CONF_VIEW}.default_reset` as const;
export const CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE =
`${CONF_VIEW_DEFAULT_RESET}.interaction_mode` as const;
export const CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS =
`${CONF_VIEW_DEFAULT_RESET}.every_seconds` as const;
export const CONF_VIEW_DEFAULT_RESET_ENTITIES =
`${CONF_VIEW_DEFAULT_RESET}.entities` as const;
export const CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION =
`${CONF_VIEW_DEFAULT_RESET}.after_interaction` as const;
export const CONF_VIEW_TRIGGERS = `${CONF_VIEW}.triggers` as const;
export const CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS =
`${CONF_VIEW_TRIGGERS}.show_trigger_status` as const;
@@ -295,7 +301,6 @@ const CONF_IMAGE = 'image' as const;
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;
+56 -18
View File
@@ -84,7 +84,6 @@ import {
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,
@@ -201,7 +200,7 @@ import {
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP,
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN,
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT,
CONF_VIEW_RESET_AFTER_INTERACTION,
CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION,
CONF_VIEW_TRIGGERS,
CONF_VIEW_TRIGGERS_ACTIONS,
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
@@ -210,10 +209,12 @@ import {
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS,
CONF_VIEW_UPDATE_CYCLE_CAMERA,
CONF_VIEW_UPDATE_FORCE,
CONF_VIEW_UPDATE_SECONDS,
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
MEDIA_CHUNK_SIZE_MAX,
CONF_VIEW_DEFAULT_RESET,
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
CONF_VIEW_DEFAULT_RESET_ENTITIES,
} from './const.js';
import { localize } from './localize/localize.js';
import frigate_card_editor_style from './scss/editor.scss';
@@ -263,6 +264,7 @@ const MENU_PERFORMANCE_FEATURES = 'performance.features';
const MENU_PERFORMANCE_STYLE = 'performance.style';
const MENU_TIMELINE_CONTROLS_THUMBNAILS = 'timeline.controls.thumbnails';
const MENU_VIEW_KEYBOARD_SHORTCUTS = 'view.keyboard_shortcuts';
const MENU_VIEW_DEFAULT_RESET = 'view.default_reset';
const MENU_VIEW_TRIGGERS = 'view.triggers';
const MENU_VIEW_TRIGGERS_ACTIONS = 'view.triggers.actions';
@@ -813,6 +815,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
},
];
protected _defaultResetInteractionModes: EditorSelectOption[] = [
{ value: '', label: '' },
{
value: 'all',
label: localize('config.view.default_reset.interaction_modes.all'),
},
{
value: 'inactive',
label: localize('config.view.default_reset.interaction_modes.inactive'),
},
{
value: 'active',
label: localize('config.view.default_reset.interaction_modes.active'),
},
];
public setConfig(config: RawFrigateCardConfig): void {
// Note: This does not use Zod to parse the full configuration, so it may be
// partially or completely invalid. It's more useful to have a partially
@@ -1068,6 +1086,36 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
);
}
protected _renderViewDefaultResetMenu(): TemplateResult {
return this._putInSubmenu(
MENU_VIEW_DEFAULT_RESET,
true,
`config.${CONF_VIEW_DEFAULT_RESET}.editor_label`,
{ name: 'mdi:restart' },
html`
${this._renderSwitch(
CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION,
this._defaults.view.default_reset.after_interaction,
)}
${this._renderNumberInput(CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS)}
${this._renderOptionSelector(
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
this._defaultResetInteractionModes,
{
label: localize('config.view.default_reset.interaction_mode'),
},
)},
${this._renderOptionSelector(
CONF_VIEW_DEFAULT_RESET_ENTITIES,
this.hass ? getEntitiesFromHASS(this.hass) : [],
{
multiple: true,
},
)}
`,
);
}
protected _renderViewTriggersMenu(): TemplateResult {
return this._putInSubmenu(
MENU_VIEW_TRIGGERS,
@@ -2330,19 +2378,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderOptionSelector(CONF_VIEW_DARK_MODE, this._darkModes)}
${this._renderNumberInput(CONF_VIEW_INTERACTION_SECONDS)}
${this._renderSwitch(
CONF_VIEW_RESET_AFTER_INTERACTION,
this._defaults.view.reset_after_interaction,
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
this._defaults.view.default_cycle_camera,
)}
${this._renderNumberInput(CONF_VIEW_UPDATE_SECONDS)}
${this._renderSwitch(
CONF_VIEW_UPDATE_FORCE,
this._defaults.view.update_force,
)}
${this._renderSwitch(
CONF_VIEW_UPDATE_CYCLE_CAMERA,
this._defaults.view.update_cycle_camera,
)}
${this._renderViewTriggersMenu()}
${this._renderViewDefaultResetMenu()} ${this._renderViewTriggersMenu()}
${this._renderViewKeyboardShortcutMenu()}
</div>
`
@@ -2695,7 +2734,6 @@ 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)}
</div>`
: ''}
${this._renderOptionSetHeader('timeline')}
+21 -5
View File
@@ -436,12 +436,31 @@
"on": "Activat"
},
"default": "Vista per defecte",
"default_cycle_camera": "Passeu per les càmeres quan s'actualitzi la vista predeterminada",
"default_reset": {
"after_interaction": "Restableix la vista predeterminada després de la interacció de l'usuari",
"editor_label": "",
"entities": "",
"every_seconds": "Actualitza la vista predeterminada cada X segons (0=mai)",
"interaction_mode": "",
"interaction_modes": {
"active": "",
"all": "",
"inactive": ""
}
},
"interaction_seconds": "Segons després de l'acció de l'usuari per continuar interactuant (0=mai)",
"keyboard_shortcuts": {
"editor_label": "",
"enabled": ""
"enabled": "",
"ptz_down": "",
"ptz_home": "",
"ptz_left": "",
"ptz_right": "",
"ptz_up": "",
"ptz_zoom_in": "",
"ptz_zoom_out": ""
},
"reset_after_interaction": "Restableix la vista predeterminada després de la interacció de l'usuari",
"triggers": {
"actions": {
"editor_label": "Activar accions",
@@ -469,9 +488,6 @@
"show_trigger_status": "Mostra la vora intermitent quan s'activa",
"untrigger_seconds": "Segons després del canvi d'estat inactiu a desactivat"
},
"update_cycle_camera": "Passeu per les càmeres quan s'actualitzi la vista predeterminada",
"update_force": "Força les actualitzacions de la targeta (ignora la interacció de l'usuari)",
"update_seconds": "Actualitza la vista predeterminada cada X segons (0=mai)",
"views": {
"clip": "Clip més recent",
"clips": "Galeria de clips",
+15 -6
View File
@@ -362,7 +362,6 @@
"camera_ui": "Camera user interface",
"cameras": "Cameras",
"clips": "Clips",
"ptz_home": "PTZ Home",
"display_mode": "Display mode",
"download": "Download",
"enabled": "Button enabled",
@@ -378,6 +377,7 @@
"play": "Play / Pause",
"priority": "Priority",
"ptz_controls": "Show PTZ controls",
"ptz_home": "PTZ Home",
"recordings": "Recordings",
"screenshot": "Screenshot",
"snapshots": "Snapshots",
@@ -436,6 +436,19 @@
"on": "On"
},
"default": "Default view",
"default_cycle_camera": "Cycle through cameras when default view updates",
"default_reset": {
"after_interaction": "Reset to the default view after user interaction ends",
"editor_label": "Default view reset behavior",
"entities": "Reset to the default view on entity state change",
"every_seconds": "Reset to default view every X seconds (0=never)",
"interaction_mode": "How default reset behaves when the card has human interaction",
"interaction_modes": {
"active": "Only allow reset when card has active human interaction",
"all": "Reset regardless of human interaction",
"inactive": "Only reset when card has no human interaction"
}
},
"interaction_seconds": "Seconds after user action to remain interacted with (0=never)",
"keyboard_shortcuts": {
"editor_label": "Keyboard shortcuts",
@@ -448,7 +461,6 @@
"ptz_zoom_in": "PTZ Zoom In",
"ptz_zoom_out": "PTZ Zoom Out"
},
"reset_after_interaction": "Reset to the default view after user interaction",
"triggers": {
"actions": {
"editor_label": "Trigger actions",
@@ -471,14 +483,11 @@
"none": "No action"
}
},
"editor_label": "Behavior when a camera is triggered",
"editor_label": "Trigger behavior",
"filter_selected_camera": "Only trigger on selected camera",
"show_trigger_status": "Show pulsing border when triggered",
"untrigger_seconds": "Seconds after inactive state change to untrigger"
},
"update_cycle_camera": "Cycle through cameras when default view updates",
"update_force": "Force card updates (ignore user interaction)",
"update_seconds": "Refresh default view every X seconds (0=never)",
"views": {
"clip": "Most recent clip",
"clips": "Clips gallery",
+21 -5
View File
@@ -436,12 +436,31 @@
"on": "Activé"
},
"default": "Vue par défaut",
"default_cycle_camera": "Parcourez les caméras lorsque la vue par défaut est mise à jour",
"default_reset": {
"after_interaction": "",
"editor_label": "",
"entities": "",
"every_seconds": "Actualiser la vue par défaut toutes les X secondes (0=jamais)",
"interaction_mode": "",
"interaction_modes": {
"active": "",
"all": "",
"inactive": ""
}
},
"interaction_seconds": "",
"keyboard_shortcuts": {
"editor_label": "",
"enabled": ""
"enabled": "",
"ptz_down": "",
"ptz_home": "",
"ptz_left": "",
"ptz_right": "",
"ptz_up": "",
"ptz_zoom_in": "",
"ptz_zoom_out": ""
},
"reset_after_interaction": "",
"triggers": {
"actions": {
"editor_label": "",
@@ -469,9 +488,6 @@
"show_trigger_status": "Afficher la bordure clignotante lors du déclenchement",
"untrigger_seconds": "Quelques secondes après le changement d'état inactif pour débloquer"
},
"update_cycle_camera": "Parcourez les caméras lorsque la vue par défaut est mise à jour",
"update_force": "Forcer les mises à jour de la carte (ignorer l'interaction de l'utilisateur)",
"update_seconds": "Actualiser la vue par défaut toutes les X secondes (0=jamais)",
"views": {
"clip": "Clip le plus récent",
"clips": "Galerie de clips",
+21 -5
View File
@@ -436,12 +436,31 @@
"on": "On"
},
"default": "Visualizzazione predefinita",
"default_cycle_camera": "Scorri le telecamere quando si aggiorna la visualizzazione predefinita",
"default_reset": {
"after_interaction": "",
"editor_label": "",
"entities": "",
"every_seconds": "Aggiorna la visualizzazione predefinita ogni x secondi (0 = mai)",
"interaction_mode": "",
"interaction_modes": {
"active": "",
"all": "",
"inactive": ""
}
},
"interaction_seconds": "",
"keyboard_shortcuts": {
"editor_label": "",
"enabled": ""
"enabled": "",
"ptz_down": "",
"ptz_home": "",
"ptz_left": "",
"ptz_right": "",
"ptz_up": "",
"ptz_zoom_in": "",
"ptz_zoom_out": ""
},
"reset_after_interaction": "",
"triggers": {
"actions": {
"editor_label": "",
@@ -469,9 +488,6 @@
"show_trigger_status": "Mostra bordo pulsante quando attivato",
"untrigger_seconds": "Reimposta la vista ai valori predefiniti dopo aver annullato l'attivazione"
},
"update_cycle_camera": "Scorri le telecamere quando si aggiorna la visualizzazione predefinita",
"update_force": "Aggiornamenti della scheda forza (ignora l'interazione dell'utente)",
"update_seconds": "Aggiorna la visualizzazione predefinita ogni x secondi (0 = mai)",
"views": {
"clip": "Clip più recente",
"clips": "Galleria delle clip",
+21 -5
View File
@@ -436,12 +436,31 @@
"on": "Ligado"
},
"default": "Visualização padrão",
"default_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada",
"default_reset": {
"after_interaction": "",
"editor_label": "",
"entities": "",
"every_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)",
"interaction_mode": "",
"interaction_modes": {
"active": "",
"all": "",
"inactive": ""
}
},
"interaction_seconds": "",
"keyboard_shortcuts": {
"editor_label": "",
"enabled": ""
"enabled": "",
"ptz_down": "",
"ptz_home": "",
"ptz_left": "",
"ptz_right": "",
"ptz_up": "",
"ptz_zoom_in": "",
"ptz_zoom_out": ""
},
"reset_after_interaction": "",
"triggers": {
"actions": {
"editor_label": "",
@@ -469,9 +488,6 @@
"show_trigger_status": "Pulsar borda quando acionado",
"untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar"
},
"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 usuário)",
"update_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)",
"views": {
"clip": "Clipe mais recente",
"clips": "Galeria de clipes",
+21 -5
View File
@@ -436,12 +436,31 @@
"on": "Ligado"
},
"default": "Visualização padrão",
"default_cycle_camera": "Percorrer as câmeras quando a visualização padrão for atualizada",
"default_reset": {
"after_interaction": "",
"editor_label": "",
"entities": "",
"every_seconds": "Atualize a visualização padrão a cada X segundos (0 = nunca)",
"interaction_mode": "",
"interaction_modes": {
"active": "",
"all": "",
"inactive": ""
}
},
"interaction_seconds": "",
"keyboard_shortcuts": {
"editor_label": "",
"enabled": ""
"enabled": "",
"ptz_down": "",
"ptz_home": "",
"ptz_left": "",
"ptz_right": "",
"ptz_up": "",
"ptz_zoom_in": "",
"ptz_zoom_out": ""
},
"reset_after_interaction": "",
"triggers": {
"actions": {
"editor_label": "",
@@ -469,9 +488,6 @@
"show_trigger_status": "Exibir estado do gatilho",
"untrigger_seconds": "Segundos após a mudar para o estado inativo para desacionar"
},
"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",
+1 -1
View File
@@ -39,7 +39,7 @@ export function createGeneralAction(
options?: {
cardID?: string;
},
): FrigateCardCustomAction | null {
): FrigateCardCustomAction {
return {
action: 'fire-dom-event',
frigate_card_action: action,
+48
View File
@@ -0,0 +1,48 @@
import { ViewMedia } from '../view/media';
/**
* Find the longest matching media object that contains a given targetTime.
* Longest is chosen to give the most stability to the media viewer.
* @param mediaArray The media.
* @param targetTime The target time used to find the relevant child.
* @returns The childindex or null if no matching child is found.
*/
export const findBestMediaIndex = (
mediaArray: ViewMedia[],
targetTime: Date,
favorCameraID?: string,
): number | null => {
let bestMatch:
| {
index: number;
duration: number;
cameraID: string;
}
| undefined;
for (const [i, media] of mediaArray.entries()) {
const start = media.getStartTime();
const end = media.getUsableEndTime();
if (media.includesTime(targetTime) && start && end) {
const duration = end.getTime() - start.getTime();
if (
// No best match so far ...
!bestMatch ||
// ... or there is a best-match, but it's from a non-favored camera (unlike this one) ...
(favorCameraID &&
bestMatch.cameraID !== favorCameraID &&
media.getCameraID() === favorCameraID) ||
// ... or this match is longer and either there's no favored camera or this is it.
(duration > bestMatch.duration &&
(!favorCameraID ||
bestMatch.cameraID !== favorCameraID ||
media.getCameraID() === favorCameraID))
) {
bestMatch = { index: i, duration: duration, cameraID: media.getCameraID() };
}
}
}
return bestMatch ? bestMatch.index : null;
};
+2
View File
@@ -24,6 +24,8 @@ import {
SubscriptionUnsubscribe,
} from './types.js';
export type DestroyCallback = () => Promise<void>;
/**
* Make a HomeAssistant websocket request. May throw.
* @param hass The HomeAssistant object to send the request with.
+15
View File
@@ -0,0 +1,15 @@
import { InteractionMode } from '../config/types';
export const isActionAllowedBasedOnInteractionState = (
interactionMode: InteractionMode,
interactionState: boolean,
): boolean => {
switch (interactionMode) {
case 'all':
return true;
case 'active':
return interactionState;
case 'inactive':
return !interactionState;
}
};
-282
View File
@@ -1,282 +0,0 @@
import { ViewContext } from 'view';
import { CameraManager } from '../camera-manager/manager';
import { CapabilitySearchOptions, MediaQuery } from '../camera-manager/types';
import { dispatchFrigateCardErrorEvent } from '../components/message';
import { CardWideConfig, FrigateCardView } from '../config/types';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const';
import { ClipsOrSnapshotsOrAll } from '../types';
import { ViewMedia } from '../view/media';
import {
EventMediaQueries,
MediaQueries,
RecordingMediaQueries,
} from '../view/media-queries';
import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view';
import { errorToConsole } from './basic';
type ResultSelectType = 'latest' | 'time' | 'none';
export const changeViewToRecentEventsForCameraAndDependents = async (
element: HTMLElement,
cameraManager: CameraManager,
cardWideConfig: CardWideConfig,
view: View,
options?: {
allCameras?: boolean;
eventsMediaType?: ClipsOrSnapshotsOrAll;
targetView?: FrigateCardView;
select?: ResultSelectType;
useCache?: boolean;
viewContext?: ViewContext;
},
): Promise<void> => {
const capabilitySearch: CapabilitySearchOptions =
!options?.eventsMediaType || options?.eventsMediaType === 'all'
? {
anyCapabilities: ['clips', 'snapshots'],
}
: options.eventsMediaType;
const cameraIDs = options?.allCameras
? cameraManager.getStore().getCameraIDsWithCapability(capabilitySearch)
: cameraManager.getStore().getAllDependentCameras(view.camera, capabilitySearch);
if (!cameraIDs.size) {
return;
}
const queries = createQueriesForEventsView(cameraManager, cardWideConfig, cameraIDs, {
eventsMediaType: options?.eventsMediaType,
});
if (!queries) {
return;
}
(
await executeMediaQueryForViewWithErrorDispatching(
element,
cameraManager,
view,
queries,
{
targetView: options?.targetView,
select: options?.select,
useCache: options?.useCache,
viewContext: options?.viewContext,
},
)
)?.dispatchChangeEvent(element);
};
const createQueriesForEventsView = (
cameraManager: CameraManager,
cardWideConfig: CardWideConfig,
cameraIDs: Set<string>,
options?: {
eventsMediaType?: ClipsOrSnapshotsOrAll;
},
): EventMediaQueries | null => {
const limit =
cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT;
const eventQueries = cameraManager.generateDefaultEventQueries(cameraIDs, {
limit: limit,
...(options?.eventsMediaType === 'clips' && { hasClip: true }),
...(options?.eventsMediaType === 'snapshots' && { hasSnapshot: true }),
});
return eventQueries ? new EventMediaQueries(eventQueries) : null;
};
/**
* Change the view to a recent recording.
* @param element The element to dispatch the view change from.
* @param hass The Home Assistant object.
* @param cameraManager The datamanager to use for data access.
* @param cameras The camera configurations.
* @param view The current view.
* @param options A set of cameraIDs to fetch recordings for, and a targetView to dispatch to.
*/
export const changeViewToRecentRecordingForCameraAndDependents = async (
element: HTMLElement,
cameraManager: CameraManager,
cardWideConfig: CardWideConfig,
view: View,
options?: {
allCameras?: boolean;
targetView?: FrigateCardView;
select?: ResultSelectType;
useCache?: boolean;
viewContext?: ViewContext;
},
): Promise<void> => {
const cameraIDs = options?.allCameras
? cameraManager.getStore().getCameraIDsWithCapability('recordings')
: cameraManager.getStore().getAllDependentCameras(view.camera, 'recordings');
if (!cameraIDs.size) {
return;
}
const queries = createQueriesForRecordingsView(
cameraManager,
cardWideConfig,
cameraIDs,
);
if (!queries) {
return;
}
(
await executeMediaQueryForViewWithErrorDispatching(
element,
cameraManager,
view,
queries,
{
targetView: options?.targetView,
select: options?.select,
useCache: options?.useCache,
viewContext: options?.viewContext,
},
)
)?.dispatchChangeEvent(element);
};
const createQueriesForRecordingsView = (
cameraManager: CameraManager,
cardWideConfig: CardWideConfig,
cameraIDs: Set<string>,
): RecordingMediaQueries | null => {
const limit =
cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT;
const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
limit: limit,
});
return recordingQueries ? new RecordingMediaQueries(recordingQueries) : null;
};
export const executeMediaQueryForView = async (
cameraManager: CameraManager,
view: View,
query: MediaQueries,
options?: {
targetCameraID?: string;
targetView?: FrigateCardView;
targetTime?: Date;
select?: ResultSelectType;
useCache?: boolean;
viewContext?: ViewContext;
},
): Promise<View | null> => {
const queries = query.getQueries();
if (!queries) {
return null;
}
const mediaArray = await cameraManager.executeMediaQueries<MediaQuery>(queries, {
useCache: options?.useCache,
});
if (!mediaArray) {
return null;
}
const queryResults = new MediaQueriesResults({ results: mediaArray });
let viewerContext: ViewContext | undefined = {};
const cameraID = options?.targetCameraID ?? view.camera;
if (options?.select === 'time' && options?.targetTime) {
queryResults.selectBestResult((media) =>
findBestMediaIndex(media, options.targetTime as Date, cameraID),
);
viewerContext = {
mediaViewer: {
seek: options.targetTime,
},
};
}
return view
.evolve({
query: query,
queryResults: queryResults,
view: options?.targetView,
camera: cameraID,
})
.mergeInContext(options?.viewContext)
.mergeInContext(viewerContext);
};
export const executeMediaQueryForViewWithErrorDispatching = async (
element: HTMLElement,
cameraManager: CameraManager,
view: View,
query: MediaQueries,
options?: {
targetCameraID?: string;
targetView?: FrigateCardView;
targetTime?: Date;
select?: ResultSelectType;
useCache?: boolean;
viewContext?: ViewContext;
},
): Promise<View | null> => {
try {
return await executeMediaQueryForView(cameraManager, view, query, {
targetCameraID: options?.targetCameraID,
targetView: options?.targetView,
targetTime: options?.targetTime,
select: options?.select,
useCache: options?.useCache,
viewContext: options?.viewContext,
});
} catch (e: unknown) {
errorToConsole(e as Error);
dispatchFrigateCardErrorEvent(element, e as Error);
}
return null;
};
/**
* Find the longest matching media object that contains a given targetTime.
* Longest is chosen to give the most stability to the media viewer.
* @param mediaArray The media.
* @param targetTime The target time used to find the relevant child.
* @returns The childindex or null if no matching child is found.
*/
export const findBestMediaIndex = (
mediaArray: ViewMedia[],
targetTime: Date,
favorCameraID?: string,
): number | null => {
let bestMatch:
| {
index: number;
duration: number;
cameraID: string;
}
| undefined;
for (const [i, media] of mediaArray.entries()) {
const start = media.getStartTime();
const end = media.getUsableEndTime();
if (media.includesTime(targetTime) && start && end) {
const duration = end.getTime() - start.getTime();
if (
// No best match so far ...
!bestMatch ||
// ... or there is a best-match, but it's from a non-favored camera (unlike this one) ...
(favorCameraID &&
bestMatch.cameraID !== favorCameraID &&
media.getCameraID() === favorCameraID) ||
// ... or this match is longer and either there's no favored camera or this is it.
(duration > bestMatch.duration &&
(!favorCameraID ||
bestMatch.cameraID !== favorCameraID ||
media.getCameraID() === favorCameraID))
) {
bestMatch = { index: i, duration: duration, cameraID: media.getCameraID() };
}
}
}
return bestMatch ? bestMatch.index : null;
};
+15
View File
@@ -7,3 +7,18 @@ export const getStreamCameraID = (view: View, cameraID?: string): string => {
export const hasSubstream = (view: View): boolean => {
return getStreamCameraID(view) !== view.camera;
};
export const setSubstream = (view: View, substreamID: string): void => {
const overrides: Map<string, string> = view.context?.live?.overrides ?? new Map();
overrides.set(view.camera, substreamID);
view.mergeInContext({
live: { overrides: overrides },
});
};
export const removeSubstream = (view: View): void => {
const overrides: Map<string, string> | undefined = view.context?.live?.overrides;
if (overrides && overrides.has(view.camera)) {
view.context?.live?.overrides?.delete(view.camera);
}
};
+9 -152
View File
@@ -1,11 +1,8 @@
import merge from 'lodash-es/merge';
import { ViewContext } from 'view';
import { FrigateCardView, ViewDisplayMode } from '../config/types.js';
import { ClipsOrSnapshots } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { MediaQueries } from './media-queries';
import { MediaQueriesClassifier } from './media-queries-classifier.js';
import { MediaQueriesResults } from './media-queries-results';
import merge from 'lodash-es/merge';
interface ViewEvolveParameters {
view?: FrigateCardView;
@@ -21,6 +18,13 @@ export interface ViewParameters extends ViewEvolveParameters {
camera: string;
}
export const mergeViewContext = (
a?: ViewContext | null,
b?: ViewContext | null,
): ViewContext => {
return merge({}, a, b);
};
export class View {
public view: FrigateCardView;
public camera: string;
@@ -38,109 +42,6 @@ export class View {
this.displayMode = params.displayMode ?? null;
}
/**
* Detect if a view change represents a major "media change" for the given
* view.
* @param prev The previous view.
* @param curr The current view.
* @returns True if the view change is a real media change.
*/
public static isMajorMediaChange(prev?: View | null, curr?: View): boolean {
return (
!prev ||
!curr ||
prev.view !== curr.view ||
prev.camera !== curr.camera ||
// When in live mode, take overrides (substreams) into account in deciding
// if this is a major media change.
(curr.view === 'live' &&
prev.context?.live?.overrides?.get(prev.camera) !==
curr.context?.live?.overrides?.get(curr.camera)) ||
// When in the live view, the queryResults contain the events that
// happened in the past -- not reflective of the actual live media viewer
// the user is seeing.
(curr.view !== 'live' && prev.queryResults !== curr.queryResults)
);
}
public static adoptFromViewIfAppropriate(next: View, curr?: View | null): void {
if (!curr) {
return;
}
// In certain cases it may make sense to adopt parameters from a prior view.
//
// * Case #1: If the user is currently using the viewer, and then switches
// to the gallery we make an attempt to keep the query/queryResults the
// same so the gallery can be used to click back and forth to the viewer,
// and the selected media can be centered in the gallery. See the matching
// code in `updated()` in `gallery.ts`. We specifically must ensure that
// the new target media of the gallery (e.g. clips, snapshots or
// recordings) is equal to the queries that are currently used in the
// viewer. See:
// https://github.com/dermotduffy/frigate-hass-card/issues/885
//
// * Case #2: If the user is looking at media in the `media` view and then
// 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)) {
const queries = curr.query.getQueries();
if (
queries?.every((query) => query.hasClip) ||
queries?.every(
(query) => query.hasClip === undefined && query.hasSnapshot === undefined,
)
) {
currentQueriesView = 'clips';
} else if (queries?.every((query) => query.hasSnapshot)) {
currentQueriesView = 'snapshots';
}
} else if (MediaQueriesClassifier.areRecordingQueries(curr.query)) {
currentQueriesView = 'recordings';
}
const hasNoQueryOrResults = !next.query || !next.queryResults;
const switchingToGalleryFromViewer =
curr.isViewerView() && next.isGalleryView() && next.view === currentQueriesView;
const switchingToMediaFromMedia = curr?.is('media') && next.is('media');
if (hasNoQueryOrResults) {
if (switchingToGalleryFromViewer && curr.query && curr.queryResults) {
next.query = curr.query;
next.queryResults = curr.queryResults;
} else if (switchingToMediaFromMedia && currentQueriesView) {
next.view =
currentQueriesView === 'clips'
? 'clip'
: currentQueriesView === 'snapshots'
? 'snapshot'
: '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 });
}
}
/**
* Clone a view.
*/
public clone(): View {
return new View({
view: this.view,
@@ -178,7 +79,7 @@ export class View {
* @returns This view.
*/
public mergeInContext(context?: ViewContext | null): View {
this.context = merge({}, this.context, context);
this.context = mergeViewContext(this.context, context);
return this;
}
@@ -259,48 +160,4 @@ export class View {
public isGrid(): boolean {
return this.displayMode === 'grid';
}
/**
* Dispatch an event to request a view change.
* @param target The target dispatching the event.
*/
public dispatchChangeEvent(target: EventTarget): void {
dispatchFrigateCardEvent(target, 'view:change', this);
}
}
// Facilitates correct typing of event handlers.
export interface FrigateCardViewChangeEventTarget extends EventTarget {
addEventListener(
event: 'frigate-card:view:change',
listener: (this: FrigateCardViewChangeEventTarget, ev: CustomEvent<View>) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): void;
removeEventListener(
event: 'frigate-card:view:change',
listener: (this: FrigateCardViewChangeEventTarget, ev: CustomEvent<View>) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
/**
* Dispatch an event to change the view context.
* @param target The EventTarget to send the event from.
* @param context The context to change.
*/
export const dispatchViewContextChangeEvent = (
target: EventTarget,
context: ViewContext | null,
): void => {
dispatchFrigateCardEvent(target, 'view:change-context', context);
};
@@ -19,10 +19,12 @@ describe('should handle camera_select action', () => {
await action.execute(api);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith(
expect.objectContaining({
viewName: 'live',
cameraID: 'camera',
params: {
view: 'live',
camera: 'camera',
},
failSafe: true,
}),
);
@@ -49,10 +51,12 @@ describe('should handle camera_select action', () => {
await action.execute(api);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith(
expect.objectContaining({
viewName: 'timeline',
cameraID: 'camera',
params: {
view: 'timeline',
camera: 'camera',
},
failSafe: true,
}),
);
@@ -86,10 +90,12 @@ describe('should handle camera_select action', () => {
await action.execute(api);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith(
expect.objectContaining({
viewName: 'clips',
cameraID: 'camera',
params: {
view: 'clips',
camera: 'camera',
},
failSafe: true,
}),
);
@@ -114,10 +120,12 @@ describe('should handle camera_select action', () => {
await action.execute(api);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith(
expect.objectContaining({
viewName: 'live',
cameraID: 'camera',
params: {
view: 'live',
camera: 'camera',
},
failSafe: true,
}),
);
@@ -141,7 +149,7 @@ describe('should handle camera_select action', () => {
await action.execute(api);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('without a current view', async () => {
@@ -158,6 +166,6 @@ describe('should handle camera_select action', () => {
await action.execute(api);
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
});
@@ -15,5 +15,9 @@ it('should handle default action', async () => {
await action.execute(api);
expect(api.getViewManager().setViewWithNewDisplayMode).toBeCalledWith('grid');
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
params: {
displayMode: 'grid',
},
});
});
@@ -1,6 +1,7 @@
import { expect, it } from 'vitest';
import { SubstreamOffAction } from '../../../../src/card-controller/actions/actions/substream-off';
import { createCardAPI } from '../../../test-utils';
import { SubstreamOffViewModifier } from '../../../../src/card-controller/view/modifiers/substream-off';
it('should handle live_substream_off action', async () => {
const api = createCardAPI();
@@ -14,5 +15,7 @@ it('should handle live_substream_off action', async () => {
await action.execute(api);
expect(api.getViewManager().setViewWithoutSubstream).toBeCalled();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamOffViewModifier)],
});
});
@@ -1,6 +1,7 @@
import { expect, it } from 'vitest';
import { SubstreamOnAction } from '../../../../src/card-controller/actions/actions/substream-on';
import { createCardAPI } from '../../../test-utils';
import { SubstreamOnViewModifier } from '../../../../src/card-controller/view/modifiers/substream-on';
it('should handle live_substream_on action', async () => {
const api = createCardAPI();
@@ -14,5 +15,7 @@ it('should handle live_substream_on action', async () => {
await action.execute(api);
expect(api.getViewManager().setViewWithSubstream).toBeCalledWith();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
modifiers: [expect.any(SubstreamOnViewModifier)],
});
});
@@ -1,6 +1,7 @@
import { expect, it } from 'vitest';
import { SubstreamSelectAction } from '../../../../src/card-controller/actions/actions/substream-select';
import { createCardAPI } from '../../../test-utils';
import { SubstreamSelectViewModifier } from '../../../../src/card-controller/view/modifiers/substream-select';
it('should handle live_substream_select action', async () => {
const api = createCardAPI();
@@ -15,5 +16,9 @@ it('should handle live_substream_select action', async () => {
await action.execute(api);
expect(api.getViewManager().setViewWithSubstream).toBeCalledWith('substream');
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect.objectContaining({
modifiers: expect.arrayContaining([expect.any(SubstreamSelectViewModifier)]),
}),
);
});
@@ -27,9 +27,11 @@ describe('should handle view action', () => {
await action.execute(api);
expect(api.getViewManager().setViewByParameters).toBeCalledWith(
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith(
expect.objectContaining({
viewName: viewName,
params: {
view: viewName,
},
}),
);
});
@@ -1,73 +0,0 @@
import { add } from 'date-fns';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AutoUpdateManager } from '../../src/card-controller/auto-update-manager';
import { createCardAPI, createConfig } from '../test-utils';
// @vitest-environment jsdom
describe('AutoUpdateManager', () => {
const start = new Date('2023-09-23T19:12:00');
afterEach(() => {
vi.useRealTimers();
});
it('should set default view when allowed', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
update_seconds: 10,
},
}),
);
// Card is triggered.
vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(true);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
vi.useFakeTimers();
vi.setSystemTime(start);
const manager = new AutoUpdateManager(api);
manager.startDefaultViewTimer();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false);
vi.setSystemTime(add(start, { seconds: 20 }));
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).toBeCalled();
});
it('should not set default view when not configured', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
update_seconds: 0,
},
}),
);
vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
vi.useFakeTimers();
vi.setSystemTime(start);
const manager = new AutoUpdateManager(api);
manager.startDefaultViewTimer();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
});
@@ -301,5 +301,38 @@ describe('ConfigManager', () => {
InitializationAspect.MICROPHONE_CONNECT,
);
});
it('view.default_reset', () => {
const api = createCardAPI();
const manager = new ConfigManager(api);
const config_1 = {
type: 'custom:frigate-card',
cameras: [{ camera_entity: 'camera.office' }],
view: {
default_reset: {
every_seconds: 1,
},
},
};
vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_1));
manager.setConfig(config_1);
expect(api.getInitializationManager().uninitialize).not.toBeCalled();
const config_2 = {
...config_1,
view: {
default_reset: {
every_seconds: 2,
},
},
};
vi.mocked(getOverriddenConfig).mockReturnValue(createConfig(config_2));
manager.computeOverrideConfig();
expect(api.getInitializationManager().uninitialize).toBeCalledWith(
InitializationAspect.DEFAULT_RESET,
);
});
});
});
+7 -7
View File
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CameraManager } from '../../src/camera-manager/manager';
import { ActionsManager } from '../../src/card-controller/actions/actions-manager';
import { AutoUpdateManager } from '../../src/card-controller/auto-update-manager';
import { AutomationsManager } from '../../src/card-controller/automations-manager';
import { CameraURLManager } from '../../src/card-controller/camera-url-manager';
import {
@@ -11,6 +10,7 @@ import {
import { ConditionsManager } from '../../src/card-controller/conditions-manager';
import { ConfigManager } from '../../src/card-controller/config/config-manager';
import { CardController } from '../../src/card-controller/controller';
import { DefaultManager } from '../../src/card-controller/default-manager';
import { DownloadManager } from '../../src/card-controller/download-manager';
import { ExpandManager } from '../../src/card-controller/expand-manager';
import { FullscreenManager } from '../../src/card-controller/fullscreen-manager';
@@ -25,19 +25,19 @@ import { MicrophoneManager } from '../../src/card-controller/microphone-manager'
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
import { StyleManager } from '../../src/card-controller/style-manager';
import { TriggersManager } from '../../src/card-controller/triggers-manager';
import { ViewManager } from '../../src/card-controller/view-manager';
import { ViewManager } from '../../src/card-controller/view/view-manager';
import { FrigateCardEditor } from '../../src/editor';
import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
vi.mock('../../src/camera-manager/manager');
vi.mock('../../src/card-controller/actions/actions-manager');
vi.mock('../../src/card-controller/auto-update-manager');
vi.mock('../../src/card-controller/automations-manager');
vi.mock('../../src/card-controller/camera-url-manager');
vi.mock('../../src/card-controller/card-element-manager');
vi.mock('../../src/card-controller/conditions-manager');
vi.mock('../../src/card-controller/config/config-manager');
vi.mock('../../src/card-controller/default-manager');
vi.mock('../../src/card-controller/download-manager');
vi.mock('../../src/card-controller/expand-manager');
vi.mock('../../src/card-controller/fullscreen-manager');
@@ -52,7 +52,7 @@ vi.mock('../../src/card-controller/microphone-manager');
vi.mock('../../src/card-controller/query-string-manager');
vi.mock('../../src/card-controller/style-manager');
vi.mock('../../src/card-controller/triggers-manager');
vi.mock('../../src/card-controller/view-manager');
vi.mock('../../src/card-controller/view/view-manager');
vi.mock('../../src/utils/ha/entity-registry');
vi.mock('../../src/utils/ha/resolved-media');
@@ -107,9 +107,9 @@ describe('CardController', () => {
);
});
it('getAutoUpdateManager', () => {
expect(createController().getAutoUpdateManager()).toBe(
vi.mocked(AutoUpdateManager).mock.instances[0],
it('getDefaultManager', () => {
expect(createController().getDefaultManager()).toBe(
vi.mocked(DefaultManager).mock.instances[0],
);
});
@@ -0,0 +1,227 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DefaultManager } from '../../src/card-controller/default-manager';
import {
callHASubscribeMessageHandler,
createCardAPI,
createConfig,
createHASS,
} from '../test-utils';
// @vitest-environment jsdom
describe('DefaultManager', () => {
afterEach(() => {
vi.useRealTimers();
});
describe('time based', () => {
it('should set default view when allowed', async () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default_reset: {
every_seconds: 10,
},
},
}),
);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true);
vi.useFakeTimers();
const manager = new DefaultManager(api);
await manager.initialize();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).toBeCalledTimes(1);
manager.uninitialize();
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).toBeCalledTimes(1);
});
it('should not set default view when not configured', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default_reset: {
every_seconds: 0,
},
},
}),
);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
vi.useFakeTimers();
const manager = new DefaultManager(api);
manager.initialize();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
it('should restart timer when reconfigured', async () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default_reset: {
every_seconds: 10,
},
},
}),
);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
const hass = createHASS();
const unsubcribeCallback = vi.fn();
vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubcribeCallback);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
vi.useFakeTimers();
const manager = new DefaultManager(api);
await manager.initialize();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).toBeCalled();
});
});
describe('state based', () => {
it('should set default view when state changed', async () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default_reset: {
every_seconds: 10,
},
},
}),
);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
const hass = createHASS();
const unsubcribeCallback = vi.fn();
vi.mocked(hass.connection.subscribeMessage).mockResolvedValue(unsubcribeCallback);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = new DefaultManager(api);
await manager.initialize();
callHASubscribeMessageHandler(hass, {
variables: {
trigger: {
from_state: {
entity_id: 'binary_sensor.foo',
state: 'off',
},
to_state: {
entity_id: 'binary_sensor.foo',
state: 'on',
},
},
},
});
await manager.initialize();
expect(unsubcribeCallback).toBeCalledTimes(1);
expect(api.getViewManager().setViewDefault).toBeCalledTimes(1);
});
it('should not monitor state without config', async () => {
const api = createCardAPI();
const hass = createHASS();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = new DefaultManager(api);
await manager.initialize();
const mock = vi.mocked(hass.connection.subscribeMessage).mock;
expect(mock.calls.length).toBe(0);
});
});
describe('interaction based', () => {
it('should not register automation on initialization', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default_reset: {
after_interaction: false,
},
},
}),
);
const manager = new DefaultManager(api);
await manager.initialize();
expect(api.getAutomationsManager().addAutomations).not.toBeCalled();
});
it('should register automation on initialization', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default_reset: {
after_interaction: true,
},
},
}),
);
const manager = new DefaultManager(api);
await manager.initialize();
expect(api.getAutomationsManager().addAutomations).toBeCalledWith([
{
actions: [
{
action: 'fire-dom-event',
frigate_card_action: 'default',
},
],
conditions: [
{
condition: 'interaction',
interaction: false,
},
],
tag: expect.anything(),
},
]);
});
it('should remove automation on uninitalize', async () => {
const api = createCardAPI();
const manager = new DefaultManager(api);
await manager.uninitialize();
expect(api.getAutomationsManager().deleteAutomations).toBeCalledWith(manager);
});
});
});
+7 -25
View File
@@ -146,7 +146,7 @@ describe('HASSManager', () => {
});
});
describe('should set default view when', () => {
describe('should not set default view when', () => {
it('selected camera is unknown', () => {
const api = createAPIWithoutMediaPlayers();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
@@ -178,15 +178,18 @@ describe('HASSManager', () => {
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
it('view.update_entities changes', () => {
it('when there is card interaction', () => {
const api = createAPIWithoutMediaPlayers();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
update_entities: ['sensor.force_default_view'],
default_reset: {
entities: ['sensor.force_default_view'],
},
},
}),
);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true);
const manager = new HASSManager(api);
const hass = createHASS({
@@ -195,7 +198,7 @@ describe('HASSManager', () => {
manager.setHASS(hass);
expect(api.getViewManager().setViewDefault).toBeCalled();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
});
@@ -236,25 +239,4 @@ describe('HASSManager', () => {
expect(api.getCardElementManager().update).toBeCalled();
});
});
it('set view default is not called when there is card interaction', () => {
const api = createAPIWithoutMediaPlayers();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
update_entities: ['sensor.force_default_view'],
},
}),
);
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(true);
const manager = new HASSManager(api);
const hass = createHASS({
'sensor.force_default_view': createStateEntity(),
});
manager.setHASS(hass);
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
});
@@ -110,7 +110,7 @@ describe('InitializationManager', () => {
expect(loadLanguages).toBeCalled();
expect(sideLoadHomeAssistantElements).toBeCalled();
expect(api.getCameraManager().initializeCamerasFromConfig).toBeCalled();
expect(api.getViewManager().setViewDefault).toBeCalled();
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
expect(api.getMicrophoneManager().connect).not.toBeCalled();
});
@@ -208,9 +208,13 @@ describe('InitializationManager', () => {
expect(await manager.initializeBackgroundIfNecessary()).toBeFalsy();
});
it('successfully with minimal initializers', async () => {
it('successfully when already initialized', async () => {
const api = createCardAPI();
const manager = new InitializationManager(api);
const initializer = mock<Initializer>();
initializer.isInitializedMultiple.mockReturnValue(true);
const manager = new InitializationManager(api, initializer);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
@@ -226,6 +230,8 @@ describe('InitializationManager', () => {
expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy();
expect(api.getMediaPlayerManager().initialize).not.toBeCalled();
expect(api.getDefaultManager().initialize).not.toBeCalled();
expect(api.getCardElementManager().update).not.toBeCalled();
});
it('successfully with all inititalizers', async () => {
@@ -246,10 +252,11 @@ describe('InitializationManager', () => {
expect(await manager.initializeBackgroundIfNecessary()).toBeTruthy();
expect(api.getMediaPlayerManager().initialize).toBeCalled();
expect(api.getDefaultManager().initialize).toBeCalled();
expect(api.getCardElementManager().update).toBeCalled();
});
it('with media player in progress', async () => {
it('with initializers in progress', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
@@ -23,55 +23,6 @@ describe('InteractionManager', () => {
expect(api.getConditionsManager().setState).toBeCalledWith({ interaction: false });
});
it('should take action when interaction is reported', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
interaction_seconds: 10,
},
}),
);
const manager = new InteractionManager(api);
vi.useFakeTimers();
vi.setSystemTime(start);
manager.reportInteraction();
expect(manager.hasInteraction()).toBeTruthy();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(false);
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(api.getViewManager().setViewDefault).toBeCalled();
});
it('should not take action when triggered', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
interaction_seconds: 10,
},
}),
);
const manager = new InteractionManager(api);
vi.useFakeTimers();
vi.setSystemTime(start);
manager.reportInteraction();
vi.mocked(api.getTriggersManager().isTriggered).mockReturnValue(true);
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
// First call is blocked by triggers (above), so interaction will report
// true but the default view will not have been set.
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
it('should not take action without an interaction timeout', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
@@ -85,33 +36,7 @@ describe('InteractionManager', () => {
manager.reportInteraction();
// First call is blocked by triggers (above), so interaction will report
// true but the default view will not have been set.
expect(api.getViewManager().setViewDefault).not.toBeCalled();
});
it('should not take action without reset_after_interaction', () => {
const api = createCardAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
reset_after_interaction: false,
interaction_seconds: 10,
},
}),
);
const manager = new InteractionManager(api);
vi.useFakeTimers();
vi.setSystemTime(start);
manager.reportInteraction();
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
// First call is blocked by triggers (above), so interaction will report
// true but the default view will not have been set.
expect(api.getViewManager().setViewDefault).not.toBeCalled();
expect(manager.hasInteraction()).toBeFalsy();
});
it('should set condition state', () => {
@@ -135,6 +60,7 @@ describe('InteractionManager', () => {
interaction: true,
}),
);
expect(manager.hasInteraction()).toBeTruthy();
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
@@ -144,5 +70,6 @@ describe('InteractionManager', () => {
interaction: false,
}),
);
expect(manager.hasInteraction()).toBeFalsy();
});
});
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
import { SubstreamSelectViewModifier } from '../../src/card-controller/view/modifiers/substream-select';
import { createCardAPI } from '../test-utils';
const setQueryString = (qs: string): void => {
@@ -51,8 +52,10 @@ describe('QueryStringManager', () => {
manager.executeAll();
expect(manager.hasViewRelatedActions()).toBeTruthy();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
viewName: viewName,
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
params: {
view: viewName,
},
});
});
});
@@ -92,7 +95,7 @@ describe('QueryStringManager', () => {
manager.executeAll();
expect(api.getViewManager().setViewDefault).toBeCalled();
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
expect(manager.hasViewRelatedActions()).toBeTruthy();
expect(api.getActionsManager().executeActions).not.toBeCalled();
@@ -107,8 +110,10 @@ describe('QueryStringManager', () => {
manager.executeAll();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
cameraID: 'camera.office',
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
params: {
camera: 'camera.office',
},
});
expect(manager.hasViewRelatedActions()).toBeTruthy();
@@ -124,8 +129,9 @@ describe('QueryStringManager', () => {
manager.executeAll();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
substream: 'camera.office_hd',
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
modifiers: [expect.any(SubstreamSelectViewModifier)],
params: {},
});
expect(manager.hasViewRelatedActions()).toBeTruthy();
@@ -190,8 +196,10 @@ describe('QueryStringManager', () => {
manager.executeAll();
expect(manager.hasViewRelatedActions()).toBeTruthy();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
viewName: viewName,
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
params: {
view: viewName,
},
});
});
});
@@ -230,11 +238,13 @@ describe('QueryStringManager', () => {
manager.executeAll();
expect(api.getViewManager().setViewDefault).toBeCalledWith({
cameraID: 'camera.kitchen',
substream: 'camera.kitchen_hd',
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({
params: {
camera: 'camera.kitchen',
},
modifiers: [expect.any(SubstreamSelectViewModifier)],
});
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('multiple cameras specified', () => {
@@ -248,8 +258,10 @@ describe('QueryStringManager', () => {
manager.executeAll();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
cameraID: 'camera.office',
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
params: {
camera: 'camera.office',
},
});
});
});
+268 -139
View File
@@ -2,7 +2,11 @@ import { add } from 'date-fns';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CardController } from '../../src/card-controller/controller';
import { TriggersManager } from '../../src/card-controller/triggers-manager';
import { TriggersOptions, triggersSchema } from '../../src/config/types';
import {
FrigateCardView,
TriggersOptions,
triggersSchema,
} from '../../src/config/types';
import {
createCameraConfig,
createCameraManager,
@@ -10,17 +14,19 @@ import {
createConfig,
createStore,
createView,
flushPromises,
} from '../test-utils';
vi.mock('lodash-es/throttle', () => ({
default: vi.fn((fn) => fn),
}));
const baseTriggersConfig: Partial<TriggersOptions> = {
const baseTriggersConfig: TriggersOptions = {
untrigger_seconds: 10,
filter_selected_camera: false,
show_trigger_status: false,
actions: {
trigger: 'live' as const,
trigger: 'update' as const,
untrigger: 'default' as const,
interaction_mode: 'inactive' as const,
},
@@ -30,6 +36,7 @@ const baseTriggersConfig: Partial<TriggersOptions> = {
// function reduces it.
const createTriggerAPI = (options?: {
config?: Partial<TriggersOptions>;
default?: FrigateCardView;
interaction?: boolean;
}): CardController => {
const api = createCardAPI();
@@ -39,6 +46,7 @@ const createTriggerAPI = (options?: {
triggers: options?.config
? triggersSchema.parse(options.config)
: baseTriggersConfig,
...(options?.default && { default: options.default }),
},
}),
);
@@ -101,161 +109,273 @@ describe('TriggersManager', () => {
expect(manager.isTriggered()).toBeFalsy();
});
it('should trigger and untrigger based on low fidelity event', () => {
const api = createTriggerAPI();
const manager = new TriggersManager(api);
manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({
triggered: new Set(['camera_1']),
});
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
viewName: 'live' as const,
cameraID: 'camera_1' as const,
});
vi.mocked(api.getConditionsManager().getState).mockReturnValue({
triggered: new Set(['camera_1']),
});
manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'end',
});
// Will still be triggered, but untrigger timer will be running.
expect(manager.isTriggered()).toBeTruthy();
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({
triggered: undefined,
});
expect(api.getViewManager().setViewDefault).toBeCalled();
});
describe('should treat high fidelity events appropriately', () => {
it('with no media', () => {
const api = createTriggerAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default: 'clips',
describe('trigger actions', () => {
it('update', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
actions: {
...baseTriggersConfig.actions,
trigger: 'update',
},
}),
);
},
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'new',
fidelity: 'high',
// Intentionally blank.
});
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
queryExecutorOptions: { useCache: false },
});
});
it('with media', () => {
const api = createTriggerAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default: 'clips',
it('default', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
actions: {
...baseTriggersConfig.actions,
trigger: 'default',
},
}),
);
const manager = new TriggersManager(api);
manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'new',
fidelity: 'high',
clip: true,
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewDefault).toBeCalled();
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalledWith({
params: {
camera: 'camera_1',
},
});
});
it('live', () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
actions: {
...baseTriggersConfig.actions,
trigger: 'live',
},
},
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
params: {
view: 'live',
camera: 'camera_1',
},
});
});
describe('media', () => {
it.each([
[false, false, null],
[false, true, 'clip' as const],
[true, false, 'snapshot' as const],
[true, true, 'clip' as const],
])(
'with snapshot %s and clip %s',
async (
hasSnapshot: boolean,
hasClip: boolean,
viewName: 'clip' | 'snapshot' | null,
) => {
const api = createTriggerAPI({
config: {
actions: {
interaction_mode: 'all',
trigger: 'media',
untrigger: 'none',
},
},
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'new',
fidelity: 'high',
snapshot: hasSnapshot,
clip: hasClip,
});
if (!viewName) {
expect(
api.getViewManager().setViewByParametersWithNewQuery,
).not.toBeCalled();
} else {
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
params: {
camera: 'camera_1',
view: viewName,
},
});
}
},
);
});
it('none', () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
actions: {
...baseTriggersConfig.actions,
trigger: 'none',
},
},
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
});
it('should change to default view when suitably configured', () => {
describe('untrigger actions', () => {
it('none', () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
actions: {
...baseTriggersConfig.actions,
trigger: 'none',
untrigger: 'none',
},
},
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' });
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('default', async () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
actions: {
...baseTriggersConfig.actions,
trigger: 'none',
untrigger: 'default',
},
},
});
const manager = new TriggersManager(api);
await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
await manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' });
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
await flushPromises();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
});
});
it('should manage condition state', () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
actions: {
interaction_mode: 'all',
trigger: 'default',
...baseTriggersConfig.actions,
trigger: 'none',
untrigger: 'none',
},
},
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'new',
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new' });
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({
triggered: new Set(['camera_1']),
});
vi.mocked(api.getConditionsManager().getState).mockReturnValue({
triggered: new Set(['camera_1']),
});
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewDefault).toBeCalledWith({
cameraID: 'camera_1',
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'end' });
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
expect(api.getConditionsManager().setState).toHaveBeenLastCalledWith({
triggered: undefined,
});
});
describe('should change to media view', () => {
it.each([
[false, false, null],
[false, true, 'clip' as const],
[true, false, 'snapshot' as const],
[true, true, 'clip' as const],
])(
'with snapshot %s and clip %s',
async (
hasSnapshot: boolean,
hasClip: boolean,
viewName: 'clip' | 'snapshot' | null,
) => {
const api = createTriggerAPI({
config: {
actions: {
interaction_mode: 'all',
trigger: 'media',
untrigger: 'none',
},
describe('should take no actions with high-fidelity event', () => {
it('with non-live action', () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
actions: {
...baseTriggersConfig.actions,
trigger: 'media',
},
});
const manager = new TriggersManager(api);
},
default: 'live',
});
manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'new',
fidelity: 'high',
snapshot: hasSnapshot,
clip: hasClip,
});
const manager = new TriggersManager(api);
if (!viewName) {
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
} else {
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
cameraID: 'camera_1',
viewName: viewName,
});
}
},
);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' });
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('with non-live default', () => {
const api = createTriggerAPI({
config: {
...baseTriggersConfig,
actions: {
...baseTriggersConfig.actions,
trigger: 'default',
},
},
default: 'clips',
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({ cameraID: 'camera_1', type: 'new', fidelity: 'high' });
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
});
it('should take no actions with human interactions', () => {
@@ -272,7 +392,8 @@ describe('TriggersManager', () => {
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
manager.handleCameraEvent({
cameraID: 'camera_1',
@@ -284,7 +405,8 @@ describe('TriggersManager', () => {
expect(manager.isTriggered()).toBeFalsy();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('should take no actions when actions are set to none', () => {
@@ -303,7 +425,8 @@ describe('TriggersManager', () => {
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParameters).not.toBeCalled();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
manager.handleCameraEvent({
cameraID: 'camera_1',
@@ -314,10 +437,11 @@ describe('TriggersManager', () => {
vi.runOnlyPendingTimers();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getViewManager().setViewDefault).not.toBeCalled();
expect(api.getViewManager().setViewDefaultWithNewQuery).not.toBeCalled();
expect(api.getViewManager().setViewByParametersWithNewQuery).not.toBeCalled();
});
it('should take actions with human interactions when interaction mode is active', () => {
it('should take actions with human interactions when interaction mode is active', async () => {
const api = createTriggerAPI({
// Interaction present.
interaction: true,
@@ -331,31 +455,34 @@ describe('TriggersManager', () => {
},
});
const manager = new TriggersManager(api);
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'new',
});
expect(manager.isTriggered()).toBeTruthy();
expect(api.getViewManager().setViewByParameters).toBeCalledWith({
viewName: 'live' as const,
cameraID: 'camera_1' as const,
expect(api.getViewManager().setViewByParametersWithNewQuery).toBeCalledWith({
params: {
view: 'live' as const,
camera: 'camera_1' as const,
},
});
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'end',
});
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
await flushPromises();
expect(manager.isTriggered()).toBeFalsy();
expect(api.getViewManager().setViewDefault).toBeCalled();
expect(api.getViewManager().setViewDefaultWithNewQuery).toBeCalled();
});
it('should report multiple triggered cameras', () => {
it('should report multiple triggered cameras', async () => {
const api = createTriggerAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
@@ -384,11 +511,11 @@ describe('TriggersManager', () => {
expect(manager.getMostRecentlyTriggeredCameraID()).toBeNull();
expect(manager.getTriggeredCameraIDs()).toEqual(new Set());
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'new',
});
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_2',
type: 'new',
});
@@ -401,7 +528,7 @@ describe('TriggersManager', () => {
manager.getMostRecentlyTriggeredCameraID(),
);
manager.handleCameraEvent({
await manager.handleCameraEvent({
cameraID: 'camera_1',
type: 'end',
});
@@ -409,6 +536,8 @@ describe('TriggersManager', () => {
vi.setSystemTime(add(start, { seconds: 10 }));
vi.runOnlyPendingTimers();
await flushPromises();
expect(manager.getTriggeredCameraIDs()).toEqual(new Set(['camera_2']));
expect(manager.getMostRecentlyTriggeredCameraID()).toBe('camera_2');
});
File diff suppressed because it is too large Load Diff
+751
View File
@@ -0,0 +1,751 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ViewFactory } from '../../../src/card-controller/view/factory';
import { QueryExecutor } from '../../../src/card-controller/view/query-executor';
import { ViewModifier } from '../../../src/card-controller/view/types';
import { FrigateCardView, ViewDisplayMode } from '../../../src/config/types';
import {
EventMediaQueries,
RecordingMediaQueries,
} from '../../../src/view/media-queries';
import { MediaQueriesResults } from '../../../src/view/media-queries-results';
import { View } from '../../../src/view/view';
import {
createCameraManager,
createCapabilities,
createCardAPI,
createConfig,
createStore,
} from '../../test-utils';
import { createPopulatedAPI } from './test-utils';
describe('getViewDefault', () => {
it('should return null without config', () => {
const factory = new ViewFactory(createCardAPI());
expect(factory.getViewDefault()).toBeNull();
});
it('should create view', () => {
const factory = new ViewFactory(createPopulatedAPI());
const view = factory.getViewDefault();
expect(view?.is('live')).toBeTruthy();
expect(view?.camera).toBe('camera.office');
});
it('should cycle camera when configured', () => {
const api = createPopulatedAPI();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(
createConfig({
view: {
default_cycle_camera: true,
},
}),
);
const factory = new ViewFactory(api);
let view = factory.getViewDefault();
expect(view?.camera).toBe('camera.office');
view = factory.getViewDefault({ baseView: view });
expect(view?.camera).toBe('camera.kitchen');
view = factory.getViewDefault({ baseView: view });
expect(view?.camera).toBe('camera.office');
// When a parameter is specified, it will not cycle.
view = factory.getViewDefault({
params: { camera: 'camera.office' },
baseView: view,
});
expect(view?.camera).toBe('camera.office');
});
it('should respect parameters', () => {
const factory = new ViewFactory(createPopulatedAPI());
const view = factory.getViewDefault({
params: {
camera: 'camera.office',
},
});
expect(view?.is('live')).toBeTruthy();
expect(view?.camera).toBe('camera.office');
});
});
describe('getViewByParameters', () => {
it('should get view by parameters specifying camera and view', () => {
const api = createPopulatedAPI();
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({
clips: true,
}),
);
const factory = new ViewFactory(api);
const view = factory.getViewByParameters({
params: {
camera: 'camera.kitchen',
view: 'clips',
},
});
expect(view?.is('clips')).toBeTruthy();
expect(view?.camera).toBe('camera.kitchen');
});
it('should get view by parameters using base view if unspecified', () => {
const api = createPopulatedAPI();
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({
clips: true,
}),
);
const factory = new ViewFactory(api);
const baseView = new View({
camera: 'camera.kitchen',
view: 'clips',
});
const view = factory.getViewByParameters({
baseView: baseView,
params: {
camera: 'camera.office',
},
});
expect(view?.view).toBe('clips');
expect(view?.camera).toBe('camera.office');
});
it('should set view by parameters using config as fallback', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({ live: true }),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const factory = new ViewFactory(api);
const view = factory.getViewByParameters({
params: {
camera: 'camera.kitchen',
// No prior view, and no specified view. This could happen during query
// string based initialization.
},
});
expect(view?.is('live')).toBeTruthy();
expect(view?.camera).toBe('camera.kitchen');
});
it('should not set view by parameters without config', () => {
const factory = new ViewFactory(createCardAPI());
const view = factory.getViewByParameters({
params: {
view: 'live',
},
});
expect(view).toBeNull();
});
it('should throw without camera and without failsafe', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({ snapshots: false }),
},
{
cameraID: 'camera.office',
// No capabilities.
capabilities: null,
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const factory = new ViewFactory(api);
expect(() =>
factory.getViewByParameters({
// Since no camera is specified, and no camera supports the capabilities
// necessary for this view, the view will be null.
params: {
view: 'snapshots',
},
}),
).toThrowError(/No cameras support this view/);
});
describe('should handle unsupported view', () => {
it('should throw without failsafe', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({ snapshots: false }),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({
snapshots: false,
}),
);
const factory = new ViewFactory(api);
expect(() =>
factory.getViewByParameters({
params: {
camera: 'camera.kitchen',
view: 'snapshots',
},
}),
).toThrowError(/The selected camera does not support this view/);
});
it('should choose live view with failsafe', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({ live: true, snapshots: false }),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
vi.mocked(api.getCameraManager().getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({
live: true,
snapshots: false,
}),
);
const factory = new ViewFactory(api);
const view = factory.getViewByParameters({
params: {
camera: 'camera.kitchen',
view: 'snapshots',
},
failSafe: true,
});
expect(view?.is('live')).toBeTruthy();
});
});
it('should call modifiers', () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({ live: true }),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const modifyCallback = vi.fn();
class TestViewModifier implements ViewModifier {
modify = modifyCallback;
}
const view = new View({
view: 'live',
camera: 'camera.office',
});
const factory = new ViewFactory(api);
const modifiedView = factory.getViewByParameters({
baseView: view,
modifiers: [new TestViewModifier()],
});
expect(modifiedView?.is('live')).toBeTruthy();
expect(modifiedView?.camera).toBe('camera.office');
expect(view).not.toBe(modifiedView);
expect(modifyCallback).toHaveBeenCalledWith(modifiedView);
});
describe('should get correct default display mode', () => {
describe.each([['single' as const], ['grid' as const]])(
'%s',
(displayMode: ViewDisplayMode) => {
it.each([
['media' as const],
['clip' as const],
['recording' as const],
['snapshot' as const],
['live' as const],
])('%s', (viewName: FrigateCardView) => {
const api = createPopulatedAPI({
media_viewer: {
display: {
mode: displayMode,
},
},
live: {
display: {
mode: displayMode,
},
},
});
const factory = new ViewFactory(api);
expect(
factory.getViewByParameters({
params: {
view: viewName,
},
})?.displayMode,
).toBe(displayMode);
});
},
);
});
});
describe('getViewByParametersWithNewQuery', () => {
it('should not execute query without config', async () => {
const factory = new ViewFactory(createCardAPI());
expect(await factory.getViewByParametersWithNewQuery()).toBeNull();
});
describe('with a live view', () => {
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2024-07-21T13:22:06Z'));
});
afterAll(() => {
vi.useRealTimers();
});
it('should set timeline window', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.context).toEqual({
timeline: {
window: {
start: new Date('2024-07-21T12:22:06.000Z'),
end: new Date('2024-07-21T13:22:06.000Z'),
},
},
});
});
it('should not fetch anything if configured for no thumbnails', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(
createPopulatedAPI({
live: {
controls: {
thumbnails: {
mode: 'none' as const,
},
},
},
}),
executor,
);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
it('should fetch events', async () => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: 'all',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
it('should fetch recordings', async () => {
const executor = mock<QueryExecutor>();
const query = new RecordingMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultRecordingQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(
createPopulatedAPI({
live: {
controls: {
thumbnails: {
media_type: 'recordings',
},
},
},
}),
executor,
);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'live',
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).not.toBeCalled();
expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
cameraID: 'camera.office',
executorOptions: {
useCache: false,
},
});
});
});
describe('with a media view', () => {
it('should do nothing with same camera', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const baseView = new View({
view: 'media',
camera: 'camera.office',
});
const view = await factory.getViewByParametersWithNewQuery({
baseView: baseView,
params: {
view: 'media',
},
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
it('should fetch clips with different camera', async () => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const baseView = new View({
view: 'media',
camera: 'camera.office',
});
const view = await factory.getViewByParametersWithNewQuery({
baseView: baseView,
params: {
view: 'media',
camera: 'camera.kitchen',
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.kitchen',
eventsMediaType: 'clips',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('with an events-based view', () => {
it.each([
['clip' as const, 'clips' as const],
['clips' as const, 'clips' as const],
['snapshot' as const, 'snapshots' as const],
['snapshots' as const, 'snapshots' as const],
])(
'%s',
async (viewName: FrigateCardView, eventsMediaType: 'clips' | 'snapshots') => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: viewName,
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: eventsMediaType,
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
},
);
});
describe('with an recordings-based view', () => {
it.each([['recording' as const], ['recordings' as const]])(
'%s',
async (viewName: FrigateCardView) => {
const executor = mock<QueryExecutor>();
const query = new RecordingMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultRecordingQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: viewName,
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).toBeCalledWith({
cameraID: 'camera.office',
executorOptions: {
useCache: false,
},
});
},
);
});
describe('with an media viewer view', () => {
it('hould not fetch anything if configured for no thumbnails', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(
createPopulatedAPI({
media_viewer: {
controls: {
thumbnails: {
mode: 'none' as const,
},
},
},
}),
executor,
);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'clip',
},
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('when changing to gallery from the media viewer', () => {
it('should adopt query and results', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const baseView = new View({
view: 'media',
camera: 'camera.office',
query: new EventMediaQueries(),
queryResults: new MediaQueriesResults(),
});
const view = await factory.getViewByParametersWithNewQuery({
baseView: baseView,
params: {
view: 'clips',
},
});
expect(view?.query).toBe(baseView.query);
expect(view?.queryResults).toBe(baseView.queryResults);
expect(executor.executeDefaultEventQuery).not.toHaveBeenCalled();
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('when set or remove seek time', () => {
it('should set seek time when results are selected based on time', async () => {
const now = new Date();
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
params: {
view: 'clips',
},
queryExecutorOptions: {
selectResult: {
time: {
time: now,
},
},
},
});
expect(view?.context).toEqual({
mediaViewer: {
seek: now,
},
});
});
it('should remove seek time when results are not selected based on time', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithNewQuery({
baseView: new View({
view: 'clips',
camera: 'camera.office',
context: {
mediaViewer: {
seek: new Date(),
},
},
}),
params: {
view: 'clips',
},
});
expect(view?.context?.mediaViewer?.seek).toBeUndefined();
});
});
});
describe('getViewDefaultWithNewQuery', () => {
it('should fetch events', async () => {
const executor = mock<QueryExecutor>();
const query = new EventMediaQueries();
const queryResults = new MediaQueriesResults();
executor.executeDefaultEventQuery.mockResolvedValue({
query: query,
queryResults: queryResults,
});
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewDefaultWithNewQuery();
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
expect(executor.executeDefaultEventQuery).toBeCalledWith({
cameraID: 'camera.office',
eventsMediaType: 'all',
executorOptions: {
useCache: false,
},
});
expect(executor.executeDefaultRecordingQuery).not.toHaveBeenCalled();
});
});
describe('getViewByParametersWithExistingQuery', () => {
it('should not execute anything when query is absent', async () => {
const executor = mock<QueryExecutor>();
const factory = new ViewFactory(createPopulatedAPI(), executor);
const view = await factory.getViewByParametersWithExistingQuery({
params: {
view: 'live',
camera: 'camera.office',
},
});
expect(view?.query).toBeNull();
expect(view?.queryResults).toBeNull();
expect(executor.executeDefaultEventQuery).not.toBeCalled();
expect(executor.executeDefaultRecordingQuery).not.toBeCalled();
});
it('should set query results', async () => {
const executor = mock<QueryExecutor>();
const queryResults = new MediaQueriesResults();
executor.execute.mockResolvedValue(queryResults);
const factory = new ViewFactory(createPopulatedAPI(), executor);
const query = new RecordingMediaQueries();
const view = await factory.getViewByParametersWithExistingQuery({
params: {
view: 'live',
camera: 'camera.office',
query: query,
},
});
expect(view?.query).toBe(query);
expect(view?.queryResults).toBe(queryResults);
});
});
@@ -0,0 +1,28 @@
import { ViewContext } from 'view';
import { expect, it } from 'vitest';
import { MergeContextViewModifier } from '../../../../src/card-controller/view/modifiers/merge-context';
import { createView } from '../../../test-utils';
it('should merge context', () => {
const context: ViewContext = {
timeline: {
window: {
start: new Date(),
end: new Date(),
},
},
};
const modifier = new MergeContextViewModifier(context);
const view = createView({
view: 'live',
camera: 'camera',
displayMode: 'grid',
});
expect(view.context).toBeNull();
modifier.modify(view);
expect(view.context).toEqual(context);
});
@@ -0,0 +1,25 @@
import { expect, it } from 'vitest';
import { createView } from '../../../test-utils';
import { RemoveContextPropertyViewModifier } from '../../../../src/card-controller/view/modifiers/remove-context-property';
it('should remove context property', () => {
const modifier = new RemoveContextPropertyViewModifier('timeline', 'window');
const view = createView({
view: 'live',
camera: 'camera',
displayMode: 'grid',
context: {
timeline: {
window: {
start: new Date(),
end: new Date(),
},
},
},
});
modifier.modify(view);
expect(view.context).toEqual({ timeline: {} });
});
@@ -0,0 +1,25 @@
import { expect, it } from 'vitest';
import { createView } from '../../../test-utils';
import { RemoveContextViewModifier } from '../../../../src/card-controller/view/modifiers/remove-context';
it('should remove context property', () => {
const modifier = new RemoveContextViewModifier(['timeline']);
const view = createView({
view: 'live',
camera: 'camera',
displayMode: 'grid',
context: {
timeline: {
window: {
start: new Date(),
end: new Date(),
},
},
},
});
modifier.modify(view);
expect(view.context).toEqual({});
});
@@ -0,0 +1,20 @@
import { expect, it } from 'vitest';
import { createView } from '../../../test-utils';
import { SubstreamOffViewModifier } from '../../../../src/card-controller/view/modifiers/substream-off';
import { hasSubstream, setSubstream } from '../../../../src/utils/substream';
it('should turn off substream', () => {
const view = createView({
view: 'live',
camera: 'camera',
displayMode: 'grid',
});
setSubstream(view, 'substream');
expect(hasSubstream(view)).toBe(true);
const modifier = new SubstreamOffViewModifier();
modifier.modify(view);
expect(hasSubstream(view)).toBe(false);
});
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from 'vitest';
import { CardController } from '../../../../src/card-controller/controller';
import { SubstreamOnViewModifier } from '../../../../src/card-controller/view/modifiers/substream-on';
import { RawFrigateCardConfig } from '../../../../src/config/types';
import {
getStreamCameraID,
hasSubstream,
setSubstream,
} from '../../../../src/utils/substream';
import {
createCameraConfig,
createCameraManager,
createCapabilities,
createCardAPI,
createConfig,
createStore,
createView,
} from '../../../test-utils';
const createAPIWithSubstreams = (config?: RawFrigateCardConfig): CardController => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({
live: true,
substream: true,
}),
config: createCameraConfig({
dependencies: {
all_cameras: true,
},
}),
},
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({
substream: true,
}),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config));
return api;
};
describe('should turn on substream', () => {
it('substream available', () => {
const view = createView({
view: 'live',
camera: 'camera.office',
});
expect(hasSubstream(view)).toBe(false);
const api = createAPIWithSubstreams();
const modifier = new SubstreamOnViewModifier(api);
modifier.modify(view);
expect(hasSubstream(view)).toBe(true);
expect(getStreamCameraID(view)).toBe('camera.kitchen');
modifier.modify(view);
expect(hasSubstream(view)).toBe(false);
expect(getStreamCameraID(view)).toBe('camera.office');
});
it('malformed substream', () => {
const view = createView({
view: 'live',
camera: 'camera.office',
});
const api = createAPIWithSubstreams();
setSubstream(view, 'NOT_A_REAL_CAMERA');
const modifier = new SubstreamOnViewModifier(api);
modifier.modify(view);
expect(hasSubstream(view)).toBe(false);
expect(getStreamCameraID(view)).toBe('camera.office');
});
it('substream unavailable', () => {
const view = createView({
view: 'live',
camera: 'camera.office',
});
expect(hasSubstream(view)).toBe(false);
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
const modifier = new SubstreamOnViewModifier(api);
modifier.modify(view);
expect(hasSubstream(view)).toBe(false);
});
});
@@ -0,0 +1,19 @@
import { expect, it } from 'vitest';
import { SubstreamSelectViewModifier } from '../../../../src/card-controller/view/modifiers/substream-select';
import { getStreamCameraID, hasSubstream } from '../../../../src/utils/substream';
import { createView } from '../../../test-utils';
it('should select substream', () => {
const view = createView({
view: 'live',
camera: 'camera.office',
});
expect(hasSubstream(view)).toBe(false);
const modifier = new SubstreamSelectViewModifier('substream');
modifier.modify(view);
expect(hasSubstream(view)).toBe(true);
expect(getStreamCameraID(view)).toBe('substream');
});
@@ -0,0 +1,360 @@
import { add } from 'date-fns';
import { describe, expect, it, vi } from 'vitest';
import { QueryType } from '../../../src/camera-manager/types';
import { QueryExecutor } from '../../../src/card-controller/view/query-executor';
import { ClipsOrSnapshotsOrAll } from '../../../src/types';
import { EventMediaQueries } from '../../../src/view/media-queries';
import {
TestViewMedia,
createCameraManager,
createCardAPI,
createStore,
generateViewMediaArray,
} from '../../test-utils';
import { createPopulatedAPI } from './test-utils';
describe('executeDefaultEventQuery', () => {
it('should return null without cameras', async () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore());
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultEventQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
it('should return null without queries', async () => {
const api = createPopulatedAPI();
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(null);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultEventQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
describe('should return query results for specified camera', async () => {
it.each([['all' as const], ['clips' as const], ['snapshots' as const], [undefined]])(
'%s',
async (mediaType?: ClipsOrSnapshotsOrAll) => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{ type: QueryType.Event as const, cameraIDs: new Set(['camera.office']) },
];
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultEventQuery({
cameraID: 'camera.office',
eventsMediaType: mediaType,
});
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
...(mediaType === 'clips' && { hasClip: true }),
...(mediaType === 'snapshots' && { hasSnapshot: true }),
},
);
},
);
});
it('should return query results for all cameras', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office', 'camera.kitchen']),
},
];
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultEventQuery();
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith(
new Set(['camera.office', 'camera.kitchen']),
{
limit: 50,
},
);
});
it('should return null when query returns null', async () => {
const api = createPopulatedAPI();
const rawQueries = [
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
];
vi.mocked(api.getCameraManager().generateDefaultEventQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(null);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultEventQuery({ cameraID: 'camera.office' }),
).toBeNull();
expect(api.getCameraManager().generateDefaultEventQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
},
);
});
});
describe('executeDefaultRecordingQuery', () => {
it('should return null without cameras', async () => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(createStore());
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultRecordingQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
it('should return null without queries', async () => {
const api = createPopulatedAPI();
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
null,
);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultRecordingQuery({
cameraID: 'camera.office',
}),
).toBeNull();
});
it('should return query results for specified camera', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{ type: QueryType.Recording as const, cameraIDs: new Set(['camera.office']) },
];
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultRecordingQuery({
cameraID: 'camera.office',
});
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
},
);
});
it('should return query results for all cameras', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
const rawQueries = [
{
type: QueryType.Recording as const,
cameraIDs: new Set(['camera.office', 'camera.kitchen']),
},
];
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const executor = new QueryExecutor(api);
const results = await executor.executeDefaultRecordingQuery();
expect(results?.query.getQueries()).toEqual(rawQueries);
expect(results?.queryResults.getResults()).toEqual(media);
expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith(
new Set(['camera.office', 'camera.kitchen']),
{
limit: 50,
},
);
});
it('should return null when query returns null', async () => {
const api = createPopulatedAPI();
const rawQueries = [
{
type: QueryType.Recording as const,
cameraIDs: new Set(['camera.office']),
},
];
vi.mocked(api.getCameraManager().generateDefaultRecordingQueries).mockReturnValue(
rawQueries,
);
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(null);
const executor = new QueryExecutor(api);
expect(
await executor.executeDefaultRecordingQuery({ cameraID: 'camera.office' }),
).toBeNull();
expect(api.getCameraManager().generateDefaultRecordingQueries).toBeCalledWith(
new Set(['camera.office']),
{
limit: 50,
},
);
});
});
describe('execute', () => {
it('should return null when query is empty', async () => {
const executor = new QueryExecutor(createCardAPI());
expect(await executor.execute(new EventMediaQueries())).toBeNull();
});
describe('should handle result rejections', () => {
it('rejected', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
expect(await executor.execute(query, { rejectResults: (_) => true })).toBeNull();
});
it('not rejected', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray();
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
expect(
await executor.execute(query, { rejectResults: (_) => false }),
).not.toBeNull();
});
});
describe('should select', () => {
it('by id', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray({
cameraIDs: ['camera.office'],
count: 100,
});
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
const results = await executor.execute(query, {
selectResult: { id: 'id-camera.office-42' },
});
expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-42');
});
});
it('by func', async () => {
const api = createPopulatedAPI();
const media = generateViewMediaArray({
cameraIDs: ['camera.office'],
count: 100,
});
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
const results = await executor.execute(query, {
selectResult: { func: (media) => media.getID() === 'id-camera.office-43' },
});
expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-43');
});
it('by time', async () => {
const now = new Date('2024-07-21T19:09:37Z');
const api = createPopulatedAPI();
const media = [
new TestViewMedia({
cameraID: 'camera.office',
id: 'id-camera.office-0',
startTime: now,
}),
new TestViewMedia({
cameraID: 'camera.office',
id: 'id-camera.office-1',
startTime: add(now, { seconds: 1 }),
}),
new TestViewMedia({
cameraID: 'camera.office',
id: 'id-camera.office-2',
startTime: add(now, { seconds: 2 }),
}),
];
vi.mocked(api.getCameraManager().executeMediaQueries).mockResolvedValue(media);
const query = new EventMediaQueries([
{
type: QueryType.Event as const,
cameraIDs: new Set(['camera.office']),
},
]);
const executor = new QueryExecutor(api);
const results = await executor.execute(query, {
selectResult: { time: { time: add(now, { seconds: 1 }) } },
});
expect(results?.getSelectedResult()?.getID()).toBe('id-camera.office-1');
});
});
+41
View File
@@ -0,0 +1,41 @@
import { vi } from 'vitest';
import { CardController } from '../../../src/card-controller/controller';
import { RawFrigateCardConfig } from '../../../src/config/types';
import {
createCameraManager,
createCapabilities,
createCardAPI,
createConfig,
createStore,
} from '../../test-utils';
export const createPopulatedAPI = (config?: RawFrigateCardConfig): CardController => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.office',
capabilities: createCapabilities({
live: true,
snapshots: true,
clips: true,
recordings: true,
substream: true,
}),
},
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({
live: true,
snapshots: true,
clips: true,
recordings: true,
substream: true,
}),
},
]),
);
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig(config));
return api;
};
@@ -0,0 +1,363 @@
import { ViewContext } from 'view';
import { describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { ViewFactory } from '../../../src/card-controller/view/factory';
import { ViewManager } from '../../../src/card-controller/view/view-manager';
import { FrigateCardView } from '../../../src/config/types';
import {
createCameraManager,
createCapabilities,
createCardAPI,
createStore,
createView,
} from '../../test-utils';
import { ViewMedia } from '../../../src/view/media';
import { MediaQueriesResults } from '../../../src/view/media-queries-results';
describe('should act correctly when view is set', () => {
it('basic view', () => {
const view = createView({
view: 'live',
camera: 'camera',
displayMode: 'grid',
});
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(view);
const api = createCardAPI();
const manager = new ViewManager(api, factory);
manager.setViewDefault();
expect(manager.getView()).toBe(view);
expect(manager.hasView()).toBeTruthy();
expect(api.getMediaLoadedInfoManager().clear).toBeCalled();
expect(api.getCardElementManager().scrollReset).toBeCalled();
expect(api.getMessageManager().reset).toBeCalled();
expect(api.getStyleManager().setExpandedMode).toBeCalled();
expect(api.getConditionsManager()?.setState).toBeCalledWith({
view: 'live',
camera: 'camera',
displayMode: 'grid',
});
expect(api.getCardElementManager().update).toBeCalled();
});
it('view with minor changes without media clearing or scroll', () => {
const view_1 = createView({
view: 'live',
camera: 'camera',
});
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(view_1);
const api = createCardAPI();
const manager = new ViewManager(api, factory);
manager.setViewDefault();
vi.mocked(api.getMediaLoadedInfoManager().clear).mockClear();
vi.mocked(api.getCardElementManager().scrollReset).mockClear();
const view_2 = createView({
view: 'live',
camera: 'camera',
displayMode: 'single',
});
factory.getViewDefault.mockReturnValue(view_2);
manager.setViewDefault();
expect(manager.getView()).toBe(view_2);
// The new view is neither a major media change, nor a different view name,
// so media clearing and scrolling should not happen.
expect(api.getMediaLoadedInfoManager().clear).not.toBeCalled();
expect(api.getCardElementManager().scrollReset).not.toBeCalled();
});
});
it('setViewWithMergedContext', () => {
const api = createCardAPI();
const factory = mock<ViewFactory>();
const manager = new ViewManager(api, factory);
const context: ViewContext = { timeline: {} };
// Setting context with no existing view does nothing.
manager.setViewWithMergedContext(context);
expect(manager.getView()).toBeNull();
const view = createView({
view: 'live',
camera: 'camera',
});
factory.getViewDefault.mockReturnValue(view);
manager.setViewDefault();
manager.setViewWithMergedContext(context);
expect(manager.getView()?.camera).toBe('camera');
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.context).toEqual(context);
});
it('getEpoch', () => {
const factory = mock<ViewFactory>();
const manager = new ViewManager(createCardAPI(), factory);
expect(manager.getEpoch()).toBeTruthy();
expect(manager.getEpoch().manager).toBe(manager);
});
it('reset', () => {
const factory = mock<ViewFactory>();
const manager = new ViewManager(createCardAPI(), factory);
manager.reset();
expect(manager.getView()).toBeNull();
expect(manager.hasView()).toBeFalsy();
factory.getViewDefault.mockReturnValue(createView());
manager.setViewDefault();
expect(manager.getView()).not.toBeNull();
expect(manager.hasView()).toBeTruthy();
manager.reset();
expect(manager.getView()).toBeNull();
expect(manager.hasView()).toBeFalsy();
});
it('setViewDefault', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('setViewByParameters', () => {
const factory = mock<ViewFactory>();
factory.getViewByParameters.mockReturnValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewByParameters();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('setViewDefaultWithNewQuery', async () => {
const factory = mock<ViewFactory>();
factory.getViewDefaultWithNewQuery.mockResolvedValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
await manager.setViewDefaultWithNewQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('setViewByParametersWithNewQuery', async () => {
const factory = mock<ViewFactory>();
factory.getViewByParametersWithNewQuery.mockResolvedValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
await manager.setViewByParametersWithNewQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
it('setViewByParametersWithExistingQuery', async () => {
const factory = mock<ViewFactory>();
factory.getViewByParametersWithExistingQuery.mockResolvedValue(createView());
const manager = new ViewManager(createCardAPI(), factory);
await manager.setViewByParametersWithExistingQuery();
expect(manager.getView()?.view).toBe('live');
expect(manager.getView()?.camera).toBe('camera');
});
describe('should handle exceptions', () => {
it('non-async', () => {
const factory = mock<ViewFactory>();
const error = new Error();
factory.getViewDefault.mockImplementation(() => {
throw error;
});
const api = createCardAPI();
const manager = new ViewManager(api, factory);
manager.setViewDefault();
expect(manager.hasView()).toBeFalsy();
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
});
it('async', async () => {
const factory = mock<ViewFactory>();
const error = new Error();
factory.getViewByParametersWithNewQuery.mockRejectedValue(error);
const api = createCardAPI();
const manager = new ViewManager(api, factory);
await manager.setViewByParametersWithNewQuery();
expect(manager.hasView()).toBeFalsy();
expect(api.getMessageManager().setErrorIfHigherPriority).toBeCalledWith(error);
});
});
describe('isViewSupportedByCamera', () => {
it.each([
['live' as const, false],
['image' as const, true],
['diagnostics' as const, true],
['clip' as const, false],
['clips' as const, false],
['snapshot' as const, false],
['snapshots' as const, false],
['recording' as const, false],
['recordings' as const, false],
['timeline' as const, false],
['media' as const, false],
])('%s', (viewName: FrigateCardView, expected: boolean) => {
const api = createCardAPI();
vi.mocked(api.getCameraManager).mockReturnValue(createCameraManager());
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera.kitchen',
capabilities: createCapabilities({
live: false,
'favorite-events': false,
'favorite-recordings': false,
seek: false,
clips: false,
recordings: false,
snapshots: false,
}),
},
]),
);
const manager = new ViewManager(api);
expect(manager.isViewSupportedByCamera('camera', viewName)).toBe(expected);
});
});
describe('hasMajorMediaChange', () => {
it('should consider undefined views as major', () => {
const manager = new ViewManager(createCardAPI());
expect(manager.hasMajorMediaChange(undefined)).toBeFalsy();
expect(manager.hasMajorMediaChange(createView())).toBeTruthy();
expect(manager.hasMajorMediaChange()).toBeFalsy();
});
it('should consider view change as major', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ view: 'live' }));
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(manager.hasMajorMediaChange(createView({ view: 'clips' }))).toBeTruthy();
});
it('should consider camera change as major', () => {
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ camera: 'camera-1' }));
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(manager.hasMajorMediaChange(createView({ camera: 'camera-2' }))).toBeTruthy();
});
it('should consider live substream change as major in live view', () => {
const overrides_1: Map<string, string> = new Map();
overrides_1.set('camera', 'camera-2');
const overrides_2: Map<string, string> = new Map();
overrides_2.set('camera', 'camera-3');
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(
createView({ context: { live: { overrides: overrides_1 } } }),
);
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(
createView({ context: { live: { overrides: overrides_2 } } }),
),
).toBeTruthy();
});
it('should not consider live substream change as major in other view', () => {
const overrides_1: Map<string, string> = new Map();
overrides_1.set('camera', 'camera-2');
const overrides_2: Map<string, string> = new Map();
overrides_2.set('camera', 'camera-3');
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(
createView({ view: 'clips', context: { live: { overrides: overrides_1 } } }),
);
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(
createView({ view: 'clips', context: { live: { overrides: overrides_2 } } }),
),
).toBeFalsy();
});
it('should consider result change as major in other view', () => {
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(
createView({ view: 'media', queryResults: queryResults_1 }),
);
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(
createView({ view: 'media', queryResults: queryResults_2 }),
),
).toBeTruthy();
});
it('should not consider selected result change as major in live view', () => {
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
const factory = mock<ViewFactory>();
factory.getViewDefault.mockReturnValue(createView({ queryResults: queryResults_1 }));
const manager = new ViewManager(createCardAPI(), factory);
manager.setViewDefault();
expect(
manager.hasMajorMediaChange(createView({ queryResults: queryResults_2 })),
).toBeFalsy();
});
});
@@ -1,26 +1,15 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LiveController } from '../../../src/components-lib/live/live-controller';
import { dispatchMessageEvent } from '../../../src/components/message';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
} from '../../../src/utils/media-to-view';
import { EventMediaQueries } from '../../../src/view/media-queries';
import {
IntersectionObserverMock,
callIntersectionHandler,
createCameraManager,
createConfig,
createLitElement,
createMediaLoadedInfo,
createMediaLoadedInfoEvent,
createParent,
createView,
createViewChangeEvent,
} from '../../test-utils';
vi.mock('../../../src/utils/media-to-view');
// @vitest-environment jsdom
describe('LiveController', () => {
beforeEach(() => {
@@ -168,210 +157,4 @@ describe('LiveController', () => {
expect(eventListener).toBeCalledTimes(1);
expect(controller.getRenderEpoch()).toBe(secondRenderEpoch);
});
it('should handle view change', () => {
const host = createLitElement();
const parent = createParent({ children: [host] });
const eventListener = vi.fn();
parent.addEventListener('frigate-card:view:change', eventListener);
const controller = new LiveController(host);
controller.hostConnected();
const view = createView();
callIntersectionHandler(false);
expect(controller.isInBackground()).toBeTruthy();
host.dispatchEvent(createViewChangeEvent(view));
expect(eventListener).toBeCalledTimes(0);
callIntersectionHandler(true);
expect(controller.isInBackground()).toBeFalsy();
host.dispatchEvent(createViewChangeEvent(view));
expect(eventListener).toBeCalledTimes(1);
});
describe('should fetch media', () => {
it('when in background', async () => {
const controller = new LiveController(createLitElement());
callIntersectionHandler(false);
await controller.fetchMediaInBackgroundIfNecessary(
createView(),
createCameraManager(),
{},
createConfig().live,
);
expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled();
expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled();
});
it('when has existing query', async () => {
const controller = new LiveController(createLitElement());
await controller.fetchMediaInBackgroundIfNecessary(
createView({ query: new EventMediaQueries() }),
createCameraManager(),
{},
createConfig().live,
);
expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled();
expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled();
});
it('when has no thumbnails', async () => {
const controller = new LiveController(createLitElement());
await controller.fetchMediaInBackgroundIfNecessary(
createView(),
createCameraManager(),
{},
createConfig({
live: {
controls: {
thumbnails: {
mode: 'none',
},
},
},
}).live,
);
expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled();
expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled();
});
it('when fetch disabled in context', async () => {
const controller = new LiveController(createLitElement());
await controller.fetchMediaInBackgroundIfNecessary(
createView({
context: {
live: {
fetchThumbnails: false,
},
},
}),
createCameraManager(),
{},
createConfig().live,
);
expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled();
expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled();
});
describe('with fetch', () => {
const now = new Date('2024-04-07T19:43');
beforeAll(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
});
afterAll(() => {
vi.useRealTimers();
});
it('events', async () => {
const host = createLitElement();
const controller = new LiveController(host);
const view = createView();
const cameraManager = createCameraManager();
const cardWideConfig = {};
await controller.fetchMediaInBackgroundIfNecessary(
view,
cameraManager,
cardWideConfig,
createConfig({
live: {
controls: {
thumbnails: {
media_type: 'events',
events_media_type: 'all',
},
timeline: {
window_seconds: 3600,
},
},
},
}).live,
);
expect(changeViewToRecentEventsForCameraAndDependents).toBeCalledWith(
host,
cameraManager,
cardWideConfig,
view,
expect.objectContaining({
allCameras: false,
targetView: 'live',
eventsMediaType: 'all',
select: 'latest',
viewContext: expect.objectContaining({
timeline: {
window: {
start: new Date('2024-04-07T18:43'),
end: now,
},
},
}),
}),
);
expect(changeViewToRecentRecordingForCameraAndDependents).not.toBeCalled();
});
it('recordings', async () => {
const host = createLitElement();
const controller = new LiveController(host);
const view = createView();
const cameraManager = createCameraManager();
const cardWideConfig = {};
await controller.fetchMediaInBackgroundIfNecessary(
view,
cameraManager,
cardWideConfig,
createConfig({
live: {
controls: {
thumbnails: {
media_type: 'recordings',
},
timeline: {
window_seconds: 3600,
},
},
},
}).live,
);
expect(changeViewToRecentEventsForCameraAndDependents).not.toBeCalled();
expect(changeViewToRecentRecordingForCameraAndDependents).toBeCalledWith(
host,
cameraManager,
cardWideConfig,
view,
expect.objectContaining({
allCameras: false,
targetView: 'live',
select: 'latest',
viewContext: expect.objectContaining({
timeline: {
window: {
start: new Date('2024-04-07T18:43'),
end: now,
},
},
}),
}),
);
});
});
});
});
@@ -1,8 +1,10 @@
import { endOfDay, startOfDay, sub } from 'date-fns';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { Capabilities } from '../../src/camera-manager/capabilities';
import { CameraManagerStore } from '../../src/camera-manager/store';
import { QueryType } from '../../src/camera-manager/types';
import { ViewManager } from '../../src/card-controller/view/view-manager';
import {
MediaFilterController,
MediaFilterCoreDefaults,
@@ -10,7 +12,6 @@ import {
MediaFilterCoreWhen,
MediaFilterMediaType,
} from '../../src/components-lib/media-filter-controller';
import { executeMediaQueryForViewWithErrorDispatching } from '../../src/utils/media-to-view';
import {
EventMediaQueries,
MediaQueries,
@@ -26,8 +27,6 @@ import {
createView,
} from '../test-utils';
vi.mock('../../src/utils/media-to-view');
const createCameraStore = (options?: {
capabilities: Capabilities;
}): CameraManagerStore => {
@@ -295,59 +294,82 @@ describe('MediaFilterController', () => {
describe('should get correct controls to show', () => {
it('view with events', () => {
const view = createView({ query: new EventMediaQueries() });
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new EventMediaQueries() }),
);
const cameraManager = createCameraManager();
const controller = new MediaFilterController(createLitElement());
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
controller.setViewManager(viewManager);
expect(controller.getControlsToShow(cameraManager)).toMatchObject({
events: true,
recordings: false,
});
});
it('view with recordings', () => {
const view = createView({ query: new RecordingMediaQueries() });
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new RecordingMediaQueries() }),
);
const cameraManager = createCameraManager();
const controller = new MediaFilterController(createLitElement());
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
controller.setViewManager(viewManager);
expect(controller.getControlsToShow(cameraManager)).toMatchObject({
events: false,
recordings: true,
});
});
it('can favorite events', () => {
const view = createView({ query: new EventMediaQueries() });
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new EventMediaQueries() }),
);
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({ 'favorite-events': true }),
);
const controller = new MediaFilterController(createLitElement());
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
controller.setViewManager(viewManager);
expect(controller.getControlsToShow(cameraManager)).toMatchObject({
favorites: true,
});
});
it('can favorite recordings', () => {
const view = createView({ query: new RecordingMediaQueries() });
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({ query: new RecordingMediaQueries() }),
);
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getAggregateCameraCapabilities).mockReturnValue(
createCapabilities({ 'favorite-recordings': true }),
);
const controller = new MediaFilterController(createLitElement());
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
controller.setViewManager(viewManager);
expect(controller.getControlsToShow(cameraManager)).toMatchObject({
favorites: true,
});
});
it('can not favorite without a query', () => {
const view = createView();
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(createView());
const cameraManager = createCameraManager();
const controller = new MediaFilterController(createLitElement());
expect(controller.getControlsToShow(cameraManager, view)).toMatchObject({
controller.setViewManager(viewManager);
expect(controller.getControlsToShow(cameraManager)).toMatchObject({
favorites: false,
});
});
@@ -355,40 +377,34 @@ describe('MediaFilterController', () => {
describe('should handle value change', () => {
it('must have visible cameras', async () => {
const host = createLitElement();
const controller = new MediaFilterController(host);
await controller.valueChangeHandler(
createCameraManager(),
createView(),
{},
{ when: {} },
);
const viewManager = mock<ViewManager>();
expect(host.requestUpdate).not.toBeCalled();
const controller = new MediaFilterController(createLitElement());
controller.setViewManager(viewManager);
await controller.valueChangeHandler(createCameraManager(), {}, { when: {} });
expect(viewManager.setViewByParametersWithExistingQuery).not.toBeCalled();
});
describe('with events media type', () => {
it.each([['clips' as const], ['snapshots' as const]])(
'%s',
async (viewName: 'clips' | 'snapshots') => {
const eventListener = vi.fn();
const host = createLitElement();
host.addEventListener('frigate-card:view:change', eventListener);
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(createView());
const controller = new MediaFilterController(host);
const cameraManager = createCameraManager();
const view = createView();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
vi.mocked(executeMediaQueryForViewWithErrorDispatching).mockResolvedValueOnce(
view,
);
controller.setViewManager(viewManager);
const cameraManager = createCameraManager(createCameraStore());
const from = new Date('2024-02-06T21:59');
const to = new Date('2024-02-06T22:00');
await controller.valueChangeHandler(
cameraManager,
view,
{
performance: createPerformanceConfig({
features: {
@@ -397,6 +413,7 @@ describe('MediaFilterController', () => {
}),
},
{
camera: 'camera.kitchen',
mediaType:
viewName === 'clips'
? MediaFilterMediaType.Clips
@@ -412,20 +429,14 @@ describe('MediaFilterController', () => {
},
);
expect(vi.mocked(executeMediaQueryForViewWithErrorDispatching)).toBeCalledWith(
host,
cameraManager,
view,
expect.anything(),
{
targetCameraID: 'camera.kitchen',
targetView: viewName,
},
);
expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({
params: expect.objectContaining({
camera: 'camera.kitchen',
view: viewName,
}),
});
expect(
vi
.mocked(executeMediaQueryForViewWithErrorDispatching)
.mock.calls[0][3].getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -442,31 +453,29 @@ describe('MediaFilterController', () => {
},
]);
expect(eventListener).toBeCalled();
expect(host.requestUpdate).toBeCalled();
},
);
});
it('with recordings media type', async () => {
const eventListener = vi.fn();
const host = createLitElement();
host.addEventListener('frigate-card:view:change', eventListener);
const cameraManager = createCameraManager(createCameraStore());
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(createView());
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
const controller = new MediaFilterController(host);
const cameraManager = createCameraManager();
const view = createView();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
vi.mocked(executeMediaQueryForViewWithErrorDispatching).mockResolvedValueOnce(
view,
);
controller.setViewManager(viewManager);
const from = new Date('2024-02-06T21:59');
const to = new Date('2024-02-06T22:00');
await controller.valueChangeHandler(
cameraManager,
view,
{
performance: createPerformanceConfig({
features: {
@@ -484,20 +493,15 @@ describe('MediaFilterController', () => {
},
);
expect(vi.mocked(executeMediaQueryForViewWithErrorDispatching)).toBeCalledWith(
host,
cameraManager,
view,
expect.anything(),
{
targetCameraID: 'camera.kitchen',
targetView: 'recordings',
},
);
expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({
params: expect.objectContaining({
camera: 'camera.kitchen',
view: 'recordings',
}),
});
expect(
vi
.mocked(executeMediaQueryForViewWithErrorDispatching)
.mock.calls[0][3].getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -509,18 +513,21 @@ describe('MediaFilterController', () => {
},
]);
expect(eventListener).toBeCalled();
expect(host.requestUpdate).toBeCalled();
});
it('without favorites', async () => {
const controller = new MediaFilterController(createLitElement());
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(createView());
const controller = new MediaFilterController(createLitElement());
controller.setViewManager(viewManager);
await controller.valueChangeHandler(
cameraManager,
createView(),
{},
{
mediaType: MediaFilterMediaType.Recordings,
@@ -528,10 +535,15 @@ describe('MediaFilterController', () => {
},
);
expect(viewManager.setViewByParametersWithExistingQuery).toBeCalledWith({
params: expect.objectContaining({
camera: 'camera.kitchen',
view: 'recordings',
}),
});
expect(
vi
.mocked(executeMediaQueryForViewWithErrorDispatching)
.mock.calls[0][3].getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -575,13 +587,17 @@ describe('MediaFilterController', () => {
new Date('2024-02-29T23:59:59.999'),
],
])('%s', async (value: MediaFilterCoreWhen | string, from: Date, to: Date) => {
const controller = new MediaFilterController(createLitElement());
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(createView());
const controller = new MediaFilterController(createLitElement());
controller.setViewManager(viewManager);
await controller.valueChangeHandler(
cameraManager,
createView(),
{},
{
mediaType: MediaFilterMediaType.Recordings,
@@ -590,11 +606,8 @@ describe('MediaFilterController', () => {
},
},
);
expect(
vi
.mocked(executeMediaQueryForViewWithErrorDispatching)
.mock.calls[0][3].getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -606,13 +619,17 @@ describe('MediaFilterController', () => {
});
it('custom without values', async () => {
const controller = new MediaFilterController(createLitElement());
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(createView());
const controller = new MediaFilterController(createLitElement());
controller.setViewManager(viewManager);
await controller.valueChangeHandler(
cameraManager,
createView(),
{},
{
mediaType: MediaFilterMediaType.Recordings,
@@ -623,9 +640,7 @@ describe('MediaFilterController', () => {
);
expect(
vi
.mocked(executeMediaQueryForViewWithErrorDispatching)
.mock.calls[0][3].getQueries(),
viewManager.setViewByParametersWithExistingQuery.mock.calls[0][0]?.params?.query?.getQueries(),
).toEqual([
{
cameraIDs: new Set(['camera.kitchen']),
@@ -638,24 +653,25 @@ describe('MediaFilterController', () => {
describe('should calculate correct defaults', () => {
it('with no queries', () => {
const controller = new MediaFilterController(createLitElement());
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(createView());
controller.computeInitialDefaultsFromView(createCameraManager(), createView());
const controller = new MediaFilterController(createLitElement());
controller.setViewManager(viewManager);
controller.computeInitialDefaultsFromView(createCameraManager());
expect(controller.getDefaults()).toBeNull();
});
it('with no cameras', () => {
const controller = new MediaFilterController(createLitElement());
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(createView());
controller.computeInitialDefaultsFromView(
createCameraManager(),
createView({
query: new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera.kitchen']) },
]),
}),
);
const controller = new MediaFilterController(createLitElement());
controller.setViewManager(viewManager);
controller.computeInitialDefaultsFromView(createCameraManager());
expect(controller.getDefaults()).toBeNull();
});
@@ -948,17 +964,21 @@ describe('MediaFilterController', () => {
mediaQueries: MediaQueries,
defaults: MediaFilterCoreDefaults | null,
) => {
const controller = new MediaFilterController(createLitElement());
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
controller.computeInitialDefaultsFromView(
cameraManager,
const viewManager = mock<ViewManager>();
viewManager.getView.mockReturnValue(
createView({
query: mediaQueries,
}),
);
const controller = new MediaFilterController(createLitElement());
controller.setViewManager(viewManager);
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore).mockReturnValue(createCameraStore());
controller.computeInitialDefaultsFromView(cameraManager);
expect(controller.getDefaults()).toEqual(defaults);
},
);

Some files were not shown because too many files have changed in this diff Show More