Merge pull request #1251 from dermotduffy/grid

Add ability to show media in a multi-camera grid
This commit is contained in:
Dermot Duffy
2023-09-06 19:59:15 -07:00
committed by GitHub
101 changed files with 7213 additions and 2380 deletions
+2 -1
View File
@@ -5,5 +5,6 @@
"i18n-ally.keepFulfilled": true, "i18n-ally.keepFulfilled": true,
"i18n-ally.editor.preferEditor": true, "i18n-ally.editor.preferEditor": true,
"i18n-ally.translate.saveAsCandidates": true, "i18n-ally.translate.saveAsCandidates": true,
"vitest.commandLine": "npx vitest --root ." "vitest.commandLine": "npx vitest --root .",
"diffEditor.experimental.useVersion2": true
} }
+99
View File
@@ -474,6 +474,7 @@ See the [fully expanded live configuration example](#config-expanded-live) for h
| `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. | | `controls` | | :white_check_mark: | Configuration for the `live` view controls. See below. |
| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.| | `layout` | | :white_check_mark: | See [media layout](#media-layout) below.|
| `microphone` | | :white_check_mark: | See [microphone](#microphone) below.| | `microphone` | | :white_check_mark: | See [microphone](#microphone) below.|
| `display` | | :white_check_mark: | See [display](#live-display) below.|
#### Live Controls #### Live Controls
@@ -579,6 +580,35 @@ live:
See [Using 2-way audio](#using-2-way-audio) for more information about the very particular requirements that must be followed for 2-way audio to work. See [Using 2-way audio](#using-2-way-audio) for more information about the very particular requirements that must be followed for 2-way audio to work.
<a name="live-display"></a>
#### Live: Display
All configuration is under:
```yaml
live:
display:
```
| Option | Default | Overridable | Description |
| - | - | - | - |
| `mode` | `single` | :white_check_mark: | Whether to display a `single` media item at a time, or a media item for all cameras in a `grid` configuration.|
| `grid_selected_width_factor` | `2` | :white_check_mark: | How much to scale up the selected media item in a grid. A value of `1` will not scale the selected item at all, the default value of `2` will scale the media item width to twice what it would otherwise be, etc. |
| `grid_columns` | | :white_check_mark: | If specified the grid will always have exactly this number of columns.|
| `grid_max_columns` | `4` | :white_check_mark: | If specified, and `grid_columns` is not specified, the grid will not render more than this number of columns. The precise number will be calculated based on the [grid layout algorithm](#grid-layout-algorith). |
<a name="grid-layout-algorithm"></a>
##### Grid Layout Algorithm
The grid will lay out cameras roughly in the order they are specified in the config (items may be moved to optimize grid 'density'). The following algorithm is used to calculate the number of columns. This attempts to offers a balance between configurability, reasonable display in a typical Lovelace card width and reasonable display in a typical fullscreen display.
* Use `grid_columns` if specified.
* Otherwise, use the largest number of columns in the range `[2 - grid_max_columns]` that will fit at least a `600px` column width.
* Otherwise, use the largest number of columns in the range `[2 - grid_max_columns]` that will fit at least a `190px` column width.
* Otherwise, there will be `1` column only.
### Media Viewer Options ### Media Viewer Options
The `media_viewer` is used for viewing all `clip`, `snapshot` or recording media, in a media carousel. The `media_viewer` is used for viewing all `clip`, `snapshot` or recording media, in a media carousel.
@@ -689,6 +719,24 @@ media_viewer:
| `mode` | `popup-bottom-right` | :heavy_multiplication_x: | How to display the Media viewer media title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . | | `mode` | `popup-bottom-right` | :heavy_multiplication_x: | How to display the Media viewer media title. Acceptable values: `none`, `popup-top-left`, `popup-top-right`, `popup-bottom-left`, `popup-bottom-right` . |
| `duration_seconds` | `2` | :heavy_multiplication_x: | The number of seconds to display the title popup. `0` implies forever.| | `duration_seconds` | `2` | :heavy_multiplication_x: | The number of seconds to display the title popup. `0` implies forever.|
<a name="media-viewer-display"></a>
#### Media Viewer: Display
All configuration is under:
```yaml
media_viewer:
display:
```
| Option | Default | Overridable | Description |
| - | - | - | - |
| `mode` | `single` | :white_check_mark: | Whether to display a `single` media item at a time, or a media item for all cameras in a `grid` configuration.|
| `grid_selected_width_factor` | `2` | :white_check_mark: | How much to scale up the selected media item in a grid. A value of `1` will not scale the selected item at all, the default value of `2` will scale the media item width to twice what it would otherwise be, etc. |
| `grid_columns` | | :white_check_mark: | If specified the grid will always have exactly this number of columns.|
| `grid_max_columns` | `4` | :white_check_mark: | If specified, and `grid_columns` is not specified, the grid will not render more than this number of columns. The precise number will be calculated based on the [grid layout algorithm](#grid-layout-algorith). |
<a name="media-gallery-options"></a> <a name="media-gallery-options"></a>
### Media Gallery Options ### Media Gallery Options
@@ -806,6 +854,17 @@ timeline:
<a name="dimensions"></a> <a name="dimensions"></a>
#### Timeline Seek Behavior
The behavior of the timeline during seeking/dragging can be controlled by means of the icon on the bottom-right of the timeline.
| Option | Behavior |
| - | - |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>filmstrip-box-multiple</title><path d="M4,6H2V20A2,2 0 0,0 4,22H18V20H4V6M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2M10,15H8V13H10V15M10,11H8V9H10V11M10,7H8V5H10V7M20,15H18V13H20V15M20,11H18V9H20V11M20,7H18V5H20V7Z" /></svg> | Dragging the timeline will seek / select across all available media from all cameras, selecting the media item with the longest duration whilst favoring (but not limited to) the currently selected camera. |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>play-box-lock</title><path d="M23 17.3V20.8C23 21.4 22.4 22 21.7 22H16.2C15.6 22 15 21.4 15 20.7V17.2C15 16.6 15.6 16 16.2 16V14.5C16.2 13.1 17.6 12 19 12C20.4 12 21.8 13.1 21.8 14.5V16C22.4 16 23 16.6 23 17.3M13 19V21H4C2.89 21 2 20.1 2 19V5C2 3.89 2.89 3 4 3H18C19.1 3 20 3.89 20 5V10.1L19 10L18 10.1C15.79 10.55 14.12 12.45 14 14.76C13.39 15.31 13 16.11 13 17V19M20.5 14.5C20.5 13.7 19.8 13.2 19 13.2C18.2 13.2 17.5 13.7 17.5 14.5V16H20.5V14.5M9 8V16L14 12L9 8Z" /></svg> | Dragging the timeline will seek within the selected media item only. |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>camera-lock</title><path d="M4 4H7L9 2H15L17 4H20C21.11 4 22 4.89 22 6V12C21.16 11.37 20.13 11 19 11C18.21 11 17.46 11.18 16.79 11.5C16.18 9.22 14.27 7 12 7C9.24 7 7 9.24 7 12C7 14.76 9.24 17 12 17C12.42 17 12.84 16.95 13.23 16.85C13.08 17.2 13 17.59 13 18V20H4C2.9 20 2 19.11 2 18V6C2 4.89 2.9 4 4 4M12 9C13.66 9 15 10.34 15 12C15 13.66 13.66 15 12 15C10.34 15 9 13.66 9 12C9 10.34 10.34 9 12 9M23 18.3V21.8C23 22.4 22.4 23 21.7 23H16.2C15.6 23 15 22.4 15 21.7V18.2C15 17.6 15.6 17 16.2 17V15.5C16.2 14.1 17.6 13 19 13C20.4 13 21.8 14.1 21.8 15.5V17C22.4 17 23 17.6 23 18.3M20.5 15.5C20.5 14.7 19.8 14.2 19 14.2C18.2 14.2 17.5 14.7 17.5 15.5V17H20.5V15.5Z" /></svg> | Dragging the timeline will seek / select across all available media from the selected camera only, selecting the media item with the longest duration. |
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>pan-horizontal</title><path d="M7,8L2.5,12L7,16V8M17,8V16L21.5,12L17,8M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10Z" /></svg> | Dragging the timeline will pan only without selected or seeking any media. |
### Dimensions Options ### Dimensions Options
These options control the aspect-ratio of the entire card to make placement in These options control the aspect-ratio of the entire card to make placement in
@@ -1590,6 +1649,12 @@ Pan around a large camera view to only show part of the video feed in the card a
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/navigate-picture-elements.gif" alt="Taking card actions via the URL" width="400px"> <img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/navigate-picture-elements.gif" alt="Taking card actions via the URL" width="400px">
### Interacting with a camera grid
<img src="https://raw.githubusercontent.com/dermotduffy/frigate-hass-card/dev/images/grid-small.gif" alt="Interacting with a camera grid" width="400px">
## Examples
## Examples ## Examples
### Illustrative Expanded Configuration Reference ### Illustrative Expanded Configuration Reference
@@ -1946,6 +2011,10 @@ live:
microphone: microphone:
always_connected: false always_connected: false
disconnect_seconds: 60 disconnect_seconds: 60
display:
mode: single
grid_selected_width_factor: 2
grid_max_columns: 4
actions: actions:
entity: light.office_main_lights entity: light.office_main_lights
tap_action: tap_action:
@@ -2007,6 +2076,10 @@ media_viewer:
position: position:
x: 50 x: 50
y: 50 y: 50
display:
mode: single
grid_selected_width_factor: 2
grid_max_columns: 4
actions: actions:
entity: light.office_main_lights entity: light.office_main_lights
tap_action: tap_action:
@@ -3823,6 +3896,32 @@ automations:
``` ```
</details> </details>
### Grid display overrides
<details>
<summary>Expand: Change the grid layout configuration in fullscreen mode</summary>
This example will always render 5 columns in fullscreen mode in both the live
and media viewer views, and will not enlarge the selected item. The normal
auto-layout behavior will be used outside of fullscreen mode.
```yaml
overrides:
- conditions:
fullscreen: true
display_mode: grid
overrides:
live:
display:
grid_columns: 5
grid_selected_width_factor: 1
media_viewer:
display:
grid_columns: 5
grid_selected_width_factor: 1
```
</details>
<a name="media-layout-examples"></a> <a name="media-layout-examples"></a>
## Card Refreshes ## Card Refreshes
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

+7 -5
View File
@@ -27,13 +27,14 @@
"custom-card-helpers": "^1.9.0", "custom-card-helpers": "^1.9.0",
"date-fns": "^2.29.2", "date-fns": "^2.29.2",
"date-fns-tz": "^1.3.7", "date-fns-tz": "^1.3.7",
"embla-carousel": "^7.0.9", "embla-carousel": "8.0.0-rc12",
"embla-carousel-wheel-gestures": "^3.0.0", "embla-carousel-wheel-gestures": "8.0.0-rc04",
"home-assistant-js-websocket": "^8.0.0", "home-assistant-js-websocket": "^8.0.0",
"keycharm": "^0.4.0", "keycharm": "^0.4.0",
"lit": "^2.3.1", "lit": "^2.3.1",
"lit-flatpickr": "^0.4.0", "lit-flatpickr": "^0.4.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"masonry-layout": "^4.2.2",
"moment": "^2.29.4", "moment": "^2.29.4",
"propagating-hammerjs": "^2.0.1", "propagating-hammerjs": "^2.0.1",
"quick-lru": "^6.1.0", "quick-lru": "^6.1.0",
@@ -58,9 +59,10 @@
"@rollup/plugin-node-resolve": "^13.3.0", "@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-replace": "^4.0.0", "@rollup/plugin-replace": "^4.0.0",
"@types/lodash-es": "^4.17.5", "@types/lodash-es": "^4.17.5",
"@types/masonry-layout": "^4.2.5",
"@typescript-eslint/eslint-plugin": "^5.36.2", "@typescript-eslint/eslint-plugin": "^5.36.2",
"@typescript-eslint/parser": "^5.36.2", "@typescript-eslint/parser": "^5.36.2",
"@vitest/coverage-c8": "^0.29.8", "@vitest/coverage-istanbul": "^0.34.3",
"eslint": "^8.23.0", "eslint": "^8.23.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
@@ -78,8 +80,8 @@
"sass": "^1.54.9", "sass": "^1.54.9",
"ts-prune": "^0.10.3", "ts-prune": "^0.10.3",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"vitest": "^0.29.8", "vitest": "^0.34.3",
"vitest-mock-extended": "^1.1.3" "vitest-mock-extended": "^1.2.1"
}, },
"scripts": { "scripts": {
"start": "rollup -c --watch", "start": "rollup -c --watch",
+5 -2
View File
@@ -6,9 +6,9 @@ import type {
import { noChange } from 'lit'; import { noChange } from 'lit';
import { import {
AttributePart, AttributePart,
directive,
Directive, Directive,
DirectiveParameters, DirectiveParameters,
directive,
} from 'lit/directive.js'; } from 'lit/directive.js';
import { stopEventFromActivatingCardWideActions } from './utils/action.js'; import { stopEventFromActivatingCardWideActions } from './utils/action.js';
import { Timer } from './utils/timer.js'; import { Timer } from './utils/timer.js';
@@ -53,7 +53,10 @@ class ActionHandler extends HTMLElement implements ActionHandler {
}); });
} }
public bind(element: ActionHandlerElement, options): void { public bind(
element: ActionHandlerElement,
options: FrigateCardActionHandlerOptions,
): void {
if (element.actionHandlerOptions) { if (element.actionHandlerOptions) {
// Reset the options on an existing actionHandler. // Reset the options on an existing actionHandler.
element.actionHandlerOptions = options; element.actionHandlerOptions = options;
-2
View File
@@ -69,14 +69,12 @@ export class CachedValueController<T> implements ReactiveController {
public startTimer(): void { public startTimer(): void {
this.stopTimer(); this.stopTimer();
if (this._timerSeconds > 0) {
this._timerStartCallback?.(); this._timerStartCallback?.();
this._timer.startRepeated(this._timerSeconds, () => { this._timer.startRepeated(this._timerSeconds, () => {
this.updateValue(); this.updateValue();
this._host.requestUpdate(); this._host.requestUpdate();
}); });
} }
}
public hasTimer(): boolean { public hasTimer(): boolean {
return this._timer.isRunning(); return this._timer.isRunning();
+1 -1
View File
@@ -66,7 +66,7 @@ export class CameraManagerEngineFactory {
engine = Engine.MotionEye; engine = Engine.MotionEye;
} else if (cameraConfig.engine === 'generic') { } else if (cameraConfig.engine === 'generic') {
engine = Engine.Generic; engine = Engine.Generic;
} else if (cameraConfig.engine === 'auto') { } else {
const cameraEntity = getCameraEntityFromConfig(cameraConfig); const cameraEntity = getCameraEntityFromConfig(cameraConfig);
if (cameraEntity) { if (cameraEntity) {
+9 -10
View File
@@ -58,8 +58,8 @@ export class ExpiringMemoryRangeSet
} }
public add(range: ExpiringRange<Date>): void { public add(range: ExpiringRange<Date>): void {
this._expireOldRanges();
this._ranges.push(range); this._ranges.push(range);
this._expireOldRanges();
} }
protected _expireOldRanges(): void { protected _expireOldRanges(): void {
@@ -95,26 +95,25 @@ export const compressRanges = <T extends Date | number>(
ranges = orderBy(ranges, (range) => range.start, 'asc'); ranges = orderBy(ranges, (range) => range.start, 'asc');
let current: Range<T> | null = null; let current: Range<T> | null = null;
for (let i = 0; i < ranges.length; ++i) { for (const range of ranges) {
const item = ranges[i]; const rangeStartSeconds: number =
const itemStartSeconds = range.start instanceof Date ? range.start.getTime() : range.start;
item.start instanceof Date ? item.start.getTime() : item.start;
if (!current) { if (!current) {
current = { ...item }; current = { ...range };
continue; continue;
} }
const currentEndSeconds = const currentEndSeconds =
current.end instanceof Date ? current.end.getTime() : (current.end as number); current.end instanceof Date ? current.end.getTime() : (current.end as number);
if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) { if (currentEndSeconds + toleranceSeconds * 1000 >= rangeStartSeconds) {
if (item.end > current.end) { if (range.end > current.end) {
current.end = item.end; current.end = range.end;
} }
} else { } else {
compressedRanges.push(current); compressedRanges.push(current);
current = { ...item }; current = { ...range };
} }
} }
if (current) { if (current) {
+80 -10
View File
@@ -48,17 +48,18 @@ import {
CameraConfig, CameraConfig,
CardWideConfig, CardWideConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
FRIGATE_CARD_VIEW_DEFAULT,
FrigateCardConfig, FrigateCardConfig,
frigateCardConfigSchema, frigateCardConfigSchema,
FrigateCardCustomAction, FrigateCardCustomAction,
FrigateCardError, FrigateCardError,
FrigateCardView, FrigateCardView,
FRIGATE_CARD_VIEW_DEFAULT,
MediaLoadedInfo, MediaLoadedInfo,
MenuButton, MenuButton,
Message, Message,
MESSAGE_TYPE_PRIORITIES, MESSAGE_TYPE_PRIORITIES,
RawFrigateCardConfig, RawFrigateCardConfig,
ViewDisplayMode,
} from './types.js'; } from './types.js';
import { import {
convertActionToFrigateCardCustomAction, convertActionToFrigateCardCustomAction,
@@ -86,6 +87,7 @@ import { supportsFeature } from './utils/ha/update.js';
import { FrigateCardInitializer } from './utils/initializer.js'; import { FrigateCardInitializer } from './utils/initializer.js';
import { MediaLoadedInfoController } from './utils/media-info-controller'; import { MediaLoadedInfoController } from './utils/media-info-controller';
import { isValidMediaLoadedInfo } from './utils/media-info.js'; import { isValidMediaLoadedInfo } from './utils/media-info.js';
import { executeMediaQueryForView } from './utils/media-to-view';
import { MenuButtonController } from './utils/menu-controller'; import { MenuButtonController } from './utils/menu-controller';
import { MicrophoneController } from './utils/microphone'; import { MicrophoneController } from './utils/microphone';
import { getActionsFromQueryString } from './utils/querystring.js'; import { getActionsFromQueryString } from './utils/querystring.js';
@@ -97,6 +99,7 @@ import {
} from './utils/substream'; } from './utils/substream';
import { Timer } from './utils/timer'; import { Timer } from './utils/timer';
import { getParseErrorPaths } from './utils/zod.js'; import { getParseErrorPaths } from './utils/zod.js';
import { ViewMediaClassifier } from './view/media-classifier';
import { View } from './view/view.js'; import { View } from './view/view.js';
/** A note on media callbacks: /** A note on media callbacks:
@@ -414,6 +417,7 @@ class FrigateCard extends LitElement {
state: this._hass.states, state: this._hass.states,
}), }),
media_loaded: this._mediaLoadedInfoController.has(), media_loaded: this._mediaLoadedInfoController.has(),
displayMode: undefined,
}); });
} }
@@ -466,6 +470,7 @@ class FrigateCard extends LitElement {
this._conditionController?.setState({ this._conditionController?.setState({
view: this._view.view, view: this._view.view,
camera: this._view.camera, camera: this._view.camera,
displayMode: this._view.displayMode ?? undefined,
}); });
}; };
@@ -493,10 +498,14 @@ class FrigateCard extends LitElement {
} }
if (cameraID) { if (cameraID) {
const viewName = args?.viewName ?? this._getConfig().view.default;
const displayMode =
this._view?.displayMode ?? this._getDefaultDisplayModeForView(viewName);
changeView( changeView(
new View({ new View({
view: args?.viewName ?? this._getConfig().view.default, view: viewName,
camera: cameraID, camera: cameraID,
displayMode: displayMode,
}), }),
); );
@@ -641,7 +650,10 @@ class FrigateCard extends LitElement {
targetCamera && targetCamera &&
(this._view.camera !== targetCamera || !this._view.is('live')) (this._view.camera !== targetCamera || !this._view.is('live'))
) { ) {
this._changeView({ view: new View({ view: 'live', camera: targetCamera }) }); this._changeView({
viewName: 'live',
cameraID: targetCamera,
});
changedCamera = true; changedCamera = true;
} }
} }
@@ -1003,7 +1015,7 @@ class FrigateCard extends LitElement {
if (this._view.isViewerView() && media) { if (this._view.isViewerView() && media) {
media_content_id = media.getContentID(); media_content_id = media.getContentID();
media_content_type = media.getContentType(); media_content_type = ViewMediaClassifier.isVideo(media) ? 'video' : 'image';
title = media.getTitle(); title = media.getTitle();
thumbnail = media.getThumbnail(); thumbnail = media.getThumbnail();
} else if (this._view?.is('live') && cameraEntity) { } else if (this._view?.is('live') && cameraEntity) {
@@ -1049,11 +1061,30 @@ class FrigateCard extends LitElement {
} }
} }
protected _getDefaultDisplayModeForView(view: FrigateCardView): ViewDisplayMode {
let mode: ViewDisplayMode | null = null;
switch (view) {
case 'media':
case 'clip':
case 'clips':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
mode = this._getConfig().media_viewer.display?.mode ?? null;
break;
case 'live':
mode = this._getConfig().live.display?.mode ?? null;
break;
}
return mode ?? 'single';
}
protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void { protected _cardActionHandler(frigateCardAction: FrigateCardCustomAction): void {
// Note: This function needs to process (view-related) commands even when // 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 // _view has not yet been initialized (since it may be used to set a view
// via the querystring). // via the querystring).
if (!this._cameraManager) { if (!this._cameraManager || !this._hass) {
return; return;
} }
@@ -1119,7 +1150,8 @@ class FrigateCard extends LitElement {
? targetView ? targetView
: FRIGATE_CARD_VIEW_DEFAULT; : FRIGATE_CARD_VIEW_DEFAULT;
this._changeView({ this._changeView({
view: new View({ view: actualView, camera: selectCameraID }), viewName: actualView,
cameraID: selectCameraID,
}); });
} }
break; break;
@@ -1203,6 +1235,38 @@ class FrigateCard extends LitElement {
} }
}); });
break; break;
case 'display_mode_select':
const newView = this._view?.evolve({
displayMode: frigateCardAction.display_mode,
});
const generateNewQuery =
newView?.query &&
((newView.isGrid() && (newView.query.getQueryCameraIDs()?.size ?? 0) <= 1) ||
(!newView.isGrid() && (newView.query.getQueryCameraIDs()?.size ?? 0) > 1));
if (generateNewQuery && newView && newView.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.
executeMediaQueryForView(
this,
this._hass,
this._cameraManager,
newView,
newView.query
.clone()
.setQueryCameraIDs(
newView.isGrid()
? this._cameraManager.getStore().getVisibleCameraIDs()
: newView.camera,
),
).then((view) => view && this._changeView({ view: view }));
} else {
this._changeView({ view: newView });
}
break;
default: default:
console.warn(`Frigate card received unknown card action: ${action}`); console.warn(`Frigate card received unknown card action: ${action}`);
} }
@@ -1501,15 +1565,21 @@ class FrigateCard extends LitElement {
? `${lastKnown.width} / ${lastKnown.height}` ? `${lastKnown.width} / ${lastKnown.height}`
: 'unset', : 'unset',
); );
// Non-media mays have no intrinsic dimensions and so we need to explicit // Non-media may have no intrinsic dimensions (or multiple media items in a
// request the dialog to use all available space. // grid) and so we need to explicit request the dialog to use all available
// space.
const isGrid = this._view?.isGrid();
this.style.setProperty( this.style.setProperty(
'--frigate-card-expand-width', '--frigate-card-expand-width',
this._view?.isAnyMediaView() ? 'none' : 'var(--frigate-card-expand-max-width)', !isGrid && this._view?.isAnyMediaView()
? 'none'
: 'var(--frigate-card-expand-max-width)',
); );
this.style.setProperty( this.style.setProperty(
'--frigate-card-expand-height', '--frigate-card-expand-height',
this._view?.isAnyMediaView() ? 'none' : 'var(--frigate-card-expand-max-height)', !isGrid && this._view?.isAnyMediaView()
? 'none'
: 'var(--frigate-card-expand-max-height)',
); );
} }
+55 -224
View File
@@ -1,29 +1,20 @@
import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import { EmblaNodesType } from 'embla-carousel/components';
import {
CreatePluginType,
EmblaPluginsType,
LoosePluginType,
} from 'embla-carousel/components/Plugins';
import { import {
CSSResultGroup, CSSResultGroup,
html,
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
html,
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js';
import throttle from 'lodash-es/throttle';
import carouselStyle from '../scss/carousel.scss'; import carouselStyle from '../scss/carousel.scss';
import { TransitionEffect } from '../types'; import { TransitionEffect } from '../types';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import {
CarouselController,
export interface CarouselSelect { CarouselDirection,
index: number; } from '../utils/embla/carousel-controller';
element: HTMLElement;
}
export type EmblaCarouselPlugins = CreatePluginType< export type EmblaCarouselPlugins = CreatePluginType<
LoosePluginType, LoosePluginType,
@@ -33,249 +24,89 @@ export type EmblaCarouselPlugins = CreatePluginType<
@customElement('frigate-card-carousel') @customElement('frigate-card-carousel')
export class FrigateCardCarousel extends LitElement { export class FrigateCardCarousel extends LitElement {
@property({ attribute: true, reflect: true }) @property({ attribute: true, reflect: true })
public direction: 'vertical' | 'horizontal' = 'horizontal'; public direction: CarouselDirection = 'horizontal';
@property({ attribute: false })
public carouselOptions?: EmblaOptionsType;
@property({ attribute: false })
public carouselPlugins?: EmblaCarouselPlugins;
@property({ attribute: false })
public selected = 0;
@property({ attribute: true }) @property({ attribute: true })
public transitionEffect?: TransitionEffect; public transitionEffect?: TransitionEffect;
protected _refSlot: Ref<HTMLSlotElement> = createRef(); @property({ attribute: false })
public loop?: boolean;
protected _carousel?: EmblaCarouselType; @property({ attribute: false })
public dragFree?: boolean;
// Whether the carousel is actively scrolling. @property({ attribute: false })
protected _scrolling = false; public dragEnabled = true;
// Whether to reinit the carousel when it settles. @property({ attribute: false })
protected _reInitOnSettle = false; public plugins?: EmblaCarouselPlugins;
protected _carouselReInitInPlace = throttle( @property({ attribute: false })
this._carouselReInitInPlaceInternal.bind(this), public selected = 0;
500,
{ trailing: true }, protected _refParent: Ref<HTMLSlotElement> = createRef();
); protected _refRoot: Ref<HTMLElement> = createRef();
protected _carousel: CarouselController | null = null;
connectedCallback(): void { connectedCallback(): void {
super.connectedCallback(); super.connectedCallback();
// Guarantee a re-render if the component is reconnected. See note in // Guarantee recreation of carousel if the component is reconnected.
// disconnectedCallback().
this.requestUpdate(); this.requestUpdate();
} }
/**
* Component disconnected callback.
*/
disconnectedCallback(): void { disconnectedCallback(): void {
// Destroy the carousel when the component is disconnected, which forces the // Destroy the carousel when the component is disconnected, which forces the
// plugins (which may have registered event handlers) to also be destroyed. // plugins (which may have registered event handlers) to also be destroyed.
// The carousel will automatically reconstruct if the component is re-rendered. // The carousel will automatically reconstruct if the component is re-rendered.
this._destroyCarousel(); this._carousel?.destroy();
super.disconnectedCallback(); super.disconnectedCallback();
} }
/**
* Destroy the carousel if certain properties change.
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
const destroyProperties = [ if (changedProps.has('direction')) {
'direction', this.setAttribute('direction', this.direction);
'carouselOptions', }
'carouselPlugins',
] as const; const destroyProperties = ['direction', 'dragFree', 'transitionEffect'] as const;
if (destroyProperties.some((prop) => changedProps.has(prop))) { if (destroyProperties.some((prop) => changedProps.has(prop))) {
this._destroyCarousel(); this._carousel?.destroy();
this._carousel = null;
} }
} }
/**
* Get the selected slide.
* @returns A CarouselSelect object (index & element).
*/
public getCarouselSelected(): CarouselSelect | null {
const index = this._carousel?.selectedScrollSnap();
const element =
index !== undefined ? this._carousel?.slideNodes()[index] ?? null : null;
if (index !== undefined && element) {
return {
index: index,
element: element,
};
}
return null;
}
/**
* Get the carousel.
*/
public carousel(): EmblaCarouselType | null {
return this._carousel ?? null;
}
/**
* ReInit the carousel but stay on the current slide.
*/
protected _carouselReInitInPlaceInternal(): void {
const carouselReInit = (options?: EmblaOptionsType): void => {
// Allow the browser a moment to paint components that are inflight, to
// ensure accurate measurements are taken during the carousel
// reinitialization.
window.requestAnimationFrame(() => {
this._carousel?.reInit({ ...options });
});
};
carouselReInit({
startIndex: this.selected,
});
}
/**
* ReInit the carousel when it is safe to do so without disturbing the
* appearance (i.e. cutting off a scroll in progress).
*/
public carouselReInitWhenSafe(): void {
if (this._scrolling) {
this._reInitOnSettle = true;
} else {
this._carouselReInitInPlace();
}
}
/**
* Get the live carousel plugins.
*/
public getCarouselPlugins(): EmblaPluginsType | null {
return this._carousel?.plugins() ?? null;
}
/**
* The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (!this._carousel) {
this._initCarousel();
}
if (changedProperties.has('selected')) {
this._carousel?.scrollTo(this.selected, this.transitionEffect === 'none');
}
}
/**
* Destroy the carousel.
* @param options If `savePosition` is set the existing carousel position
* will be saved so it can be restored if the carousel is recreated.
*/
protected _destroyCarousel(): void {
if (this._carousel) {
this._carousel.destroy();
}
this._carousel = undefined;
}
/**
* Initialize the carousel.
*/
protected _initCarousel(): void {
const carouselNode = this.renderRoot.querySelector(
'.embla__viewport',
) as HTMLElement;
const nodes: EmblaNodesType = {
root: carouselNode,
// As the slides are slotted, need to explicitly pull them out and pass
// them to Embla.
slides: this._refSlot.value?.assignedElements({ flatten: true }) as HTMLElement[],
};
if (carouselNode && nodes.slides) {
this._carousel = EmblaCarousel(
nodes,
{
axis: this.direction == 'horizontal' ? 'x' : 'y',
speed: 30,
startIndex: this.selected,
...this.carouselOptions,
},
this.carouselPlugins,
);
const selectSlide = (): void => {
const selected = this.getCarouselSelected();
if (selected) {
dispatchFrigateCardEvent<CarouselSelect>(this, 'carousel:select', selected);
}
// Make sure every select causes a refresh to allow for re-paint of the
// next/previous controls.
this.requestUpdate();
};
this._carousel.on('init', selectSlide);
this._carousel.on('select', selectSlide);
this._carousel.on('scroll', () => {
this._scrolling = true;
});
this._carousel.on('settle', () => {
// Reinitialize the carousel if a request to reinitialize was made
// during scrolling (instead the request is handled after the scrolling
// has settled).
this._scrolling = false;
if (this._reInitOnSettle) {
this._reInitOnSettle = false;
this._carouselReInitInPlace();
}
});
this._carousel.on('settle', () => {
const selected = this.getCarouselSelected();
if (selected) {
dispatchFrigateCardEvent<CarouselSelect>(this, 'carousel:settle', selected);
}
});
}
}
/**
* Called when the slotted children in the carousel change.
*/
protected _slotChanged(): void {
// Cannot just re-init, because the slide elements themselves may have
// changed, and only a carousel init can pass in new (slotted) children. If
this._destroyCarousel();
this.requestUpdate();
}
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
const showPrevious = this.carouselOptions?.loop || this.selected > 0;
const showNext = this.carouselOptions?.loop || this.selected + 1 < slides.length;
return html` <div class="embla"> return html` <div class="embla">
${showPrevious ? html`<slot name="previous"></slot>` : ``} <slot name="previous"></slot>
<div class="embla__viewport"> <div ${ref(this._refRoot)} class="embla__viewport">
<div class="embla__container"> <div class="embla__container">
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged.bind(this)}></slot> <slot ${ref(this._refParent)}></slot>
</div> </div>
</div> </div>
${showNext ? html`<slot name="next"></slot>` : ``} <slot name="next"></slot>
</div>`; </div>`;
} }
/** protected updated(changedProps: PropertyValues): void {
* Get element styles. if (!this._carousel && this._refRoot.value && this._refParent.value) {
*/ this._carousel = new CarouselController(
this._refRoot.value,
this._refParent.value,
{
direction: this.direction,
dragEnabled: this.dragEnabled,
dragFree: this.dragFree,
startIndex: this.selected,
transitionEffect: this.transitionEffect,
loop: this.loop,
plugins: this.plugins,
},
);
} else if (changedProps.has('selected')) {
this._carousel?.selectSlide(this.selected);
}
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(carouselStyle); return unsafeCSS(carouselStyle);
} }
+12 -8
View File
@@ -13,7 +13,7 @@ import { SideDrawer } from 'side-drawer';
import drawerInjectStyle from '../scss/drawer-inject.scss'; import drawerInjectStyle from '../scss/drawer-inject.scss';
import drawerStyle from '../scss/drawer.scss'; import drawerStyle from '../scss/drawer.scss';
import { stopEventFromActivatingCardWideActions } from '../utils/action'; import { stopEventFromActivatingCardWideActions } from '../utils/action';
import { isHoverableDevice } from '../utils/basic'; import { getChildrenFromElement, isHoverableDevice } from '../utils/basic';
export interface DrawerIcons { export interface DrawerIcons {
open?: string; open?: string;
@@ -67,12 +67,14 @@ export class FrigateCardDrawer extends LitElement {
* Called when the slotted children in the drawer change. * Called when the slotted children in the drawer change.
*/ */
protected _slotChanged(): void { protected _slotChanged(): void {
const elements = this._refSlot.value?.assignedElements({ flatten: true }); const children = this._refSlot.value
? getChildrenFromElement(this._refSlot.value)
: [];
// Watch all slot children for size changes. // Watch all slot children for size changes.
this._resizeObserver.disconnect(); this._resizeObserver.disconnect();
for (const element of elements ?? []) { for (const child of children) {
this._resizeObserver.observe(element); this._resizeObserver.observe(child);
} }
this._hideDrawerIfNecessary(); this._hideDrawerIfNecessary();
} }
@@ -86,11 +88,13 @@ export class FrigateCardDrawer extends LitElement {
return; return;
} }
const elements = this._refSlot.value?.assignedElements({ flatten: true }); const children = this._refSlot.value
? getChildrenFromElement(this._refSlot.value)
: null;
this.empty = this.empty =
!elements || !children ||
!elements.length || !children.length ||
elements.every((element) => { children.every((element) => {
const box = element.getBoundingClientRect(); const box = element.getBoundingClientRect();
return !box.width || !box.height; return !box.width || !box.height;
}); });
-234
View File
@@ -1,234 +0,0 @@
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { CreateOptionsType } from 'embla-carousel/components/Options.js';
import { CreatePluginType } from 'embla-carousel/components/Plugins.js';
import {
AutoMuteCondition,
AutoPauseCondition,
AutoPlayCondition,
AutoUnmuteCondition,
FrigateCardMediaPlayer,
} from '../../types.js';
type OptionsType = CreateOptionsType<{
playerSelector?: string;
// Note: Neither play nor unmute will activate on selection. The caller is
// expected to call the `play()` or `unmute()` methods manually when the media
// is actually loaded (and not just when the slide is visible -- the browser
// cannot play media that is not actually loaded yet, e.g. lazy loading).
autoPlayCondition?: AutoPlayCondition;
autoUnmuteCondition?: AutoUnmuteCondition;
autoPauseCondition?: AutoPauseCondition;
autoMuteCondition?: AutoMuteCondition;
}>;
const defaultOptions: OptionsType = {
active: true,
breakpoints: {},
};
type AutoMediaOptionsType = Partial<OptionsType>
export type AutoMediaType = CreatePluginType<
{
play: () => void;
pause: () => void;
mute: () => void;
unmute: () => void;
},
AutoMediaOptionsType
>;
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
autoMedia?: AutoMediaType
}
}
/**
* An Embla plugin to take automated actions on media (e.g. pause, unmute, etc).
* @param userOptions
* @returns
*/
export function AutoMediaPlugin(
userOptions?: AutoMediaOptionsType,
): AutoMediaType {
const optionsHandler = EmblaCarousel.optionsHandler();
const optionsBase = optionsHandler.merge(
defaultOptions,
AutoMediaPlugin.globalOptions,
);
let options: AutoMediaType['options'];
let carousel: EmblaCarouselType;
let slides: HTMLElement[];
/**
* Initialize the plugin.
*/
function init(embla: EmblaCarouselType): void {
carousel = embla;
options = optionsHandler.atMedia(self.options);
slides = carousel.slideNodes();
// Frigate card media autoplays when the media loads not necessarily when the
// slide is selected, so only pause (and not play/unmute) based on carousel
// events.
carousel.on('destroy', pause);
if (
options.autoPauseCondition &&
['all', 'unselected'].includes(options.autoPauseCondition)
) {
carousel.on('select', pausePrevious);
}
carousel.on('destroy', mute);
if (
options.autoMuteCondition &&
['all', 'unselected'].includes(options.autoMuteCondition)
) {
carousel.on('select', mutePrevious);
}
document.addEventListener('visibilitychange', visibilityHandler);
}
/**
* Destroy the plugin.
*/
function destroy(): void {
carousel.off('destroy', pause);
if (
options.autoPauseCondition &&
['all', 'unselected'].includes(options.autoPauseCondition)
) {
carousel.off('select', pausePrevious);
}
carousel.off('destroy', mute);
if (
options.autoMuteCondition &&
['all', 'unselected'].includes(options.autoMuteCondition)
) {
carousel.off('select', mutePrevious);
}
document.removeEventListener('visibilitychange', visibilityHandler);
}
/**
* Handle document visibility changes.
*/
function visibilityHandler(): void {
if (document.visibilityState === 'hidden') {
if (
options.autoPauseCondition &&
['all', 'hidden'].includes(options.autoPauseCondition)
) {
pauseAll();
}
if (
options.autoMuteCondition &&
['all', 'hidden'].includes(options.autoMuteCondition)
) {
muteAll();
}
} else if (document.visibilityState === 'visible') {
if (
options.autoPlayCondition &&
['all', 'visible'].includes(options.autoPlayCondition)
) {
play();
}
if (
options.autoUnmuteCondition &&
['all', 'visible'].includes(options.autoUnmuteCondition)
) {
unmute();
}
}
}
/**
* Get the media player from a slide.
* @param slide
* @returns A FrigateCardMediaPlayer object or `null`.
*/
function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null {
return options.playerSelector
? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null)
: null;
}
/**
* Play the current slide.
*/
function play(): void {
getPlayer(slides[carousel.selectedScrollSnap()])?.play();
}
/**
* Pause the current slide.
*/
function pause(): void {
getPlayer(slides[carousel.selectedScrollSnap()])?.pause();
}
/**
* Pause the previous slide.
*/
function pausePrevious(): void {
getPlayer(slides[carousel.previousScrollSnap()])?.pause();
}
/**
* Pause all slides.
*/
function pauseAll(): void {
for (const slide of slides) {
getPlayer(slide)?.pause();
}
}
/**
* Unmute the current slide.
*/
function unmute(): void {
getPlayer(slides[carousel.selectedScrollSnap()])?.unmute();
}
/**
* Mute the current slide.
*/
function mute(): void {
getPlayer(slides[carousel.selectedScrollSnap()])?.mute();
}
/**
* Mute the previous slide.
*/
function mutePrevious(): void {
getPlayer(slides[carousel.previousScrollSnap()])?.mute();
}
/**
* Mute all slides.
*/
function muteAll(): void {
for (const slide of slides) {
getPlayer(slide)?.mute();
}
}
const self: AutoMediaType = {
name: 'autoMedia',
options: optionsHandler.merge(optionsBase, userOptions),
init,
destroy,
play,
pause,
mute,
unmute,
};
return self;
}
AutoMediaPlugin.globalOptions = <AutoMediaOptionsType | undefined>undefined;
+4 -2
View File
@@ -368,7 +368,9 @@ export class FrigateCardGalleryCore extends LitElement {
this.view this.view
?.evolve({ ?.evolve({
query: newMediaQueries, query: newMediaQueries,
queryResults: new MediaQueriesResults(extension.results).selectResultIfFound( queryResults: new MediaQueriesResults({
results: extension.results,
}).selectResultIfFound(
(media) => media === this.view?.queryResults?.getSelectedResult(), (media) => media === this.view?.queryResults?.getSelectedResult(),
), ),
}) })
@@ -468,7 +470,7 @@ export class FrigateCardGalleryCore extends LitElement {
this.view this.view
.evolve({ .evolve({
view: 'media', view: 'media',
queryResults: this.view.queryResults?.clone().selectResult( queryResults: this.view.queryResults?.clone().selectIndex(
// Media in the gallery is reversed vs the queryResults (see // Media in the gallery is reversed vs the queryResults (see
// note above). // note above).
this._media.length - index - 1, this._media.length - index - 1,
+3 -3
View File
@@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import liveImageStyle from '../../scss/live-image.scss'; import basicBlockStyle from '../../scss/basic-block.scss';
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js'; import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
import '../image.js'; import '../image.js';
import { getStateObjOrDispatchError } from './live.js'; import { getStateObjOrDispatchError } from './live.js';
@@ -50,7 +50,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
} }
public async getScreenshotURL(): Promise<string | null> { public async getScreenshotURL(): Promise<string | null> {
return await this._refImage.value?.getScreenshotURL() ?? null; return (await this._refImage.value?.getScreenshotURL()) ?? null;
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
@@ -77,7 +77,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(liveImageStyle); return unsafeCSS(basicBlockStyle);
} }
} }
+232 -182
View File
@@ -1,6 +1,4 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { EmblaOptionsType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { HassEntity } from 'home-assistant-js-websocket'; import { HassEntity } from 'home-assistant-js-websocket';
import { import {
CSSResultGroup, CSSResultGroup,
@@ -13,15 +11,16 @@ import {
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { guard } from 'lit/directives/guard.js'; import { guard } from 'lit/directives/guard.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { keyed } from 'lit/directives/keyed.js'; import { keyed } from 'lit/directives/keyed.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js'; import { CameraManager } from '../../camera-manager/manager.js';
import { CameraEndpoints } from '../../camera-manager/types.js'; import { CameraConfigs, CameraEndpoints } from '../../camera-manager/types.js';
import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js'; import { ConditionControllerEpoch, getOverriddenConfig } from '../../conditions.js';
import { localize } from '../../localize/localize.js'; import { localize } from '../../localize/localize.js';
import basicBlockStyle from '../../scss/basic-block.scss';
import liveCarouselStyle from '../../scss/live-carousel.scss'; import liveCarouselStyle from '../../scss/live-carousel.scss';
import liveProviderStyle from '../../scss/live-provider.scss'; import liveProviderStyle from '../../scss/live-provider.scss';
import liveStyle from '../../scss/live.scss';
import { import {
CameraConfig, CameraConfig,
CardWideConfig, CardWideConfig,
@@ -37,25 +36,30 @@ import {
} from '../../types.js'; } from '../../types.js';
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
import { contentsChanged } from '../../utils/basic.js'; import { contentsChanged } from '../../utils/basic.js';
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
import { MediaGridSelected } from '../../utils/media-grid-controller.js';
import { import {
dispatchExistingMediaLoadedInfoAsEvent, dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent, dispatchMediaUnloadedEvent,
} from '../../utils/media-info.js'; } from '../../utils/media-info.js';
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
import { playMediaMutingIfNecessary } from '../../utils/media.js'; import { playMediaMutingIfNecessary } from '../../utils/media.js';
import { Timer } from '../../utils/timer.js';
import { dispatchViewContextChangeEvent, View } from '../../view/view.js'; import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js'; import { EmblaCarouselPlugins } from '../carousel.js';
import {
FrigateCardMediaCarousel,
wrapMediaLoadedEventForCarousel,
wrapMediaUnloadedEventForCarousel,
} from '../media-carousel.js';
import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js'; import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js';
import '../next-prev-control.js'; import '../next-prev-control.js';
import '../surround.js'; import '../surround.js';
import '../title-control.js'; import '../title-control.js';
import { AutoMediaPlugin } from './../embla-plugins/automedia.js'; import {
import { Lazyload } from './../embla-plugins/lazyload.js'; FrigateCardTitleControl,
getDefaultTitleConfigForView,
showTitleControlAfterDelay,
} from '../title-control.js';
interface LiveViewContext { interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different // A cameraID override (used for dependencies/substreams to force a different
@@ -126,7 +130,10 @@ export class FrigateCardLive extends LitElement {
public view?: Readonly<View>; public view?: Readonly<View>;
@property({ attribute: false }) @property({ attribute: false })
public liveConfig?: LiveConfig; public nonOverriddenLiveConfig?: LiveConfig;
@property({ attribute: false })
public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides; public liveOverrides?: LiveOverrides;
@@ -194,11 +201,6 @@ export class FrigateCardLive extends LitElement {
} }
} }
/**
* Determine whether the element should be updated.
* @param _changedProps The changed properties if any.
* @returns `true` if the element should be updated.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
protected shouldUpdate(_changedProps: PropertyValues): boolean { protected shouldUpdate(_changedProps: PropertyValues): boolean {
// Don't process updates if it's in the background and a message was // Don't process updates if it's in the background and a message was
@@ -207,34 +209,29 @@ export class FrigateCardLive extends LitElement {
return !this._inBackground || !this._messageReceivedPostRender; return !this._inBackground || !this._messageReceivedPostRender;
} }
/**
* Component connected callback.
*/
connectedCallback(): void { connectedCallback(): void {
this._intersectionObserver.observe(this); this._intersectionObserver.observe(this);
super.connectedCallback(); super.connectedCallback();
} }
/**
* Component disconnected callback.
*/
disconnectedCallback(): void { disconnectedCallback(): void {
super.disconnectedCallback(); super.disconnectedCallback();
this._intersectionObserver.disconnect(); this._intersectionObserver.disconnect();
} }
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.liveConfig || !this.cameraManager || !this.view) { if (
!this.hass ||
!this.nonOverriddenLiveConfig ||
!this.cameraManager ||
!this.view
) {
return; return;
} }
// Notes: // Notes:
// - See use of liveConfig and not config below -- the carousel will // - See use of liveConfig and not config below -- the underlying carousel
// independently override the liveConfig to reflect the camera in the // will independently override the liveConfig to reflect the camera in the
// carousel (not necessarily the selected camera). // carousel (not necessarily the selected camera).
// - Various events are captured to prevent them propagating upwards if the // - Various events are captured to prevent them propagating upwards if the
// card is in the background. // card is in the background.
@@ -244,10 +241,11 @@ export class FrigateCardLive extends LitElement {
const result = html`${keyed( const result = html`${keyed(
this._renderKey, this._renderKey,
html` html`
<frigate-card-live-carousel <frigate-card-live-grid
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.liveConfig=${this.liveConfig} .nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
.inBackground=${this._inBackground} .inBackground=${this._inBackground}
.conditionControllerEpoch=${this.conditionControllerEpoch} .conditionControllerEpoch=${this.conditionControllerEpoch}
.liveOverrides=${this.liveOverrides} .liveOverrides=${this.liveOverrides}
@@ -276,7 +274,7 @@ export class FrigateCardLive extends LitElement {
} }
}} }}
> >
</frigate-card-live-carousel> </frigate-card-live-grid>
`, `,
)}`; )}`;
@@ -284,16 +282,13 @@ export class FrigateCardLive extends LitElement {
return result; return result;
} }
/**
* Get styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(liveStyle); return unsafeCSS(basicBlockStyle);
} }
} }
@customElement('frigate-card-live-carousel') @customElement('frigate-card-live-grid')
export class FrigateCardLiveCarousel extends LitElement { export class FrigateCardLiveGrid extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public hass?: ExtendedHomeAssistant; public hass?: ExtendedHomeAssistant;
@@ -301,14 +296,14 @@ export class FrigateCardLiveCarousel extends LitElement {
public view?: Readonly<View>; public view?: Readonly<View>;
@property({ attribute: false }) @property({ attribute: false })
public liveConfig?: LiveConfig; public nonOverriddenLiveConfig?: LiveConfig;
@property({ attribute: false })
public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged }) @property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides; public liveOverrides?: LiveOverrides;
@property({ attribute: false })
public inBackground?: boolean;
@property({ attribute: false }) @property({ attribute: false })
public conditionControllerEpoch?: ConditionControllerEpoch; public conditionControllerEpoch?: ConditionControllerEpoch;
@@ -321,108 +316,160 @@ export class FrigateCardLiveCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public microphoneStream?: MediaStream; public microphoneStream?: MediaStream;
protected _renderCarousel(cameraID?: string): TemplateResult {
return html`
<frigate-card-live-carousel
grid-id=${ifDefined(cameraID)}
.hass=${this.hass}
.view=${this.view}
.viewFilterCameraID=${cameraID}
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
.overriddenLiveConfig=${this.overriddenLiveConfig}
.conditionControllerEpoch=${this.conditionControllerEpoch}
.liveOverrides=${this.liveOverrides}
.cardWideConfig=${this.cardWideConfig}
.cameraManager=${this.cameraManager}
.microphoneStream=${this.microphoneStream}
>
</frigate-card-live-carousel>
`;
}
protected _gridSelectCamera(cameraID: string, view?: View): void {
(view ?? this.view)
?.evolve({
camera: cameraID,
})
.dispatchChangeEvent(this);
}
protected _needsGrid(): boolean {
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
return (
!!this.view?.isGrid() &&
!!this.view?.supportsMultipleDisplayModes() &&
!!cameraIDs &&
cameraIDs.size > 1
);
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('view') && this._needsGrid()) {
import('../media-grid.js');
}
}
protected render(): TemplateResult | void {
if (!this.conditionControllerEpoch || !this.nonOverriddenLiveConfig) {
return;
}
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
if (!cameraIDs || !this._needsGrid()) {
return this._renderCarousel();
}
return html`
<frigate-card-media-grid
.selected=${this.view?.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>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(basicBlockStyle);
}
}
@customElement('frigate-card-live-carousel')
export class FrigateCardLiveCarousel extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false })
public nonOverriddenLiveConfig?: LiveConfig;
@property({ attribute: false })
public overriddenLiveConfig?: LiveConfig;
@property({ attribute: false, hasChanged: contentsChanged })
public liveOverrides?: LiveOverrides;
@property({ attribute: false })
public conditionControllerEpoch?: ConditionControllerEpoch;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
@property({ attribute: false })
public microphoneStream?: MediaStream;
@property({ attribute: false })
public viewFilterCameraID?: string;
// Index between camera name and slide number. // Index between camera name and slide number.
protected _cameraToSlide: Record<string, number> = {}; protected _cameraToSlide: Record<string, number> = {};
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef(); protected _titleTimer = new Timer();
protected _refTitleControl: Ref<FrigateCardTitleControl> = createRef();
/**
* The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render.
*/
updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has('inBackground')) {
this.updateComplete.then(async () => {
const frigateCardMediaCarousel = this._refMediaCarousel.value;
if (frigateCardMediaCarousel) {
await frigateCardMediaCarousel.updateComplete;
// If this has changed to be in the background (i.e. preloaded but not
// visible) take the appropriate play/pause/mute/unmute actions.
if (this.inBackground) {
frigateCardMediaCarousel.autoPause();
frigateCardMediaCarousel.autoMute();
} else {
frigateCardMediaCarousel.autoPlay();
frigateCardMediaCarousel.autoUnmute();
}
}
});
}
}
/**
* Get the transition effect to use.
* @returns An TransitionEffect object.
*/
protected _getTransitionEffect(): TransitionEffect { protected _getTransitionEffect(): TransitionEffect {
return ( return (
this.liveConfig?.transition_effect ?? this.overriddenLiveConfig?.transition_effect ??
frigateCardConfigDefaults.live.transition_effect frigateCardConfigDefaults.live.transition_effect
); );
} }
protected _getSelectedCameraIndex(): number { protected _getSelectedCameraIndex(): number {
const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs(); const cameraIDs = this.cameraManager?.getStore().getVisibleCameraIDs();
if (!cameraIDs || !this.view) { if (!cameraIDs || !this.view || this.viewFilterCameraID) {
// If the carousel is limited to a single cameraID, the first (only)
// element is always the selected one.
return 0; return 0;
} }
return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera)); return Math.max(0, Array.from(cameraIDs).indexOf(this.view.camera));
} }
/**
* Get the Embla options to use.
* @returns An EmblaOptionsType object or undefined for no options.
*/
protected _getOptions(): EmblaOptionsType {
return {
draggable: this.liveConfig?.draggable,
loop: true,
};
}
/**
* Get the Embla plugins to use.
* @returns A list of EmblaOptionsTypes.
*/
protected _getPlugins(): EmblaCarouselPlugins { protected _getPlugins(): EmblaCarouselPlugins {
const cameras = this.cameraManager?.getStore().getVisibleCameraIDs();
return [ return [
// Only enable wheel plugin if there is more than one camera. AutoLazyLoad({
...(cameras && cameras.size > 1 ...(this.overriddenLiveConfig?.lazy_load && {
? [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
// gestures as scrolling for the carousel.
forceWheelAxis: 'y',
}),
]
: []),
Lazyload({
...(this.liveConfig?.lazy_load && {
lazyLoadCallback: (index, slide) => lazyLoadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('load', index, slide), this._lazyloadOrUnloadSlide('load', index, slide),
}), }),
lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload,
lazyUnloadCondition: this.liveConfig?.lazy_unload,
lazyUnloadCallback: (index, slide) => lazyUnloadCallback: (index, slide) =>
this._lazyloadOrUnloadSlide('unload', index, slide), this._lazyloadOrUnloadSlide('unload', index, slide),
}), }),
AutoMediaPlugin({ AutoMediaLoadedInfo(),
AutoMediaActions({
playerSelector: FRIGATE_CARD_LIVE_PROVIDER, playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
...(this.liveConfig?.auto_play && { ...(this.overriddenLiveConfig?.auto_play && {
autoPlayCondition: this.liveConfig.auto_play, autoPlayCondition: this.overriddenLiveConfig.auto_play,
}), }),
...(this.liveConfig?.auto_pause && { ...(this.overriddenLiveConfig?.auto_pause && {
autoPauseCondition: this.liveConfig.auto_pause, autoPauseCondition: this.overriddenLiveConfig.auto_pause,
}), }),
...(this.liveConfig?.auto_mute && { ...(this.overriddenLiveConfig?.auto_mute && {
autoMuteCondition: this.liveConfig.auto_mute, autoMuteCondition: this.overriddenLiveConfig.auto_mute,
}), }),
...(this.liveConfig?.auto_unmute && { ...(this.overriddenLiveConfig?.auto_unmute && {
autoUnmuteCondition: this.liveConfig.auto_unmute, autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute,
}), }),
}), }),
AutoSize(),
]; ];
} }
@@ -435,24 +482,29 @@ export class FrigateCardLiveCarousel extends LitElement {
*/ */
protected _getLazyLoadCount(): number | null { protected _getLazyLoadCount(): number | null {
// Defaults to fully-lazy loading. // Defaults to fully-lazy loading.
return this.liveConfig?.lazy_load === false ? null : 0; return this.overriddenLiveConfig?.lazy_load === false ? null : 0;
} }
/**
* Get slides to include in the render.
* @returns The slides to include in the render and an index keyed by camera
* name to slide number.
*/
protected _getSlides(): [TemplateResult[], Record<string, number>] { protected _getSlides(): [TemplateResult[], Record<string, number>] {
const visibleCameras = this.cameraManager?.getStore().getVisibleCameras(); let cameras: CameraConfigs | null = null;
if (!visibleCameras) { if (this.viewFilterCameraID) {
const config = this.cameraManager
?.getStore()
.getCameraConfig(this.viewFilterCameraID);
if (config) {
cameras = new Map([[this.viewFilterCameraID, config]]);
}
} else {
cameras = this.cameraManager?.getStore().getVisibleCameras() ?? null;
}
if (!cameras) {
return [[], {}]; return [[], {}];
} }
const slides: TemplateResult[] = []; const slides: TemplateResult[] = [];
const cameraToSlide: Record<string, number> = {}; const cameraToSlide: Record<string, number> = {};
for (const [cameraID, cameraConfig] of visibleCameras) { for (const [cameraID, cameraConfig] of cameras) {
const liveCameraID = const liveCameraID =
this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID; this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
const liveCameraConfig = const liveCameraConfig =
@@ -461,7 +513,7 @@ export class FrigateCardLiveCarousel extends LitElement {
: this.cameraManager?.getStore().getCameraConfig(liveCameraID); : this.cameraManager?.getStore().getCameraConfig(liveCameraID);
const slide = liveCameraConfig const slide = liveCameraConfig
? this._renderLive(liveCameraID, liveCameraConfig, slides.length) ? this._renderLive(liveCameraID, liveCameraConfig)
: null; : null;
if (slide) { if (slide) {
cameraToSlide[cameraID] = slides.length; cameraToSlide[cameraID] = slides.length;
@@ -471,10 +523,7 @@ export class FrigateCardLiveCarousel extends LitElement {
return [slides, cameraToSlide]; return [slides, cameraToSlide];
} }
/** protected _setViewHandler(ev: CustomEvent<CarouselSelected>): void {
* Handle the user selecting a new slide in the carousel.
*/
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
const cameras = this.cameraManager?.getStore().getVisibleCameras(); const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) { if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) {
this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]); this._setViewCameraID(Array.from(cameras.keys())[ev.detail.index]);
@@ -497,11 +546,6 @@ export class FrigateCardLiveCarousel extends LitElement {
} }
} }
/**
* Lazy load a slide.
* @param _index The slide number to lazy load.
* @param slide The slide to lazy load.
*/
protected _lazyloadOrUnloadSlide( protected _lazyloadOrUnloadSlide(
action: 'load' | 'unload', action: 'load' | 'unload',
_index: number, _index: number,
@@ -515,17 +559,17 @@ export class FrigateCardLiveCarousel extends LitElement {
FRIGATE_CARD_LIVE_PROVIDER, FRIGATE_CARD_LIVE_PROVIDER,
) as FrigateCardLiveProvider | null; ) as FrigateCardLiveProvider | null;
if (liveProvider) { if (liveProvider) {
liveProvider.disabled = action !== 'load'; liveProvider.load = action === 'load';
} }
} }
protected _renderLive( protected _renderLive(
cameraID: string, cameraID: string,
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
slideIndex: number,
): TemplateResult | void { ): TemplateResult | void {
if ( if (
!this.liveConfig || !this.overriddenLiveConfig ||
!this.nonOverriddenLiveConfig ||
!this.hass || !this.hass ||
!this.cameraManager || !this.cameraManager ||
!this.conditionControllerEpoch !this.conditionControllerEpoch
@@ -538,7 +582,7 @@ export class FrigateCardLiveCarousel extends LitElement {
// stateOverride to evaluate the condition in that context. // stateOverride to evaluate the condition in that context.
const config = getOverriddenConfig( const config = getOverriddenConfig(
this.conditionControllerEpoch.controller, this.conditionControllerEpoch.controller,
this.liveConfig, this.nonOverriddenLiveConfig,
this.liveOverrides, this.liveOverrides,
{ camera: cameraID }, { camera: cameraID },
) as LiveConfig; ) as LiveConfig;
@@ -548,7 +592,7 @@ export class FrigateCardLiveCarousel extends LitElement {
return html` return html`
<div class="embla__slide"> <div class="embla__slide">
<frigate-card-live-provider <frigate-card-live-provider
?disabled=${this.liveConfig.lazy_load} ?load=${!config.lazy_load}
.microphoneStream=${this.view?.camera === cameraID .microphoneStream=${this.view?.camera === cameraID
? this.microphoneStream ? this.microphoneStream
: undefined} : undefined}
@@ -561,12 +605,6 @@ export class FrigateCardLiveCarousel extends LitElement {
.liveConfig=${config} .liveConfig=${config}
.hass=${this.hass} .hass=${this.hass}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
wrapMediaLoadedEventForCarousel(slideIndex, ev);
}}
@frigate-card:media:unloaded=${(ev: CustomEvent<void>) => {
wrapMediaUnloadedEventForCarousel(slideIndex, ev);
}}
> >
</frigate-card-live-provider> </frigate-card-live-provider>
</div> </div>
@@ -575,11 +613,13 @@ export class FrigateCardLiveCarousel extends LitElement {
protected _getCameraIDsOfNeighbors(): [string | null, string | null] { protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
const cameras = this.cameraManager?.getStore().getVisibleCameras(); const cameras = this.cameraManager?.getStore().getVisibleCameras();
if (!cameras || !this.view || !this.hass) { if (this.viewFilterCameraID || !cameras || !this.view || !this.hass) {
return [null, null]; return [null, null];
} }
const cameraID = this.viewFilterCameraID ?? this.view.camera;
const keys = Array.from(cameras.keys()); const keys = Array.from(cameras.keys());
const currentIndex = keys.indexOf(this.view.camera); const currentIndex = keys.indexOf(cameraID);
if (currentIndex < 0 || cameras.size <= 1) { if (currentIndex < 0 || cameras.size <= 1) {
return [null, null]; return [null, null];
@@ -591,12 +631,8 @@ export class FrigateCardLiveCarousel extends LitElement {
]; ];
} }
/**
* Render the element.
* @returns A template to display to the user.
*/
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.liveConfig || !this.view || !this.hass || !this.cameraManager) { if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) {
return; return;
} }
@@ -606,6 +642,7 @@ export class FrigateCardLiveCarousel extends LitElement {
return; return;
} }
const hasMultipleCameras = slides.length > 1;
const [prevID, nextID] = this._getCameraIDsOfNeighbors(); const [prevID, nextID] = this._getCameraIDsOfNeighbors();
const overrideCameraID = (cameraID: string): string => { const overrideCameraID = (cameraID: string): string => {
@@ -617,12 +654,17 @@ export class FrigateCardLiveCarousel extends LitElement {
: null; : null;
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata( const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
this.hass, this.hass,
overrideCameraID(this.view.camera), overrideCameraID(this.viewFilterCameraID ?? this.view.camera),
); );
const cameraMetadataNext = nextID const cameraMetadataNext = nextID
? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(nextID)) ? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(nextID))
: null; : null;
const titleConfig = getDefaultTitleConfigForView(
this.view,
this.overriddenLiveConfig?.controls.title,
);
// Notes on the below: // Notes on the below:
// - guard() is used to avoid reseting the carousel unless the // - guard() is used to avoid reseting the carousel unless the
// options/plugins actually change. // options/plugins actually change.
@@ -634,34 +676,31 @@ export class FrigateCardLiveCarousel extends LitElement {
// little later). // little later).
return html` return html`
<frigate-card-media-carousel <frigate-card-carousel
${ref(this._refMediaCarousel)} .loop=${hasMultipleCameras}
.carouselOptions=${guard( .dragEnabled=${hasMultipleCameras && this.overriddenLiveConfig?.draggable}
[this.cameraManager, this.liveConfig], .plugins=${guard(
this._getOptions.bind(this), [this.cameraManager, this.overriddenLiveConfig],
)}
.carouselPlugins=${guard(
[this.cameraManager, this.liveConfig],
this._getPlugins.bind(this), this._getPlugins.bind(this),
) as EmblaCarouselPlugins} )}
.label="${cameraMetadataCurrent
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
.titlePopupConfig=${this.liveConfig.controls.title}
.selected=${this._getSelectedCameraIndex()} .selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()} transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)} @frigate-card:carousel:select=${this._setViewHandler.bind(this)}
@frigate-card:carousel:settle=${() => { @frigate-card:carousel:settle=${() => {
// Fetch the thumbnails after the carousel has settled. // Fetch the thumbnails after the carousel has settled.
dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } }); dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } });
}} }}
@frigate-card:media:loaded=${() => {
if (this._refTitleControl.value) {
showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer);
}
}}
> >
<frigate-card-next-previous-control <frigate-card-next-previous-control
slot="previous" slot="previous"
.hass=${this.hass} .hass=${this.hass}
.direction=${'previous'} .direction=${'previous'}
.controlConfig=${this.liveConfig.controls.next_previous} .controlConfig=${this.overriddenLiveConfig.controls.next_previous}
.label=${cameraMetadataPrevious?.title ?? ''} .label=${cameraMetadataPrevious?.title ?? ''}
.icon=${cameraMetadataPrevious?.icon} .icon=${cameraMetadataPrevious?.icon}
?disabled=${prevID === null} ?disabled=${prevID === null}
@@ -676,7 +715,7 @@ export class FrigateCardLiveCarousel extends LitElement {
slot="next" slot="next"
.hass=${this.hass} .hass=${this.hass}
.direction=${'next'} .direction=${'next'}
.controlConfig=${this.liveConfig.controls.next_previous} .controlConfig=${this.overriddenLiveConfig.controls.next_previous}
.label=${cameraMetadataNext?.title ?? ''} .label=${cameraMetadataNext?.title ?? ''}
.icon=${cameraMetadataNext?.icon} .icon=${cameraMetadataNext?.icon}
?disabled=${nextID === null} ?disabled=${nextID === null}
@@ -686,13 +725,22 @@ export class FrigateCardLiveCarousel extends LitElement {
}} }}
> >
</frigate-card-next-previous-control> </frigate-card-next-previous-control>
</frigate-card-media-carousel> </frigate-card-carousel>
${cameraMetadataCurrent && titleConfig
? html`<frigate-card-title-control
${ref(this._refTitleControl)}
.config=${titleConfig}
.text="${cameraMetadataCurrent
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
: ``}
`; `;
} }
/**
* Get styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(liveCarouselStyle); return unsafeCSS(liveCarouselStyle);
} }
@@ -715,10 +763,11 @@ export class FrigateCardLiveProvider
@property({ attribute: false }) @property({ attribute: false })
public liveConfig?: LiveConfig; public liveConfig?: LiveConfig;
// Whether or not to disable this entity. If `true`, no contents are rendered // Whether or not to load the video for this camera. If `false`, no contents
// until this attribute is set to `false` (this is useful for lazy loading). // are rendered until this attribute is set to `true` (this is useful for lazy
// loading).
@property({ attribute: true, type: Boolean }) @property({ attribute: true, type: Boolean })
public disabled = false; public load = false;
// Label that is used for ARIA support and as tooltip. // Label that is used for ARIA support and as tooltip.
@property({ attribute: false }) @property({ attribute: false })
@@ -794,7 +843,7 @@ export class FrigateCardLiveProvider
public async getScreenshotURL(): Promise<string | null> { public async getScreenshotURL(): Promise<string | null> {
await this.updateComplete; await this.updateComplete;
await this._refProvider.value?.updateComplete; await this._refProvider.value?.updateComplete;
return await this._refProvider.value?.getScreenshotURL() ?? null; return (await this._refProvider.value?.getScreenshotURL()) ?? null;
} }
/** /**
@@ -853,8 +902,8 @@ export class FrigateCardLiveProvider
* Called before each update. * Called before each update.
*/ */
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('disabled')) { if (changedProps.has('load')) {
if (this.disabled) { if (!this.load) {
this._isVideoMediaLoaded = false; this._isVideoMediaLoaded = false;
dispatchMediaUnloadedEvent(this); dispatchMediaUnloadedEvent(this);
} }
@@ -909,7 +958,7 @@ export class FrigateCardLiveProvider
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (this.disabled || !this.hass || !this.liveConfig || !this.cameraConfig) { if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) {
return; return;
} }
@@ -1002,6 +1051,7 @@ declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
FRIGATE_CARD_LIVE_PROVIDER: FrigateCardLiveProvider; FRIGATE_CARD_LIVE_PROVIDER: FrigateCardLiveProvider;
'frigate-card-live-carousel': FrigateCardLiveCarousel; 'frigate-card-live-carousel': FrigateCardLiveCarousel;
'frigate-card-live-grid': FrigateCardLiveGrid;
'frigate-card-live': FrigateCardLive; 'frigate-card-live': FrigateCardLive;
} }
} }
-443
View File
@@ -1,443 +0,0 @@
import { EmblaOptionsType } from 'embla-carousel';
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import mediaCarouselStyle from '../scss/media-carousel.scss';
import type {
MediaLoadedInfo,
NextPreviousControlConfig,
TitleControlConfig,
TransitionEffect,
} from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic';
import {
dispatchExistingMediaLoadedInfoAsEvent,
isValidMediaLoadedInfo,
} from '../utils/media-info.js';
import { CarouselSelect, EmblaCarouselPlugins, FrigateCardCarousel } from './carousel';
import { AutoMediaType } from './embla-plugins/automedia.js';
import './next-prev-control.js';
import './carousel.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import { FrigateCardTitleControl } from './title-control.js';
import debounce from 'lodash-es/debounce';
import { Timer } from '../utils/timer';
interface CarouselMediaLoadedInfo {
slide: number;
mediaLoadedInfo: MediaLoadedInfo;
}
interface CarouselMediaUnloadedInfo {
slide: number;
}
/**
* Dispatch a carousel media loaded event.
* @param target The target to send it from.
* @param carouselMediaLoadedInfo The CarouselMediaLoadedInfo.
*/
const dispatchFrigateCardCarouselMediaLoaded = (
target: EventTarget,
carouselMediaLoadedInfo: CarouselMediaLoadedInfo,
): void => {
dispatchFrigateCardEvent<CarouselMediaLoadedInfo>(
target,
'carousel:media:loaded',
carouselMediaLoadedInfo,
);
};
/**
* Dispatch a carousel media UNloaded event.
* @param target The target to send it from.
* @param carouselMediaUnloadedInfo The CarouselMediaUnloadedInfo.
*/
const dispatchFrigateCardCarouselMediaUnloaded = (
target: EventTarget,
carouselMediaUnloadedInfo: CarouselMediaUnloadedInfo,
): void => {
dispatchFrigateCardEvent<CarouselMediaUnloadedInfo>(
target,
'carousel:media:unloaded',
carouselMediaUnloadedInfo,
);
};
/**
* Turn a MediaLoadedInfo into a CarouselMediaLoadedInfo.
* @param slide The slide number.
* @param event The MediaShowEvent.
*/
export const wrapMediaLoadedEventForCarousel = (
slide: number,
event: CustomEvent<MediaLoadedInfo>,
) => {
event.stopPropagation();
dispatchFrigateCardCarouselMediaLoaded(event.composedPath()[0], {
slide: slide,
mediaLoadedInfo: event.detail,
});
};
/**
* Turn a MediaUnloadedInfo into a CarouselMediaUnloadedInfo.
* @param slide The slide number.
* @param event The MediaUnloadedEvent.
*/
export const wrapMediaUnloadedEventForCarousel = (
slide: number,
event: CustomEvent<void>,
) => {
event.stopPropagation();
dispatchFrigateCardCarouselMediaUnloaded(event.composedPath()[0], {
slide: slide,
});
};
@customElement('frigate-card-media-carousel')
export class FrigateCardMediaCarousel extends LitElement {
@property({ attribute: false })
public nextPreviousConfig?: NextPreviousControlConfig;
@property({ attribute: false })
public carouselOptions?: EmblaOptionsType;
@property({ attribute: false })
public carouselPlugins?: EmblaCarouselPlugins;
@property({ attribute: false, type: Number })
public selected = 0;
@property({ attribute: true })
public transitionEffect?: TransitionEffect;
@property({ attribute: false })
public label?: string;
@property({ attribute: false })
public logo?: string;
@property({ attribute: false })
public titlePopupConfig?: TitleControlConfig;
// A "map" from slide number to MediaLoadedInfo object.
protected _mediaLoadedInfo: Record<number, MediaLoadedInfo> = {};
protected _nextControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
protected _previousControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
protected _titleControlRef: Ref<FrigateCardTitleControl> = createRef();
protected _titleTimer = new Timer();
protected _boundAutoPlayHandler = this.autoPlay.bind(this);
protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this);
protected _boundTitleHandler = this._titleHandler.bind(this);
// Debounce multiple calls to adapt the container height.
protected _debouncedAdaptContainerHeightToSlide = debounce(
this._adaptContainerHeightToSlide.bind(this),
1 * 100,
{trailing: true});
// This carousel may be resized by Lovelace resizes, window resizes,
// fullscreen, etc. Always call the adaptive height handler when the size
// changes.
protected _slideResizeObserver: ResizeObserver;
protected _intersectionObserver: IntersectionObserver;
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
constructor() {
super();
// Need to watch both changes in this element (e.g. caused by a window
// resize or fullscreen change) and changes in the selected slide itself
// (e.g. changing from a progress indicator to a loaded media).
this._slideResizeObserver = new ResizeObserver(
this._reInitAndAdjustHeight.bind(this),
);
this._intersectionObserver = new IntersectionObserver(
this._intersectionHandler.bind(this),
);
}
/**
* Get the underlying carousel.
*/
public frigateCardCarousel(): FrigateCardCarousel | null {
return this._refCarousel.value ?? null;
}
/**
* Get the AutoMedia plugin (if any).
* @returns The plugin or `null`.
*/
protected _getAutoMediaPlugin(): AutoMediaType | null {
return this.frigateCardCarousel()?.carousel()?.plugins().autoMedia ?? null;
}
/**
* Play the media on the selected slide.
*/
public autoPlay(): void {
const automediaOptions = this._getAutoMediaPlugin()?.options;
if (
automediaOptions?.autoPlayCondition &&
['all', 'selected'].includes(automediaOptions?.autoPlayCondition)
) {
this._getAutoMediaPlugin()?.play();
}
}
/**
* Pause the media on the selected slide.
*/
public autoPause(): void {
const automediaOptions = this._getAutoMediaPlugin()?.options;
if (
automediaOptions?.autoPauseCondition &&
['all', 'selected'].includes(automediaOptions.autoPauseCondition)
) {
this._getAutoMediaPlugin()?.pause();
}
}
/**
* Unmute the media on the selected slide.
*/
public autoUnmute(): void {
const automediaOptions = this._getAutoMediaPlugin()?.options;
if (
automediaOptions?.autoUnmuteCondition &&
['all', 'selected'].includes(automediaOptions?.autoUnmuteCondition)
) {
this._getAutoMediaPlugin()?.unmute();
}
}
/**
* Mute the media on the selected slide.
*/
public autoMute(): void {
const automediaOptions = this._getAutoMediaPlugin()?.options;
if (
automediaOptions?.autoMuteCondition &&
['all', 'selected'].includes(automediaOptions?.autoMuteCondition)
) {
this._getAutoMediaPlugin()?.mute();
}
}
/**
* Show the media title after the media loads.
*/
protected _titleHandler(): void {
const show = () => {
this._titleTimer.stop();
this._titleControlRef.value?.show();
};
if (this._titleControlRef.value?.isVisible()) {
// If it's already visible, update it immediately (but also update it
// after the timer expires to ensure it re-positions if necessary, see
// comment below).
show();
}
// Allow a brief pause after the media loads, but before the title is
// displayed. This allows for a pleasant appearance/disappear of the title,
// and allows for the browser to finish rendering the carousel.
this._titleTimer.start(0.5, show);
}
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler);
this.addEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler);
this.addEventListener(
'frigate-card:media:loaded',
this._debouncedAdaptContainerHeightToSlide,
);
this.addEventListener('frigate-card:media:loaded', this._boundTitleHandler);
this._intersectionObserver.observe(this);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this.removeEventListener('frigate-card:media:loaded', this._boundAutoPlayHandler);
this.removeEventListener('frigate-card:media:loaded', this._boundAutoUnmuteHandler);
this.removeEventListener(
'frigate-card:media:loaded',
this._debouncedAdaptContainerHeightToSlide,
);
this.removeEventListener('frigate-card:media:loaded', this._boundTitleHandler);
this._intersectionObserver.disconnect();
this._mediaLoadedInfo = {};
super.disconnectedCallback();
}
/**
* ReInit the carousel and adapt the container height.
*/
protected _reInitAndAdjustHeight(): void {
this.frigateCardCarousel()?.carouselReInitWhenSafe();
this._debouncedAdaptContainerHeightToSlide();
}
/**
* Called when the carousel intersects with the viewport.
* @param entries The IntersectionObserverEntry entries (should be only 1).
*/
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
/**
* - If the DOM that contains this carousel changes such that it causes
* slides to entirely appear/disappear (e.g. `display: none` or hidden),
* then the displayed slide sizes will significantly change and the
* carousel will need to be reinitialized. Without this, odd bugs may
* occur for some users in some circumstances causing the carousel to
* appear 'stuck'.
* - Example bug when this reinitialization is not performed:
* https://github.com/dermotduffy/frigate-hass-card/issues/651
*/
if (entries.some((entry) => entry.isIntersecting)) {
this._reInitAndAdjustHeight();
}
}
/**
* Set the the height of the component on media load in case the dimensions
* have changed. This handler is not triggered from carousel events, as it's
* actually the media load/show that will change the dimensions, and that is
* async from carousel actions (e.g. lazy-loaded media).
*
* This component does not use the stock Embla auto-height plugin as that
* resizes the container only on selection rather than media load.
*/
protected _adaptContainerHeightToSlide(): void {
const selected = this.frigateCardCarousel()?.getCarouselSelected();
if (selected) {
this.style.removeProperty('max-height');
const height = selected.element.getBoundingClientRect().height;
if (height !== undefined && height > 0) {
this.style.maxHeight = `${height}px`;
}
}
}
/**
* Fire a media show event when a slide is selected.
*/
protected _dispatchMediaLoadedInfo(selected: CarouselSelect): void {
const slideIndex = selected.index;
if (slideIndex !== undefined && slideIndex in this._mediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(this, this._mediaLoadedInfo[slideIndex]);
}
}
/**
* Handle a media:loaded event that is generated by a child component, saving the
* contents for future use when the relevant slide is actually shown.
* @param slideIndex The relevant slide index.
* @param event The media:loaded event from the child component.
*/
protected _storeMediaLoadedInfo(event: CustomEvent<CarouselMediaLoadedInfo>): void {
// Don't allow the inbound event to propagate upwards, that will be
// automatically done at the appropriate time as the slide is shown.
event.stopPropagation();
const mediaLoadedInfo = event.detail.mediaLoadedInfo;
const slideIndex = event.detail.slide;
// isValidMediaLoadedInfo is used to prevent saving media info that will be
// rejected upstream (empty 1x1 images will be rejected here).
if (mediaLoadedInfo && isValidMediaLoadedInfo(mediaLoadedInfo)) {
this._mediaLoadedInfo[slideIndex] = mediaLoadedInfo;
if (this.frigateCardCarousel()?.getCarouselSelected()?.index === slideIndex) {
dispatchExistingMediaLoadedInfoAsEvent(this, mediaLoadedInfo);
}
}
}
/**
* Remove a media loaded info (i.e. a media item has unloaded).
* @param event The CarouselMediaUnloadedInfo event.
*/
protected _removeMediaLoadedInfo(event: CustomEvent<CarouselMediaUnloadedInfo>): void {
const slideIndex = event.detail.slide;
delete this._mediaLoadedInfo[slideIndex];
// If the slide that unloaded is not visible, don't propagate the event upwards.
if (this.frigateCardCarousel()?.getCarouselSelected()?.index !== slideIndex) {
event.stopPropagation();
}
}
protected render(): TemplateResult | void {
const selectSlide = (ev: CustomEvent<CarouselSelect>): void => {
this._slideResizeObserver.disconnect();
const parent = this.getRootNode();
if (parent && parent instanceof ShadowRoot) {
this._slideResizeObserver.observe(parent.host);
}
const selected = ev.detail;
this._slideResizeObserver.observe(selected.element);
// Pass up the media-carousel select event first to allow parents to
// initialize/reset before the media info is dispatched.
dispatchFrigateCardEvent<CarouselSelect>(
this,
'media-carousel:select',
selected,
);
// Dispatch media info.
this._dispatchMediaLoadedInfo(selected);
}
return html` <frigate-card-carousel
${ref(this._refCarousel)}
.selected=${this.selected ?? 0}
.carouselOptions=${this.carouselOptions}
.carouselPlugins=${this.carouselPlugins}
transitionEffect=${ifDefined(this.transitionEffect)}
@frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelect>) => {
selectSlide(ev);
}}
@frigate-card:carousel:media:loaded=${this._storeMediaLoadedInfo.bind(this)}
@frigate-card:carousel:media:unloaded=${this._removeMediaLoadedInfo.bind(this)}
>
<slot slot="previous" name="previous"></slot>
<slot></slot>
<slot slot="next" name="next"></slot>
</frigate-card-carousel>
${this.label && this.titlePopupConfig
? html`<frigate-card-title-control
${ref(this._titleControlRef)}
.config=${this.titlePopupConfig}
.text="${this.label}"
.logo="${this.logo}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
: ``}`;
}
/**
* Get element styles.
*/
static get styles(): CSSResultGroup {
return unsafeCSS(mediaCarouselStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-media-carousel': FrigateCardMediaCarousel;
}
}
+73
View File
@@ -0,0 +1,73 @@
import {
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import mediaGridStyle from '../scss/media-grid.scss';
import { ViewDisplayConfig } from '../types.js';
import { MediaGridController } from '../utils/media-grid-controller.js';
@customElement('frigate-card-media-grid')
export class FrigateCardMediaGrid extends LitElement {
@property({ attribute: false })
public selected?: string;
@property({ attribute: false })
public displayConfig?: ViewDisplayConfig;
protected _controller: MediaGridController | null = null;
protected _refSlot: Ref<HTMLSlotElement> = createRef();
connectedCallback(): void {
super.connectedCallback();
// Ensure the controller is recreated.
this.requestUpdate();
}
disconnectedCallback(): void {
this._controller?.destroy();
this._controller = null;
super.disconnectedCallback();
}
protected updated(changedProps: PropertyValues): void {
if (!this._controller && this._refSlot.value) {
this._controller = new MediaGridController(this._refSlot.value, {
selected: this.selected,
displayConfig: this.displayConfig,
});
}
if (changedProps.has('selected')) {
if (this.selected) {
this._controller?.selectCell(this.selected);
} else {
this._controller?.unselectAll();
}
}
if (changedProps.has('displayConfig')) {
this._controller?.setDisplayConfig(this.displayConfig ?? null);
}
}
protected render(): TemplateResult | void {
return html`<slot ${ref(this._refSlot)}></slot> `;
}
static get styles(): CSSResultGroup {
return unsafeCSS(mediaGridStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-media-grid': FrigateCardMediaGrid;
}
}
+19 -26
View File
@@ -7,7 +7,8 @@ import {
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import surroundStyle from '../scss/surround.scss'; import { CameraManager } from '../camera-manager/manager.js';
import basicBlockStyle from '../scss/basic-block.scss';
import { import {
CardWideConfig, CardWideConfig,
ClipsOrSnapshotsOrAll, ClipsOrSnapshotsOrAll,
@@ -16,13 +17,11 @@ import {
ThumbnailsControlConfig, ThumbnailsControlConfig,
} from '../types.js'; } from '../types.js';
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js'; import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
import { CameraManager } from '../camera-manager/manager.js';
import { View } from '../view/view.js';
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
import './surround-basic.js';
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
import { getAllDependentCameras } from '../utils/camera.js'; import { getAllDependentCameras } from '../utils/camera.js';
import type { DataQuery } from '../camera-manager/types'; import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
import { View } from '../view/view.js';
import './surround-basic.js';
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
interface ThumbnailViewContext { interface ThumbnailViewContext {
// Whether or not to fetch thumbnails. // Whether or not to fetch thumbnails.
@@ -88,6 +87,7 @@ export class FrigateCardSurround extends LitElement {
this.cardWideConfig, this.cardWideConfig,
this.view, this.view,
{ {
allCameras: this.view.isGrid(),
targetView: this.view.view, targetView: this.view.view,
mediaType: this.fetchMedia, mediaType: this.fetchMedia,
select: 'latest', select: 'latest',
@@ -113,12 +113,14 @@ export class FrigateCardSurround extends LitElement {
import('./timeline.js'); import('./timeline.js');
} }
// Only reset the timeline cameraIDs when the media materially changes (and // Only reset the timeline cameraIDs when the media or display mode
// not on every view change, since the view will change frequently when the // materially changes (and not on every view change, since the view will
// user is scrubbing video). // change frequently when the user is scrubbing video).
const oldView = changedProperties.get('view');
if ( if (
changedProperties.has('view') && changedProperties.has('view') &&
View.isMajorMediaChange(changedProperties.get('view'), this.view) (View.isMajorMediaChange(oldView, this.view) ||
oldView.displayMode !== this.view?.displayMode)
) { ) {
this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined; this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined;
} }
@@ -138,23 +140,16 @@ export class FrigateCardSurround extends LitElement {
return null; return null;
} }
if (this.view?.is('live')) { if (this.view?.is('live')) {
return getAllDependentCameras(this.cameraManager, this.view.camera); return this.view.isGrid()
? this.cameraManager?.getStore().getVisibleCameraIDs() ?? null
: getAllDependentCameras(this.cameraManager, this.view.camera);
} }
if (this.view.isViewerView()) { if (this.view.isViewerView()) {
return new Set( return this.view.query?.getQueryCameraIDs() ?? null;
this.view.query
?.getQueries()
?.map((query: DataQuery) => [...query.cameraIDs])
.flat(),
);
} }
return null; return null;
} }
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.view) { if (!this.hass || !this.view) {
return; return;
@@ -183,6 +178,7 @@ export class FrigateCardSurround extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.config=${this.thumbnailConfig} .config=${this.thumbnailConfig}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.fadeThumbnails=${this.view.isViewerView()}
.view=${this.view} .view=${this.view}
.selected=${this.view.queryResults?.getSelectedIndex() ?? undefined} .selected=${this.view.queryResults?.getSelectedIndex() ?? undefined}
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')} @frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
@@ -230,11 +226,8 @@ export class FrigateCardSurround extends LitElement {
</frigate-card-surround-basic>`; </frigate-card-surround-basic>`;
} }
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(surroundStyle); return unsafeCSS(basicBlockStyle);
} }
} }
+41 -116
View File
@@ -1,5 +1,3 @@
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { import {
CSSResultGroup, CSSResultGroup,
html, html,
@@ -10,18 +8,16 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { CameraManager } from '../camera-manager/manager.js';
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
import { ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js'; import { ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { View } from '../view/view.js'; import { CarouselDirection } from '../utils/embla/carousel-controller.js';
import { MediaQueriesResults } from '../view/media-queries-results'; import { MediaQueriesResults } from '../view/media-queries-results';
import { FrigateCardCarousel } from './carousel.js'; import { View } from '../view/view.js';
import './thumbnail.js';
import './carousel.js'; import './carousel.js';
import { ifDefined } from 'lit/directives/if-defined.js'; import './thumbnail.js';
import { CameraManager } from '../camera-manager/manager.js';
export interface ThumbnailCarouselTap { export interface ThumbnailCarouselTap {
queryResults: MediaQueriesResults; queryResults: MediaQueriesResults;
@@ -38,83 +34,14 @@ export class FrigateCardThumbnailCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
// Thumbnail carousels can expand (e.g. drawer-based carousels after the main
// media loads). The carousel must be re-initialized in these cases, or the
// dynamic sizing fails (and users can scroll past the end of the carousel).
protected _resizeObserver: ResizeObserver;
@property({ attribute: false }) @property({ attribute: false })
public config?: ThumbnailsControlConfig; public config?: ThumbnailsControlConfig;
@property({ attribute: false }) @property({ attribute: false })
public selected? = 0; public fadeThumbnails = false;
protected _carouselOptions?: EmblaOptionsType = { protected _thumbnailSlides: TemplateResult[] = [];
containScroll: 'keepSnaps',
dragFree: true,
};
protected _carouselPlugins: EmblaPluginType[] = [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
// gestures as scrolling for the carousel.
forceWheelAxis: 'y',
}),
];
constructor() {
super();
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
}
/**
* Handle gallery resize.
*/
protected _resizeHandler(): void {
this._refCarousel.value?.carouselReInitWhenSafe();
}
/**
* Component connected callback.
*/
connectedCallback(): void {
super.connectedCallback();
this._resizeObserver.observe(this);
}
/**
* Component disconnected callback.
*/
disconnectedCallback(): void {
this._resizeObserver.disconnect();
super.disconnectedCallback();
}
/**
* Get slides to include in the render.
* @returns The slides to include in the render.
*/
protected _getSlides(): TemplateResult[] {
if (!this.view?.query || !this.view.queryResults?.hasResults()) {
return [];
}
const slides: TemplateResult[] = [];
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
const thumbnail = this._renderThumbnail(i);
if (thumbnail) {
slides[i] = thumbnail;
}
}
return slides;
}
/**
* Called when an update will occur.
* @param changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('config')) { if (changedProps.has('config')) {
if (this.config?.size) { if (this.config?.size) {
@@ -128,32 +55,41 @@ export class FrigateCardThumbnailCarousel extends LitElement {
} }
} }
if (changedProps.has('selected')) { const renderProperties = [
'cameraManager',
'config',
'transitionEffect',
'view',
] as const;
if (renderProperties.some((prop) => changedProps.has(prop))) {
this._thumbnailSlides = this._renderSlides();
}
if (changedProps.has('view')) {
this.style.setProperty( this.style.setProperty(
'--frigate-card-carousel-thumbnail-opacity', '--frigate-card-carousel-thumbnail-opacity',
this.selected === undefined ? '1.0' : '0.4', !this.fadeThumbnails || this._getSelectedSlide() === null ? '1.0' : '0.4',
); );
} }
} }
/** protected _getSelectedSlide(view?: View): number | null {
* Render a given thumbnail. return (view ?? this.view)?.queryResults?.getSelectedIndex() ?? null;
* @param mediaToRender The media item to render.
* @returns A template or void if the item could not be rendered.
*/
protected _renderThumbnail(index: number): TemplateResult | void {
const media = this.view?.queryResults?.getResult(index) ?? null;
if (!media || !this.view) {
return;
} }
protected _renderSlides(): TemplateResult[] {
const slides: TemplateResult[] = [];
const seekTarget = this.view?.context?.mediaViewer?.seek;
const selectedIndex = this._getSelectedSlide();
for (const media of this.view?.queryResults?.getResults() ?? []) {
const index = slides.length;
const classes = { const classes = {
embla__slide: true, embla__slide: true,
'slide-selected': this.selected === index, 'slide-selected': selectedIndex === index,
}; };
const seekTarget = this.view?.context?.mediaViewer?.seek; slides.push(html` <frigate-card-thumbnail
return html` <frigate-card-thumbnail
class="${classMap(classes)}" class="${classMap(classes)}"
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.hass=${this.hass} .hass=${this.hass}
@@ -170,53 +106,42 @@ export class FrigateCardThumbnailCarousel extends LitElement {
this, this,
'thumbnail-carousel:tap', 'thumbnail-carousel:tap',
{ {
queryResults: this.view.queryResults.clone().selectResult(index), queryResults: this.view.queryResults.clone().selectIndex(index),
}, },
); );
} }
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
> >
</frigate-card-thumbnail>`; </frigate-card-thumbnail>`);
}
return slides;
} }
/** protected _getDirection(): CarouselDirection | null {
* Get the direction of the thumbnail carousel.
* @returns `vertical`, `horizontal` or undefined.
*/
protected _getDirection(): 'horizontal' | 'vertical' | undefined {
if (this.config?.mode === 'left' || this.config?.mode === 'right') { if (this.config?.mode === 'left' || this.config?.mode === 'right') {
return 'vertical'; return 'vertical';
} else if (this.config?.mode === 'above' || this.config?.mode === 'below') { } else if (this.config?.mode === 'above' || this.config?.mode === 'below') {
return 'horizontal'; return 'horizontal';
} }
return undefined; return null;
} }
/**
* Render the element.
* @returns A template to display to the user.
*/
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const slides = this._getSlides(); const direction = this._getDirection();
if (!slides.length || !this.config || this.config.mode === 'none') { if (!this._thumbnailSlides.length || !this.config || !direction) {
return; return;
} }
return html`<frigate-card-carousel return html`<frigate-card-carousel
${ref(this._refCarousel)} direction=${direction}
direction=${ifDefined(this._getDirection())} .selected=${this._getSelectedSlide() ?? 0}
.selected=${this.selected ?? 0} .dragFree=${true}
.carouselOptions=${this._carouselOptions}
.carouselPlugins=${this._carouselPlugins}
> >
${slides} ${this._thumbnailSlides}
</frigate-card-carousel>`; </frigate-card-carousel>`;
} }
/**
* Get element styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(thumbnailCarouselStyle); return unsafeCSS(thumbnailCarouselStyle);
} }
+10 -7
View File
@@ -333,11 +333,18 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
@customElement('frigate-card-thumbnail') @customElement('frigate-card-thumbnail')
export class FrigateCardThumbnail extends LitElement { export class FrigateCardThumbnail extends LitElement {
// HomeAssistant object may be required for thumbnail signing (for Frigate // Performance: During timeline scrubbing, hass may be updated
// events). // continuously. As it is not needed for the thumbnail rendering itself, it
@property({ attribute: false }) // 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; 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>;
@property({ attribute: false }) @property({ attribute: false })
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
@@ -359,9 +366,6 @@ export class FrigateCardThumbnail extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public seek?: Date; public seek?: Date;
@property({ attribute: false })
public view?: Readonly<View>;
/** /**
* Render the element. * Render the element.
* @returns A template to display to the user. * @returns A template to display to the user.
@@ -381,7 +385,6 @@ export class FrigateCardThumbnail extends LitElement {
const shouldShowTimelineControl = const shouldShowTimelineControl =
this.show_timeline_control && this.show_timeline_control &&
this.view &&
(!ViewMediaClassifier.isRecording(this.media) || (!ViewMediaClassifier.isRecording(this.media) ||
// Only show timeline control if the recording has a start & end time. // Only show timeline control if the recording has a start & end time.
(this.media.getStartTime() && this.media.getEndTime())); (this.media.getStartTime() && this.media.getEndTime()));
+142 -120
View File
@@ -1,9 +1,7 @@
import add from 'date-fns/add'; import add from 'date-fns/add';
import differenceInSeconds from 'date-fns/differenceInSeconds'; import differenceInSeconds from 'date-fns/differenceInSeconds';
import endOfDay from 'date-fns/endOfDay'; import endOfDay from 'date-fns/endOfDay';
import endOfHour from 'date-fns/endOfHour';
import startOfDay from 'date-fns/startOfDay'; import startOfDay from 'date-fns/startOfDay';
import startOfHour from 'date-fns/startOfHour';
import sub from 'date-fns/sub'; import sub from 'date-fns/sub';
import { import {
CSSResultGroup, CSSResultGroup,
@@ -31,6 +29,7 @@ import {
import { CameraManager } from '../camera-manager/manager'; import { CameraManager } from '../camera-manager/manager';
import { rangesOverlap } from '../camera-manager/range'; import { rangesOverlap } from '../camera-manager/range';
import { MediaQuery } from '../camera-manager/types'; import { MediaQuery } from '../camera-manager/types';
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
import { localize } from '../localize/localize'; import { localize } from '../localize/localize';
import timelineCoreStyle from '../scss/timeline-core.scss'; import timelineCoreStyle from '../scss/timeline-core.scss';
import { import {
@@ -48,18 +47,23 @@ import {
dispatchFrigateCardEvent, dispatchFrigateCardEvent,
formatDateAndTime, formatDateAndTime,
isHoverableDevice, isHoverableDevice,
isTruthy,
setOrRemoveAttribute, setOrRemoveAttribute,
} from '../utils/basic'; } from '../utils/basic';
import { import { executeMediaQueryForView, findBestMediaIndex } from '../utils/media-to-view';
createQueriesForRecordingsView,
executeMediaQueryForView,
findBestMediaIndex,
} from '../utils/media-to-view';
import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source'; import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier'; import { ViewMediaClassifier } from '../view/media-classifier';
import { EventMediaQueries, MediaQueries } from '../view/media-queries'; import {
import { MediaQueriesClassifier } from '../view/media-queries-classifier'; EventMediaQueries,
MediaQueries,
RecordingMediaQueries,
} from '../view/media-queries';
import {
MediaQueriesClassifier,
MediaQueriesType,
} from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view'; import { View } from '../view/view';
import './date-picker.js'; import './date-picker.js';
import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js'; import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js';
@@ -82,7 +86,7 @@ interface TimelineViewContext {
} }
type TimelineItemClickAction = 'play' | 'select'; type TimelineItemClickAction = 'play' | 'select';
type TimelinePanBehavior = 'pan' | 'seek' | 'seek-in-media'; type TimelinePanBehavior = 'pan' | 'seek' | 'seek-in-media' | 'seek-in-camera';
declare module 'view' { declare module 'view' {
interface ViewContext { interface ViewContext {
@@ -285,13 +289,17 @@ export class FrigateCardTimelineCore extends LitElement {
? localize('timeline.pan_behavior.pan') ? localize('timeline.pan_behavior.pan')
: this._panBehavior === 'seek' : this._panBehavior === 'seek'
? localize('timeline.pan_behavior.seek') ? localize('timeline.pan_behavior.seek')
: localize('timeline.pan_behavior.seek-in-media'); : this._panBehavior === 'seek-in-media'
? localize('timeline.pan_behavior.seek-in-media')
: localize('timeline.pan_behavior.seek-in-camera');
const panIcon = const panIcon =
this._panBehavior === 'pan' this._panBehavior === 'pan'
? 'mdi:pan-horizontal' ? 'mdi:pan-horizontal'
: this._panBehavior === 'seek' : this._panBehavior === 'seek'
? 'mdi:filmstrip' ? 'mdi:filmstrip-box-multiple'
: 'mdi:lock'; : this._panBehavior === 'seek-in-media'
? 'mdi:play-box-lock'
: 'mdi:camera-lock';
return html` ${capabilities?.supportsTimeline return html` ${capabilities?.supportsTimeline
? html` <div ? html` <div
@@ -311,6 +319,8 @@ export class FrigateCardTimelineCore extends LitElement {
? 'seek' ? 'seek'
: this._panBehavior === 'seek' : this._panBehavior === 'seek'
? 'seek-in-media' ? 'seek-in-media'
: this._panBehavior === 'seek-in-media'
? 'seek-in-camera'
: 'pan'; : 'pan';
}} }}
aria-label="${panTitle}" aria-label="${panTitle}"
@@ -403,10 +413,13 @@ export class FrigateCardTimelineCore extends LitElement {
const targetBarOn = const targetBarOn =
this._shouldSupportSeeking() && this._shouldSupportSeeking() &&
(this._panBehavior === 'seek' || (this._panBehavior === 'seek' ||
this._panBehavior === 'seek-in-camera' ||
(this._panBehavior === 'seek-in-media' && (this._panBehavior === 'seek-in-media' &&
this._timeline.getSelection().some((id) => { this._timeline.getSelection().some((id) => {
const item = this._timelineSource?.dataset?.get(id); const item = this._timelineSource?.dataset?.get(id);
return ( return (
this._panBehavior !== 'seek-in-camera' ||
item?.media?.getCameraID() === this.view?.camera,
item && item &&
item.start && item.start &&
item.end && item.end &&
@@ -479,13 +492,30 @@ export class FrigateCardTimelineCore extends LitElement {
} }
const canSeek = this._shouldSupportSeeking(); const canSeek = this._shouldSupportSeeking();
const newResults = let newResults: MediaQueriesResults | null = null;
this._panBehavior === 'seek-in-media'
? null if (this._panBehavior === 'seek') {
: results newResults = results
.clone() .clone()
.resetSelectedResult() .resetSelectedResult()
.selectBestResult((media) => findBestMediaIndex(media, targetTime)); .selectBestResult(
(mediaArray) => findBestMediaIndex(mediaArray, targetTime, this.view?.camera),
{
allCameras: true,
main: true,
},
);
} else if (this._panBehavior === 'seek-in-camera') {
newResults = results
.clone()
.resetSelectedResult()
.selectBestResult((mediaArray) => findBestMediaIndex(mediaArray, targetTime), {
cameraID: this.view.camera,
})
.promoteCameraSelectionToMainSelection(this.view.camera);
} else if (this._panBehavior === 'seek-in-media') {
newResults = results;
}
const desiredView: FrigateCardView = this.mini const desiredView: FrigateCardView = this.mini
? targetTime >= new Date() ? targetTime >= new Date()
@@ -493,11 +523,12 @@ export class FrigateCardTimelineCore extends LitElement {
: 'media' : 'media'
: this.view.view; : this.view.view;
const selectedCamera = newResults?.getSelectedResult()?.getCameraID();
this.view this.view
.evolve({ .evolve({
...(selectedCamera && { camera: selectedCamera }),
view: desiredView, view: desiredView,
...(newResults && queryResults: newResults,
newResults.hasSelectedResult() && { queryResults: newResults }),
}) // Whether or not to set the timeline window. }) // Whether or not to set the timeline window.
.mergeInContext({ .mergeInContext({
...(canSeek && { mediaViewer: { seek: targetTime } }), ...(canSeek && { mediaViewer: { seek: targetTime } }),
@@ -533,6 +564,7 @@ export class FrigateCardTimelineCore extends LitElement {
!this.cameraManager || !this.cameraManager ||
!this.cardWideConfig || !this.cardWideConfig ||
!timelineCameraIDs || !timelineCameraIDs ||
!this._timelineSource ||
!properties.what !properties.what
) { ) {
return; return;
@@ -543,45 +575,10 @@ export class FrigateCardTimelineCore extends LitElement {
if ( if (
this.timelineConfig?.show_recordings && this.timelineConfig?.show_recordings &&
['background', 'group-label'].includes(properties.what) properties.time &&
['background', 'axis'].includes(properties.what)
) { ) {
const cameraIDs = properties.group const query = this._createMediaQueries('recording');
? new Set([String(properties.group)])
: this._getTimelineCameraIDs();
const query = cameraIDs
? createQueriesForRecordingsView(
this.cameraManager,
this.cardWideConfig,
cameraIDs,
)
: null;
if (query) {
view = await executeMediaQueryForView(
this,
this.hass,
this.cameraManager,
this.view,
query,
{
targetView: 'recording',
targetTime:
properties.what === 'background'
? properties.time
: this._timeline.getWindow().end,
select: 'time',
},
);
}
} else if (this.timelineConfig?.show_recordings && properties.what === 'axis') {
const query = createQueriesForRecordingsView(
this.cameraManager,
this.cardWideConfig,
timelineCameraIDs,
{
start: startOfHour(properties.time),
end: endOfHour(properties.time),
},
);
if (query) { if (query) {
view = await executeMediaQueryForView( view = await executeMediaQueryForView(
this, this,
@@ -597,10 +594,15 @@ export class FrigateCardTimelineCore extends LitElement {
); );
} }
} else if (properties.item && properties.what === 'item') { } else if (properties.item && properties.what === 'item') {
const cameraID = String(properties.group);
const criteria = {
main: true,
...(cameraID && this.view.isGrid() && { cameraID: cameraID }),
};
const newResults = this.view.queryResults const newResults = this.view.queryResults
?.clone() ?.clone()
.resetSelectedResult() .resetSelectedResult()
.selectResultIfFound((media) => media.getID() === properties.item); .selectResultIfFound((media) => media.getID() === properties.item, criteria);
if (!newResults || !newResults.hasSelectedResult()) { if (!newResults || !newResults.hasSelectedResult()) {
// This can happen in a few situations: // This can happen in a few situations:
@@ -610,8 +612,8 @@ export class FrigateCardTimelineCore extends LitElement {
// gallery (i.e. any case where the thumbnails may not be match the // gallery (i.e. any case where the thumbnails may not be match the
// events on the timeline, e.g. in the snapshots viewer but // events on the timeline, e.g. in the snapshots viewer but
// mini-timeline showing all media). // mini-timeline showing all media).
const fullEventView = await this._createViewWithEventMediaQuery( const fullEventView = await this._createViewWithMediaQueries(
this._createEventMediaQuerys(), this._createMediaQueries('event'),
{ {
selectedItem: properties.item, selectedItem: properties.item,
targetView: 'media', targetView: 'media',
@@ -673,24 +675,18 @@ export class FrigateCardTimelineCore extends LitElement {
} }
this._removeTargetBar(); this._removeTargetBar();
if (!this.hass) { if (!this.hass || !this._timeline || !this.view) {
return; return;
} }
const prefetchedWindow = this._getPrefetchWindow(properties); await this._timelineSource?.refresh(this.hass, this._getPrefetchWindow(properties));
await this._timelineSource?.refresh(this.hass, prefetchedWindow);
// Don't show event thumbnails if the user is looking at recordings, const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
// as the recording "hours" are the media, not the event if (!queryType) {
// clips/snapshots. return;
if ( }
this._timeline && const mediaQuery = this._createMediaQueries(queryType);
this.view && const newView = await this._createViewWithMediaQueries(mediaQuery);
!MediaQueriesClassifier.areRecordingQueries(this.view.query)
) {
const newView = await this._createViewWithEventMediaQuery(
this._createEventMediaQuerys({ window: this._timeline.getWindow() }),
);
// Specifically avoid dispatching new results on range change unless there // Specifically avoid dispatching new results on range change unless there
// is something to be gained by doing so. Example usecase: On initial view // is something to be gained by doing so. Example usecase: On initial view
@@ -703,29 +699,34 @@ export class FrigateCardTimelineCore extends LitElement {
newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this); newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this);
} }
} }
}
protected _createEventMediaQuerys(options?: { protected _createMediaQueries(
type: MediaQueriesType,
options?: {
window?: TimelineWindow; window?: TimelineWindow;
}): EventMediaQueries | null { },
if (!this._timeline || !this._timelineSource || !this.cardWideConfig) { ): MediaQueries | null {
if (!this._timeline || !this._timelineSource) {
return null; return null;
} }
const cacheFriendlyWindow = this._timelineSource.getCacheFriendlyEventWindow( const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(
options?.window ?? this._timeline.getWindow(), this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()),
); );
const eventQueries = if (type === 'event') {
this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow); const queries = this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow);
if (!eventQueries) { return queries ? new EventMediaQueries(queries) : null;
} else if (type === 'recording') {
const queries =
this._timelineSource.getTimelineRecordingQueries(cacheFriendlyWindow);
return queries ? new RecordingMediaQueries(queries) : null;
}
return null; return null;
} }
return new EventMediaQueries(eventQueries);
}
protected async _createViewWithEventMediaQuery( protected async _createViewWithMediaQueries(
query: EventMediaQueries | null, query: MediaQueries | null,
options?: { options?: {
targetView?: FrigateCardView; targetView?: FrigateCardView;
selectedItem?: IdType; selectedItem?: IdType;
@@ -788,11 +789,12 @@ export class FrigateCardTimelineCore extends LitElement {
return new DataSet(groups); return new DataSet(groups);
} }
protected _getPerfectWindowFromMedia(media: ViewMedia): TimelineWindow | null { protected _getPerfectWindowFromMediaStartAndEndTime(
const startTime = media.getStartTime(); isEvent: boolean,
const endTime = media.getEndTime(); startTime: Date | null,
endTime: Date | null,
if (ViewMediaClassifier.isEvent(media)) { ): TimelineWindow | null {
if (isEvent) {
const windowSeconds = this._getConfiguredWindowSeconds(); const windowSeconds = this._getConfiguredWindowSeconds();
if (startTime && endTime) { if (startTime && endTime) {
@@ -820,7 +822,7 @@ export class FrigateCardTimelineCore extends LitElement {
end: add(startTime, { seconds: windowSeconds / 2 }), end: add(startTime, { seconds: windowSeconds / 2 }),
}; };
} }
} else if (ViewMediaClassifier.isRecording(media) && startTime && endTime) { } else if (startTime && endTime) {
return { return {
start: startTime, start: startTime,
end: endTime, end: endTime,
@@ -889,8 +891,7 @@ export class FrigateCardTimelineCore extends LitElement {
maxItems: this.timelineConfig.clustering_threshold, maxItems: this.timelineConfig.clustering_threshold,
clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => {
const media = this.view?.queryResults?.getSelectedResult(); const selectedIDs = this._getAllSelectedMediaIDsFromView();
const selectedId = media?.getID();
const firstMedia = (<FrigateCardTimelineItem>first).media; const firstMedia = (<FrigateCardTimelineItem>first).media;
const secondMedia = (<FrigateCardTimelineItem>second).media; const secondMedia = (<FrigateCardTimelineItem>second).media;
@@ -900,8 +901,8 @@ export class FrigateCardTimelineCore extends LitElement {
return ( return (
first.type !== 'background' && first.type !== 'background' &&
first.type === second.type && first.type === second.type &&
first.id !== selectedId && !selectedIDs.includes(first.id) &&
second.id !== selectedId && !selectedIDs.includes(second.id) &&
!!firstMedia && !!firstMedia &&
!!secondMedia && !!secondMedia &&
ViewMediaClassifier.isEvent(firstMedia) && ViewMediaClassifier.isEvent(firstMedia) &&
@@ -956,6 +957,18 @@ export class FrigateCardTimelineCore extends LitElement {
return !!this.hass && !!this.cameraManager; return !!this.hass && !!this.cameraManager;
} }
protected _getAllSelectedMediaIDsFromView(): IdType[] {
return (
this.view?.queryResults?.getMultipleSelectedResults({
main: true,
...(this.view.isGrid() && { allCameras: true }),
}) ?? []
)
.filter((media) => ViewMediaClassifier.isEvent(media))
.map((media) => media.getID())
.filter(isTruthy);
}
/** /**
* Update the timeline from the view object. * Update the timeline from the view object.
*/ */
@@ -980,8 +993,10 @@ export class FrigateCardTimelineCore extends LitElement {
let desiredWindow = timelineWindow; let desiredWindow = timelineWindow;
const media = this.view.queryResults?.getSelectedResult(); const media = this.view.queryResults?.getSelectedResult();
const mediaStartTime = media?.getStartTime(); const mediaStartTime = media?.getStartTime() ?? null;
const mediaEndTime = media?.getEndTime(); const mediaEndTime = media?.getEndTime() ?? null;
const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false;
const mediaWindow: TimelineWindow | null = const mediaWindow: TimelineWindow | null =
media && mediaStartTime media && mediaStartTime
? // If this media has no end time, it's just a "point" in time so the ? // If this media has no end time, it's just a "point" in time so the
@@ -996,8 +1011,12 @@ export class FrigateCardTimelineCore extends LitElement {
if (context && context.window) { if (context && context.window) {
desiredWindow = context.window; desiredWindow = context.window;
} else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) { } else if (mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) {
const perfectMediaWindow = this._getPerfectWindowFromMedia(media); const perfectMediaWindow = this._getPerfectWindowFromMediaStartAndEndTime(
mediaIsEvent,
mediaStartTime,
mediaEndTime,
);
if (perfectMediaWindow) { if (perfectMediaWindow) {
desiredWindow = perfectMediaWindow; desiredWindow = perfectMediaWindow;
} }
@@ -1013,22 +1032,27 @@ export class FrigateCardTimelineCore extends LitElement {
await this._timelineSource?.refresh(this.hass, prefetchedWindow); await this._timelineSource?.refresh(this.hass, prefetchedWindow);
} }
const mediaID = media?.getID(); const currentSelection = this._timeline.getSelection();
if (media && mediaID && this._isClustering()) { const mediaIDsToSelect = this._getAllSelectedMediaIDsFromView();
const needToSelect = mediaIDsToSelect.some(
(mediaID) => !currentSelection.includes(mediaID),
);
if (needToSelect) {
if (this._isClustering()) {
// Hack: Clustering may not update unless the dataset changes, artifically // Hack: Clustering may not update unless the dataset changes, artifically
// update the dataset to ensure the newly selected item cannot be included // update the dataset to ensure the newly selected item cannot be included
// in a cluster. Only do this when the pointer is not held to avoid // in a cluster.
// interrupting the user and to make the timeline smoother.
for (const mediaID of mediaIDsToSelect) {
// Need to this rewrite prior to setting the selection (just below), or // Need to this rewrite prior to setting the selection (just below), or
// the selection will be lost on rewrite. // the selection will be lost on rewrite.
this._timelineSource?.rewriteEvent(mediaID); this._timelineSource?.rewriteEvent(mediaID);
} }
}
const desiredId = this._timeline?.setSelection(mediaIDsToSelect, {
!!media && ViewMediaClassifier.isEvent(media) ? media.getID() : null;
if (desiredId) {
this._timeline?.setSelection([desiredId], {
focus: false, focus: false,
animation: { animation: {
animation: false, animation: false,
@@ -1053,20 +1077,23 @@ export class FrigateCardTimelineCore extends LitElement {
// -> New view received ... [loop] // -> New view received ... [loop]
// //
// Also don't generate thumbnails in mini-timelines (they will already have // Also don't generate thumbnails in mini-timelines (they will already have
// been generated), or if the view is for recordings (media thumbnails are // been generated).
// recordings, not events in this case).
const freshMediaQuery = this._createEventMediaQuerys({ const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
if (!queryType) {
return;
}
const freshMediaQuery = this._createMediaQueries(queryType, {
window: desiredWindow, window: desiredWindow,
}); });
if ( if (
!this.mini && !this.mini &&
!MediaQueriesClassifier.areRecordingQueries(this.view.query) &&
freshMediaQuery && freshMediaQuery &&
!this._alreadyHasAcceptableMediaQuery(freshMediaQuery) !this._alreadyHasAcceptableMediaQuery(freshMediaQuery)
) { ) {
(await this._createViewWithEventMediaQuery(freshMediaQuery)) (await this._createViewWithMediaQueries(freshMediaQuery))
?.mergeInContext(this._getTimelineContext(desiredWindow)) ?.mergeInContext(this._getTimelineContext(desiredWindow))
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
} }
@@ -1194,8 +1221,6 @@ export class FrigateCardTimelineCore extends LitElement {
createdTimeline = true; createdTimeline = true;
const noGroups = this.mini && groups.length === 1; const noGroups = this.mini && groups.length === 1;
if (noGroups) { if (noGroups) {
// In a mini timeline, if there's only one group don't bother grouping
// at all.
this._timeline = new Timeline( this._timeline = new Timeline(
this._refTimeline.value, this._refTimeline.value,
this._timelineSource.dataset, this._timelineSource.dataset,
@@ -1244,9 +1269,6 @@ export class FrigateCardTimelineCore extends LitElement {
} }
} }
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(timelineCoreStyle); return unsafeCSS(timelineCoreStyle);
} }
+3 -10
View File
@@ -1,8 +1,8 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import timelineStyle from '../scss/timeline.scss';
import { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
import { CameraManager } from '../camera-manager/manager'; import { CameraManager } from '../camera-manager/manager';
import basicBlockStyle from '../scss/basic-block.scss';
import { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
import { View } from '../view/view'; import { View } from '../view/view';
import './surround.js'; import './surround.js';
import './timeline-core.js'; import './timeline-core.js';
@@ -24,10 +24,6 @@ export class FrigateCardTimeline extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public cardWideConfig?: CardWideConfig; public cardWideConfig?: CardWideConfig;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.timelineConfig) { if (!this.timelineConfig) {
return html``; return html``;
@@ -49,11 +45,8 @@ export class FrigateCardTimeline extends LitElement {
`; `;
} }
/**
* Return compiled CSS styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(timelineStyle); return unsafeCSS(basicBlockStyle);
} }
} }
+42 -21
View File
@@ -1,14 +1,52 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { TitleControlConfig } from '../types.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js';
import titleStyle from '../scss/title-control.scss'; import titleStyle from '../scss/title-control.scss';
import { TitleControlConfig } from '../types.js';
import { Timer } from '../utils/timer';
import { View } from '../view/view.js';
type PaperToast = HTMLElement & { type PaperToast = HTMLElement & {
opened: boolean; opened: boolean;
}; };
export const showTitleControlAfterDelay = (
control: FrigateCardTitleControl,
timer: Timer,
delay = 0.5,
): void => {
const show = () => {
timer.stop();
control.show();
};
if (control.isVisible()) {
// If it's already visible, update it immediately (but also update it
// after the timer expires to ensure it re-positions if necessary, see
// comment below).
show();
}
// Allow a brief pause after the media loads, but before the title is
// displayed. This allows for a pleasant appearance/disappear of the title,
// and allows for the browser to finish rendering the carousel.
timer.start(delay, show);
};
export const getDefaultTitleConfigForView = (
view?: Readonly<View>,
baseConfig?: TitleControlConfig,
): TitleControlConfig | null => {
if (!baseConfig && view?.isGrid()) {
return { mode: 'none', duration_seconds: 2 };
}
return {
mode: 'popup-bottom-right',
duration_seconds: 2,
...baseConfig,
};
};
@customElement('frigate-card-title-control') @customElement('frigate-card-title-control')
export class FrigateCardTitleControl extends LitElement { export class FrigateCardTitleControl extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
@@ -25,10 +63,6 @@ export class FrigateCardTitleControl extends LitElement {
protected _toastRef: Ref<PaperToast> = createRef(); protected _toastRef: Ref<PaperToast> = createRef();
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) { if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) {
return html``; return html``;
@@ -50,17 +84,10 @@ export class FrigateCardTitleControl extends LitElement {
</paper-toast>`; </paper-toast>`;
} }
/**
* Determine if the toast is visible.
* @returns `true` if the toast is visible, `false` otherwise.
*/
public isVisible(): boolean { public isVisible(): boolean {
return this._toastRef.value?.opened ?? false; return this._toastRef.value?.opened ?? false;
} }
/**
* Show the toast.
*/
public hide(): void { public hide(): void {
if (this._toastRef.value) { if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets. // Set it to false first, to ensure the timer resets.
@@ -68,9 +95,6 @@ export class FrigateCardTitleControl extends LitElement {
} }
} }
/**
* Show the toast.
*/
public show(): void { public show(): void {
if (this._toastRef.value) { if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets. // Set it to false first, to ensure the timer resets.
@@ -79,9 +103,6 @@ export class FrigateCardTitleControl extends LitElement {
} }
} }
/**
* Get element styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(titleStyle); return unsafeCSS(titleStyle);
} }
+297 -153
View File
@@ -1,5 +1,3 @@
import { EmblaPluginType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { import {
CSSResultGroup, CSSResultGroup,
html, html,
@@ -8,13 +6,19 @@ import {
TemplateResult, TemplateResult,
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js'; 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 { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../camera-manager/manager.js'; import { CameraManager } from '../camera-manager/manager.js';
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js'; import {
dispatchMessageEvent,
renderMessage,
renderProgressIndicator,
} from '../components/message.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import '../patches/ha-hls-player'; import '../patches/ha-hls-player';
import basicBlockStyle from '../scss/basic-block.scss';
import viewerCarouselStyle from '../scss/viewer-carousel.scss'; import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import viewerProviderStyle from '../scss/viewer-provider.scss'; import viewerProviderStyle from '../scss/viewer-provider.scss';
import viewerStyle from '../scss/viewer.scss'; import viewerStyle from '../scss/viewer.scss';
@@ -29,9 +33,19 @@ import {
} from '../types.js'; } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
import { mayHaveAudio } from '../utils/audio.js'; import { mayHaveAudio } from '../utils/audio.js';
import { contentsChanged, errorToConsole } from '../utils/basic.js'; import {
contentsChanged,
errorToConsole,
setOrRemoveAttribute,
} from '../utils/basic.js';
import { CarouselSelected } from '../utils/embla/carousel-controller.js';
import { AutoLazyLoad } from '../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
import { AutoMediaActions } from '../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
import AutoMediaLoadedInfo from '../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js';
import { canonicalizeHAURL } from '../utils/ha/index.js'; import { canonicalizeHAURL } from '../utils/ha/index.js';
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
import { MediaGridSelected } from '../utils/media-grid-controller.js';
import { import {
dispatchMediaLoadedEvent, dispatchMediaLoadedEvent,
dispatchMediaPauseEvent, dispatchMediaPauseEvent,
@@ -50,21 +64,21 @@ import {
setControlsOnVideo, setControlsOnVideo,
} from '../utils/media.js'; } from '../utils/media.js';
import { screenshotMedia } from '../utils/screenshot.js'; import { screenshotMedia } from '../utils/screenshot.js';
import { Timer } from '../utils/timer';
import { ViewMediaClassifier } from '../view/media-classifier'; import { ViewMediaClassifier } from '../view/media-classifier';
import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier';
import { MediaQueriesResults } from '../view/media-queries-results.js'; import { MediaQueriesResults } from '../view/media-queries-results.js';
import { VideoContentType, ViewMedia } from '../view/media.js'; import { VideoContentType, ViewMedia } from '../view/media.js';
import { View } from '../view/view.js'; import { View } from '../view/view.js';
import type { CarouselSelect } from './carousel.js'; import type { EmblaCarouselPlugins } from './carousel.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload } from './embla-plugins/lazyload.js';
import {
FrigateCardMediaCarousel,
wrapMediaLoadedEventForCarousel,
} from './media-carousel.js';
import './next-prev-control.js'; import './next-prev-control.js';
import './surround.js'; import './surround.js';
import './title-control.js'; import './title-control.js';
import {
FrigateCardTitleControl,
getDefaultTitleConfigForView,
showTitleControlAfterDelay,
} from './title-control.js';
export interface MediaViewerViewContext { export interface MediaViewerViewContext {
seek?: Date; seek?: Date;
@@ -76,6 +90,16 @@ declare module 'view' {
} }
} }
interface MediaNeighbor {
index: number;
media: ViewMedia;
}
interface MediaNeighbors {
previous?: MediaNeighbor;
next?: MediaNeighbor;
}
@customElement('frigate-card-viewer') @customElement('frigate-card-viewer')
export class FrigateCardViewer extends LitElement { export class FrigateCardViewer extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
@@ -96,10 +120,6 @@ export class FrigateCardViewer extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public cardWideConfig?: CardWideConfig; public cardWideConfig?: CardWideConfig;
/**
* Master render method.
* @returns A rendered template.
*/
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if ( if (
!this.hass || !this.hass ||
@@ -118,7 +138,14 @@ export class FrigateCardViewer extends LitElement {
// timeline). // timeline).
const mediaType = this.view.getDefaultMediaType(); const mediaType = this.view.getDefaultMediaType();
if (!mediaType) { if (!mediaType) {
return; // Directly render an error message (instead of dispatching it upwards)
// to preserve the mini-timeline if the user scans into an area with no
// media.
return renderMessage({
type: 'info',
message: localize('common.no_media'),
icon: 'mdi:multimedia',
});
} }
if (mediaType === 'recordings') { if (mediaType === 'recordings') {
@@ -129,8 +156,8 @@ export class FrigateCardViewer extends LitElement {
this.cardWideConfig, this.cardWideConfig,
this.view, this.view,
{ {
allCameras: this.view.isGrid(),
targetView: 'recording', targetView: 'recording',
select: 'latest',
}, },
); );
} else { } else {
@@ -141,17 +168,16 @@ export class FrigateCardViewer extends LitElement {
this.cardWideConfig, this.cardWideConfig,
this.view, this.view,
{ {
allCameras: this.view.isGrid(),
targetView: 'media', targetView: 'media',
mediaType: mediaType, mediaType: mediaType,
select: 'latest',
}, },
); );
} }
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig }); return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
} }
return html` return html` <frigate-card-viewer-grid
<frigate-card-viewer-carousel
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.viewerConfig=${this.viewerConfig} .viewerConfig=${this.viewerConfig}
@@ -159,13 +185,9 @@ export class FrigateCardViewer extends LitElement {
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
> >
</frigate-card-viewer-carousel> </frigate-card-viewer-grid>`;
`;
} }
/**
* Get element styles.
*/
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(viewerStyle); return unsafeCSS(viewerStyle);
} }
@@ -181,6 +203,9 @@ export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public view?: Readonly<View>; public view?: Readonly<View>;
@property({ attribute: false })
public viewFilterCameraID?: string;
// Resetting the viewer configuration causes a full reset so ensure the config // Resetting the viewer configuration causes a full reset so ensure the config
// has actually changed with a full comparison (dynamic configuration // has actually changed with a full comparison (dynamic configuration
// overrides may causes changes elsewhere in the full card configuration that // overrides may causes changes elsewhere in the full card configuration that
@@ -198,7 +223,13 @@ export class FrigateCardViewerCarousel extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef(); @state()
protected _selected = 0;
protected _media: ViewMedia[] | null = null;
protected _titleTimer = new Timer();
protected _refTitleControl: Ref<FrigateCardTitleControl> = createRef();
protected _player: FrigateCardMediaPlayer | null = null;
/** /**
* The updated lifecycle callback for this element. * The updated lifecycle callback for this element.
@@ -229,47 +260,19 @@ export class FrigateCardViewerCarousel extends LitElement {
); );
} }
/**
* The the HLS player on a slide (or current slide if not provided.)
* @param slide An optional slide.
* @returns The FrigateCardMediaPlayer or null if not found.
*/
protected _getPlayer(slide?: HTMLElement | null): FrigateCardMediaPlayer | null {
if (!slide) {
slide = this._refMediaCarousel.value
?.frigateCardCarousel()
?.getCarouselSelected()?.element;
}
return (
(slide?.querySelector(
FRIGATE_CARD_VIEWER_PROVIDER,
) as unknown as FrigateCardMediaPlayer) ?? null
);
}
/** /**
* Get the Embla plugins to use. * Get the Embla plugins to use.
* @returns A list of EmblaOptionsTypes. * @returns A list of EmblaOptionsTypes.
*/ */
protected _getPlugins(): EmblaPluginType[] { protected _getPlugins(): EmblaCarouselPlugins {
return [ return [
// Only enable wheel plugin if there is more than one media item. AutoLazyLoad({
...(this.view?.queryResults?.getResultsCount() ?? 0 > 1
? [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
// gestures as scrolling for the carousel.
forceWheelAxis: 'y',
}),
]
: []),
Lazyload({
...(this.viewerConfig?.lazy_load && { ...(this.viewerConfig?.lazy_load && {
lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide), lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide),
}), }),
}), }),
AutoMediaPlugin({ AutoMediaLoadedInfo(),
AutoMediaActions({
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER, playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
...(this.viewerConfig?.auto_play && { ...(this.viewerConfig?.auto_play && {
autoPlayCondition: this.viewerConfig.auto_play, autoPlayCondition: this.viewerConfig.auto_play,
@@ -284,6 +287,7 @@ export class FrigateCardViewerCarousel extends LitElement {
autoUnmuteCondition: this.viewerConfig.auto_unmute, autoUnmuteCondition: this.viewerConfig.auto_unmute,
}), }),
}), }),
AutoSize(),
]; ];
} }
@@ -292,44 +296,51 @@ export class FrigateCardViewerCarousel extends LitElement {
* @returns A BrowseMediaNeighbors with indices and objects of true media * @returns A BrowseMediaNeighbors with indices and objects of true media
* neighbors. * neighbors.
*/ */
protected _getMediaNeighbors(): [ViewMedia | null, ViewMedia | null] { protected _getMediaNeighbors(): MediaNeighbors | null {
const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null; const mediaCount = this._media?.length ?? 0;
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0; if (!this._media) {
if (!this.view || !this.view.queryResults || selectedIndex === null) { return null;
return [null, null];
} }
const previous: ViewMedia | null = const prevIndex = this._selected > 0 ? this._selected - 1 : null;
selectedIndex > 0 ? this.view.queryResults.getResult(selectedIndex - 1) : null; const nextIndex = this._selected + 1 < mediaCount ? this._selected + 1 : null;
const next: ViewMedia | null = return {
selectedIndex + 1 < resultCount ...(prevIndex !== null && {
? this.view.queryResults.getResult(selectedIndex + 1) previous: {
: null; index: prevIndex,
return [previous, next]; media: this._media[prevIndex],
} },
}),
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void { ...(nextIndex !== null && {
this._setViewSelectedIndex(ev.detail.index); next: {
index: nextIndex,
media: this._media[nextIndex],
},
}),
};
} }
protected _setViewSelectedIndex(index: number): void { protected _setViewSelectedIndex(index: number): void {
if (!this.view?.queryResults) { if (!this._media) {
return; return;
} }
const selectedIndex = this.view.queryResults.getSelectedIndex(); if (this._selected === index) {
if (selectedIndex === null || selectedIndex === index) {
// The slide may already be selected on load, so don't dispatch a new view // The slide may already be selected on load, so don't dispatch a new view
// unless necessary (i.e. the new index is different from the current // unless necessary (i.e. the new index is different from the current
// index). // index).
return; return;
} }
const newResults = this.view?.queryResults?.clone().selectResult(index); const newResults = this.view?.queryResults
?.clone()
.selectIndex(index, this.viewFilterCameraID);
if (!newResults) { if (!newResults) {
return; return;
} }
const cameraID = newResults.getSelectedResult()?.getCameraID(); const cameraID = newResults
.getSelectedResult(this.viewFilterCameraID)
?.getCameraID();
this.view this.view
?.evolve({ ?.evolve({
@@ -338,6 +349,7 @@ export class FrigateCardViewerCarousel extends LitElement {
// Always change the camera to the owner of the selected media. // Always change the camera to the owner of the selected media.
...(cameraID && { camera: cameraID }), ...(cameraID && { camera: cameraID }),
}) })
.removeContextProperty('mediaViewer', 'seek')
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
} }
@@ -354,7 +366,7 @@ export class FrigateCardViewerCarousel extends LitElement {
'frigate-card-viewer-provider', 'frigate-card-viewer-provider',
) as FrigateCardViewerProvider | null; ) as FrigateCardViewerProvider | null;
if (viewerProvider) { if (viewerProvider) {
viewerProvider.disabled = false; viewerProvider.load = true;
} }
} }
@@ -363,15 +375,15 @@ export class FrigateCardViewerCarousel extends LitElement {
* @returns The slides to include in the render. * @returns The slides to include in the render.
*/ */
protected _getSlides(): TemplateResult[] { protected _getSlides(): TemplateResult[] {
if (!this.view || !this.view.queryResults) { if (!this._media) {
return []; return [];
} }
const slides: TemplateResult[] = []; const slides: TemplateResult[] = [];
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) { for (let i = 0; i < this._media.length; ++i) {
const media = this.view.queryResults.getResult(i); const media = this._media[i];
if (media) { if (media) {
const slide = this._renderMediaItem(media, i); const slide = this._renderMediaItem(media);
if (slide) { if (slide) {
slides[i] = slide; slides[i] = slide;
} }
@@ -388,11 +400,25 @@ export class FrigateCardViewerCarousel extends LitElement {
if (changedProps.has('viewerConfig')) { if (changedProps.has('viewerConfig')) {
updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout); updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout);
} }
if (changedProps.has('view')) {
const newMedia =
this.view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
const newSelected =
this.view?.queryResults?.getSelectedIndex(this.viewFilterCameraID) ?? 0;
const newSeek = this.view?.context?.mediaViewer?.seek;
if (newMedia !== this._media || newSelected !== this._selected || !newSeek) {
setOrRemoveAttribute(this, false, 'unseekable');
this._media = newMedia;
this._selected = newSelected;
}
}
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0; const mediaCount = this._media?.length ?? 0;
if (!resultCount) { if (!this._media || !mediaCount) {
return dispatchMessageEvent(this, localize('common.no_media'), 'info', { return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
icon: 'mdi:multimedia', icon: 'mdi:multimedia',
}); });
@@ -401,82 +427,98 @@ export class FrigateCardViewerCarousel extends LitElement {
// If there's no selected media, just choose the last (most recent one) to // If there's no selected media, just choose the last (most recent one) to
// avoid rendering a blank. This situation should not occur in practice, as // avoid rendering a blank. This situation should not occur in practice, as
// this view should not be called without a selected media. // this view should not be called without a selected media.
const media = const selectedMedia = this._media[this._selected] ?? this._media[mediaCount - 1];
this.view?.queryResults?.getSelectedResult() ??
this.view?.queryResults?.getResult(resultCount - 1); if (!this.hass || !this.cameraManager || !selectedMedia) {
if (
!this.hass ||
!this.cameraManager ||
!media ||
!this.view ||
!this.view.queryResults
) {
return; return;
} }
const [prev, next] = this._getMediaNeighbors(); const neighbors = this._getMediaNeighbors();
const scroll = (direction: 'previous' | 'next'): void => { const scroll = (direction: 'previous' | 'next'): void => {
const currentIndex = this.view?.queryResults?.getSelectedIndex() ?? null; if (!neighbors || !this._media) {
if (!this.view || !this.view?.queryResults || currentIndex === null) {
return; return;
} }
const newIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1; const newIndex =
if (newIndex >= 0 && newIndex < this.view.queryResults.getResultsCount()) { (direction === 'previous' ? neighbors.previous?.index : neighbors.next?.index) ??
null;
if (newIndex !== null) {
this._setViewSelectedIndex(newIndex); this._setViewSelectedIndex(newIndex);
} }
}; };
const cameraMetadata = this.cameraManager.getCameraMetadata( const cameraMetadata = this.cameraManager.getCameraMetadata(
this.hass, this.hass,
media.getCameraID(), selectedMedia.getCameraID(),
); );
return html` <frigate-card-media-carousel const titleConfig = getDefaultTitleConfigForView(
${ref(this._refMediaCarousel)} this.view,
.carouselOptions=${guard([this.viewerConfig], () => ({ this.viewerConfig?.controls.title,
draggable: this.viewerConfig?.draggable ?? true, );
}))}
.carouselPlugins=${guard( return html`
[this.viewerConfig, this.view.queryResults.getResults()], <frigate-card-carousel
this._getPlugins.bind(this), .dragEnabled=${this.viewerConfig?.draggable ?? true}
)} .plugins=${guard([this.viewerConfig, this._media], this._getPlugins.bind(this))}
.label=${media.getTitle() ?? undefined} .selected=${this._selected}
.logo=${cameraMetadata?.engineLogo}
.titlePopupConfig=${this.viewerConfig?.controls.title}
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
transitionEffect=${this._getTransitionEffect()} transitionEffect=${this._getTransitionEffect()}
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)} @frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelected>) => {
@frigate-card:media:loaded=${this._seekHandler.bind(this)} this._setViewSelectedIndex(ev.detail.index);
}}
@frigate-card:media:loaded=${(ev: CustomEvent<MediaLoadedInfo>) => {
if (this._refTitleControl.value) {
showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer);
}
this._player = ev.detail.player ?? null;
this._seekHandler();
}}
@frigate-card:media:unloaded=${() => {
this._player = null;
}}
> >
<frigate-card-next-previous-control <frigate-card-next-previous-control
slot="previous" slot="previous"
.hass=${this.hass} .hass=${this.hass}
.direction=${'previous'} .direction=${'previous'}
.controlConfig=${this.viewerConfig?.controls.next_previous} .controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${prev?.getThumbnail() ?? undefined} .thumbnail=${neighbors?.previous?.media.getThumbnail() ?? undefined}
.label=${prev?.getTitle() ?? ''} .label=${neighbors?.previous?.media.getTitle() ?? ''}
?disabled=${!prev} ?disabled=${!neighbors?.previous}
@click=${(ev) => { @click=${(ev: Event) => {
scroll('previous'); scroll('previous');
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
></frigate-card-next-previous-control> ></frigate-card-next-previous-control>
${guard(this.view?.queryResults?.getResults(), () => this._getSlides())} ${guard(this._media, () => this._getSlides())}
<frigate-card-next-previous-control <frigate-card-next-previous-control
slot="next" slot="next"
.hass=${this.hass} .hass=${this.hass}
.direction=${'next'} .direction=${'next'}
.controlConfig=${this.viewerConfig?.controls.next_previous} .controlConfig=${this.viewerConfig?.controls.next_previous}
.thumbnail=${next?.getThumbnail() ?? undefined} .thumbnail=${neighbors?.next?.media.getThumbnail() ?? undefined}
.label=${next?.getTitle() ?? ''} .label=${neighbors?.next?.media.getTitle() ?? ''}
?disabled=${!next} ?disabled=${!neighbors?.next}
@click=${(ev) => { @click=${(ev: Event) => {
scroll('next'); scroll('next');
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
></frigate-card-next-previous-control> ></frigate-card-next-previous-control>
</frigate-card-media-carousel>`; </frigate-card-carousel>
<div class="seek-warning">
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
</ha-icon>
</div>
${cameraMetadata && titleConfig
? html`<frigate-card-title-control
${ref(this._refTitleControl)}
.config=${titleConfig}
.text="${selectedMedia.getTitle() ?? undefined}"
.logo="${cameraMetadata?.engineLogo}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
: ``}
`;
} }
/** /**
@@ -484,26 +526,37 @@ export class FrigateCardViewerCarousel extends LitElement {
*/ */
protected async _seekHandler(): Promise<void> { protected async _seekHandler(): Promise<void> {
const seek = this.view?.context?.mediaViewer?.seek; const seek = this.view?.context?.mediaViewer?.seek;
const media = this.view?.queryResults?.getSelectedResult(); if (
if (!this.hass || !media || !seek) { !this.hass ||
!seek ||
!this._media ||
!this._player
) {
return;
}
const selectedMedia = this._media[this._selected];
if (!selectedMedia) {
return; return;
} }
const seekTimeInMedia = selectedMedia.includesTime(seek);
setOrRemoveAttribute(this, !seekTimeInMedia, 'unseekable');
if (!seekTimeInMedia && !this._player.isPaused()) {
this._player.pause();
} else if (seekTimeInMedia && this._player.isPaused()) {
this._player.play();
}
const seekTime = const seekTime =
(await this.cameraManager?.getMediaSeekTime(this.hass, media, seek)) ?? null; (await this.cameraManager?.getMediaSeekTime(this.hass, selectedMedia, seek)) ??
const player = this._getPlayer(); null;
if (player && seekTime !== null) {
player.seek(seekTime); if (seekTime !== null) {
this._player.seek(seekTime);
} }
} }
/** protected _renderMediaItem(media: ViewMedia): TemplateResult | null {
* Render a single media item in the viewer carousel.
* @param media The ViewMedia to render.
* @param index The (slide|queryResult) index of the item to render.
* @returns A rendered template.
*/
protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null {
if (!this.hass || !this.view || !this.viewerConfig) { if (!this.hass || !this.view || !this.viewerConfig) {
return null; return null;
} }
@@ -516,11 +569,8 @@ export class FrigateCardViewerCarousel extends LitElement {
.viewerConfig=${this.viewerConfig} .viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache} .resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
.disabled=${this.viewerConfig.lazy_load} .load=${!this.viewerConfig.lazy_load}
.cardWideConfig=${this.cardWideConfig} .cardWideConfig=${this.cardWideConfig}
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
wrapMediaLoadedEventForCarousel(index, e);
}}
></frigate-card-viewer-provider> ></frigate-card-viewer-provider>
</div>`; </div>`;
} }
@@ -530,6 +580,98 @@ export class FrigateCardViewerCarousel extends LitElement {
} }
} }
@customElement('frigate-card-viewer-grid')
export class FrigateCardViewerGrid extends LitElement {
@property({ attribute: false })
public hass?: ExtendedHomeAssistant;
@property({ attribute: false })
public view?: Readonly<View>;
@property({ attribute: false })
public viewerConfig?: ViewerConfig;
@property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache;
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
@property({ attribute: false })
public cameraManager?: CameraManager;
protected _renderCarousel(filterCamera?: string): TemplateResult {
return html`
<frigate-card-viewer-carousel
grid-id=${ifDefined(filterCamera)}
.hass=${this.hass}
.view=${this.view}
.viewFilterCameraID=${filterCamera}
.viewerConfig=${this.viewerConfig}
.resolvedMediaCache=${this.resolvedMediaCache}
.cameraManager=${this.cameraManager}
.cardWideConfig=${this.cardWideConfig}
>
</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()) {
import('./media-grid.js');
}
}
protected _needsGrid(): boolean {
const cameraIDs = this.view?.queryResults?.getCameraIDs();
return (
!!this.view?.isGrid() &&
!!this.view?.supportsMultipleDisplayModes() &&
(cameraIDs?.size ?? 0) > 1
);
}
protected render(): TemplateResult {
const cameraIDs = this.view?.queryResults?.getCameraIDs();
if (!cameraIDs || !this._needsGrid()) {
return this._renderCarousel();
}
return html`
<frigate-card-media-grid
.selected=${this.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>
`;
}
static get styles(): CSSResultGroup {
return unsafeCSS(basicBlockStyle);
}
}
@customElement(FRIGATE_CARD_VIEWER_PROVIDER) @customElement(FRIGATE_CARD_VIEWER_PROVIDER)
export class FrigateCardViewerProvider export class FrigateCardViewerProvider
extends LitElement extends LitElement
@@ -550,10 +692,11 @@ export class FrigateCardViewerProvider
@property({ attribute: false }) @property({ attribute: false })
public resolvedMediaCache?: ResolvedMediaCache; public resolvedMediaCache?: ResolvedMediaCache;
// Whether or not to disable this entity. If `true`, no contents are rendered // Whether or not to load the viewer media. If `false`, no contents are
// until this attribute is set to `false` (this is useful for lazy loading). // rendered until this attribute is set to `true` (this is useful for lazy
// loading).
@property({ attribute: false }) @property({ attribute: false })
public disabled = false; public load = false;
@property({ attribute: false }) @property({ attribute: false })
public cameraManager?: CameraManager; public cameraManager?: CameraManager;
@@ -679,7 +822,7 @@ export class FrigateCardViewerProvider
return; return;
} }
const results = new MediaQueriesResults(mediaArray); const results = new MediaQueriesResults({ results: mediaArray });
results.selectResultIfFound( results.selectResultIfFound(
(clipMedia) => clipMedia.getID() === this.media?.getID(), (clipMedia) => clipMedia.getID() === this.media?.getID(),
); );
@@ -700,7 +843,7 @@ export class FrigateCardViewerProvider
const mediaContentID = this.media ? this.media.getContentID() : null; const mediaContentID = this.media ? this.media.getContentID() : null;
if ( if (
(changedProps.has('disabled') || (changedProps.has('load') ||
changedProps.has('media') || changedProps.has('media') ||
changedProps.has('viewerConfig') || changedProps.has('viewerConfig') ||
changedProps.has('resolvedMediaCache') || changedProps.has('resolvedMediaCache') ||
@@ -708,7 +851,7 @@ export class FrigateCardViewerProvider
this.hass && this.hass &&
mediaContentID && mediaContentID &&
!this.resolvedMediaCache?.has(mediaContentID) && !this.resolvedMediaCache?.has(mediaContentID) &&
(!this.viewerConfig?.lazy_load || !this.disabled) (!this.viewerConfig?.lazy_load || this.load)
) { ) {
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => { resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => {
this.requestUpdate(); this.requestUpdate();
@@ -732,7 +875,7 @@ export class FrigateCardViewerProvider
} }
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (this.disabled || !this.media || !this.hass || !this.view || !this.viewerConfig) { if (!this.load || !this.media || !this.hass || !this.view || !this.viewerConfig) {
return; return;
} }
@@ -830,6 +973,7 @@ declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'frigate-card-viewer-carousel': FrigateCardViewerCarousel; 'frigate-card-viewer-carousel': FrigateCardViewerCarousel;
'frigate-card-viewer': FrigateCardViewer; 'frigate-card-viewer': FrigateCardViewer;
'frigate-card-viewer-grid': FrigateCardViewerGrid;
FRIGATE_CARD_VIEWER_PROVIDER: FrigateCardViewerProvider; FRIGATE_CARD_VIEWER_PROVIDER: FrigateCardViewerProvider;
} }
} }
+2 -1
View File
@@ -199,7 +199,8 @@ export class FrigateCardViews extends LitElement {
<frigate-card-live <frigate-card-live
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.liveConfig=${this.nonOverriddenConfig.live} .nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
.overriddenLiveConfig=${this.config.live}
.conditionControllerEpoch=${this.conditionControllerEpoch} .conditionControllerEpoch=${this.conditionControllerEpoch}
.liveOverrides=${getOverridesByKey('live', this.config.overrides)} .liveOverrides=${getOverridesByKey('live', this.config.overrides)}
.cameraManager=${this.cameraManager} .cameraManager=${this.cameraManager}
+9 -3
View File
@@ -7,6 +7,7 @@ import {
frigateConditionalSchema, frigateConditionalSchema,
OverrideConfigurationKey, OverrideConfigurationKey,
RawFrigateCardConfig, RawFrigateCardConfig,
ViewDisplayMode
} from './types'; } from './types';
interface ConditionState { interface ConditionState {
@@ -16,6 +17,7 @@ interface ConditionState {
camera?: string; camera?: string;
state?: HassEntities; state?: HassEntities;
media_loaded?: boolean; media_loaded?: boolean;
displayMode?: ViewDisplayMode;
} }
export class ConditionEvaluateRequestEvent extends Event { export class ConditionEvaluateRequestEvent extends Event {
@@ -176,10 +178,10 @@ export class ConditionController {
} }
if (condition.fullscreen !== undefined) { if (condition.fullscreen !== undefined) {
result &&= result &&=
state.fullscreen !== undefined && condition.fullscreen == state.fullscreen; state.fullscreen !== undefined && condition.fullscreen === state.fullscreen;
} }
if (condition.expand !== undefined) { if (condition.expand !== undefined) {
result &&= state.expand !== undefined && condition.expand == state.expand; result &&= state.expand !== undefined && condition.expand === state.expand;
} }
if (condition.camera?.length) { if (condition.camera?.length) {
result &&= !!state.camera && condition.camera.includes(state.camera); result &&= !!state.camera && condition.camera.includes(state.camera);
@@ -198,11 +200,15 @@ export class ConditionController {
} }
if (condition.media_loaded !== undefined) { if (condition.media_loaded !== undefined) {
result &&= result &&=
state.media_loaded !== undefined && condition.media_loaded == state.media_loaded; state.media_loaded !== undefined &&
condition.media_loaded === state.media_loaded;
} }
if (condition.media_query) { if (condition.media_query) {
result &&= window.matchMedia(condition.media_query).matches; result &&= window.matchMedia(condition.media_query).matches;
} }
if (condition.display_mode) {
result &&= !!state.displayMode && condition.display_mode === state.displayMode;
}
return result; return result;
} }
+15
View File
@@ -98,6 +98,14 @@ export const CONF_MEDIA_VIEWER_AUTO_PLAY = `${CONF_MEDIA_VIEWER}.auto_play` as c
export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const; export const CONF_MEDIA_VIEWER_AUTO_PAUSE = `${CONF_MEDIA_VIEWER}.auto_pause` as const;
export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const; export const CONF_MEDIA_VIEWER_AUTO_MUTE = `${CONF_MEDIA_VIEWER}.auto_mute` as const;
export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const; export const CONF_MEDIA_VIEWER_AUTO_UNMUTE = `${CONF_MEDIA_VIEWER}.auto_unmute` as const;
export const CONF_MEDIA_VIEWER_DISPLAY_MODE =
`${CONF_MEDIA_VIEWER}.display.mode` as const;
export const CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS =
`${CONF_MEDIA_VIEWER}.display.grid_columns` as const;
export const CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS =
`${CONF_MEDIA_VIEWER}.display.grid_max_columns` as const;
export const CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR =
`${CONF_MEDIA_VIEWER}.display.grid_selected_width_factor` as const;
export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const; export const CONF_MEDIA_VIEWER_DRAGGABLE = `${CONF_MEDIA_VIEWER}.draggable` as const;
export const CONF_MEDIA_VIEWER_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const; export const CONF_MEDIA_VIEWER_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const;
export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP = export const CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP =
@@ -185,6 +193,13 @@ export const CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS =
export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const; export const CONF_LIVE_CONTROLS_TITLE_MODE = `${CONF_LIVE}.controls.title.mode` as const;
export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS = export const CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS =
`${CONF_LIVE}.controls.title.duration_seconds` as const; `${CONF_LIVE}.controls.title.duration_seconds` as const;
export const CONF_LIVE_DISPLAY_MODE = `${CONF_LIVE}.display.mode` as const;
export const CONF_LIVE_DISPLAY_GRID_COLUMNS =
`${CONF_LIVE}.display.grid_columns` as const;
export const CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS =
`${CONF_LIVE}.display.grid_max_columns` as const;
export const CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR =
`${CONF_LIVE}.display.grid_selected_width_factor` as const;
export const CONF_LIVE_LAYOUT_FIT = `${CONF_LIVE}.layout.fit` as const; export const CONF_LIVE_LAYOUT_FIT = `${CONF_LIVE}.layout.fit` as const;
export const CONF_LIVE_LAYOUT_POSITION_X = `${CONF_LIVE}.layout.position.x` as const; export const CONF_LIVE_LAYOUT_POSITION_X = `${CONF_LIVE}.layout.position.x` as const;
export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const; export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const;
+79 -4
View File
@@ -78,6 +78,10 @@ import {
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS, CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
CONF_LIVE_CONTROLS_TITLE_MODE, CONF_LIVE_CONTROLS_TITLE_MODE,
CONF_LIVE_DISPLAY_GRID_COLUMNS,
CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS,
CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
CONF_LIVE_DISPLAY_MODE,
CONF_LIVE_DRAGGABLE, CONF_LIVE_DRAGGABLE,
CONF_LIVE_LAYOUT_FIT, CONF_LIVE_LAYOUT_FIT,
CONF_LIVE_LAYOUT_POSITION_X, CONF_LIVE_LAYOUT_POSITION_X,
@@ -117,6 +121,10 @@ import {
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS, CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE, CONF_MEDIA_VIEWER_CONTROLS_TITLE_MODE,
CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS,
CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS,
CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
CONF_MEDIA_VIEWER_DISPLAY_MODE,
CONF_MEDIA_VIEWER_DRAGGABLE, CONF_MEDIA_VIEWER_DRAGGABLE,
CONF_MEDIA_VIEWER_LAYOUT_FIT, CONF_MEDIA_VIEWER_LAYOUT_FIT,
CONF_MEDIA_VIEWER_LAYOUT_POSITION_X, CONF_MEDIA_VIEWER_LAYOUT_POSITION_X,
@@ -126,8 +134,8 @@ import {
CONF_MEDIA_VIEWER_TRANSITION_EFFECT, CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
CONF_MEDIA_VIEWER_ZOOMABLE, CONF_MEDIA_VIEWER_ZOOMABLE,
CONF_MENU_ALIGNMENT, CONF_MENU_ALIGNMENT,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_BUTTONS, CONF_MENU_BUTTONS,
CONF_MENU_BUTTON_SIZE,
CONF_MENU_POSITION, CONF_MENU_POSITION,
CONF_MENU_STYLE, CONF_MENU_STYLE,
CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR, CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR,
@@ -165,9 +173,9 @@ import { setLowPerformanceProfile } from './performance.js';
import frigate_card_editor_style from './scss/editor.scss'; import frigate_card_editor_style from './scss/editor.scss';
import { import {
BUTTON_SIZE_MIN, BUTTON_SIZE_MIN,
FRIGATE_MENU_PRIORITY_MAX,
FrigateCardConfig, FrigateCardConfig,
frigateCardConfigDefaults, frigateCardConfigDefaults,
FRIGATE_MENU_PRIORITY_MAX,
RawFrigateCardConfig, RawFrigateCardConfig,
RawFrigateCardConfigArray, RawFrigateCardConfigArray,
THUMBNAIL_WIDTH_MAX, THUMBNAIL_WIDTH_MAX,
@@ -198,6 +206,7 @@ const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous';
const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails'; const MENU_LIVE_CONTROLS_THUMBNAILS = 'live.controls.thumbnails';
const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline'; const MENU_LIVE_CONTROLS_TIMELINE = 'live.controls.timeline';
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title'; const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
const MENU_LIVE_DISPLAY = 'live.display';
const MENU_LIVE_LAYOUT = 'live.layout'; const MENU_LIVE_LAYOUT = 'live.layout';
const MENU_LIVE_MICROPHONE = 'live.microphone'; const MENU_LIVE_MICROPHONE = 'live.microphone';
const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter'; const MENU_MEDIA_GALLERY_CONTROLS_FILTER = 'media_gallery.controls.filter';
@@ -207,6 +216,7 @@ const MENU_MEDIA_VIEWER_CONTROLS_NEXT_PREVIOUS = 'media_viewer.controls.next_pre
const MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS = 'media_viewer.controls.thumbnails'; const MENU_MEDIA_VIEWER_CONTROLS_THUMBNAILS = 'media_viewer.controls.thumbnails';
const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline'; const MENU_MEDIA_VIEWER_CONTROLS_TIMELINE = 'media_viewer.controls.timeline';
const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title'; const MENU_MEDIA_VIEWER_CONTROLS_TITLE = 'media_viewer.controls.title';
const MENU_MEDIA_VIEWER_DISPLAY = 'media_viewer.display';
const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout'; const MENU_MEDIA_VIEWER_LAYOUT = 'media_viewer.layout';
const MENU_OPTIONS = 'options'; const MENU_OPTIONS = 'options';
const MENU_PERFORMANCE_FEATURES = 'performance.features'; const MENU_PERFORMANCE_FEATURES = 'performance.features';
@@ -545,6 +555,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'toggle', label: localize('config.menu.buttons.types.toggle') }, { value: 'toggle', label: localize('config.menu.buttons.types.toggle') },
]; ];
protected _displayModes: EditorSelectOption[] = [
{ value: '', label: '' },
{ value: 'single', label: localize('display_modes.single') },
{ value: 'grid', label: localize('display_modes.grid') },
];
public setConfig(config: RawFrigateCardConfig): void { public setConfig(config: RawFrigateCardConfig): void {
// Note: This does not use Zod to parse the configuration, so it may be // Note: This does not use Zod to parse the configuration, so it may be
// partially or completely invalid. It's more useful to have a partially // partially or completely invalid. It's more useful to have a partially
@@ -979,7 +995,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
labelPath, labelPath,
{ name: 'mdi:page-layout-body' }, { name: 'mdi:page-layout-body' },
html` html`
${this._renderOptionSelector(configPathFit, this._layoutFits)} ${this._renderOptionSelector(configPathFit, this._layoutFits, {
label: localize('config.common.layout.fit'),
})}
${this._renderNumberInput(configPathPositionX, { ${this._renderNumberInput(configPathPositionX, {
min: 0, min: 0,
max: 100, max: 100,
@@ -1071,6 +1089,49 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
); );
} }
/**
* Render the next & previous controls.
* @param domain The submenu domain.
* @param configPathStyle Next previous style config path.
* @param configPathSize Next previous size config path.
* @returns A rendered template.
*/
protected _renderViewDisplay(
domain: string,
configPathMode: string,
configPathSelectedWidthFactor: string,
configPathColumns: string,
configPathMaxColumns: string,
): TemplateResult | void {
// grid_select_width_factor: z.number().min(0).optional(),
// grid_max_columns: z.number().min(0).optional(),
// grid_columns: z.number().min(0).optional(),
return this._putInSubmenu(
domain,
true,
'config.common.display.editor_label',
{ name: 'mdi:palette-swatch' },
html`
${this._renderOptionSelector(configPathMode, this._displayModes, {
label: localize('config.common.display.mode'),
})}
${this._renderNumberInput(configPathSelectedWidthFactor, {
min: 0,
label: localize('config.common.display.grid_selected_width_factor'),
})}
${this._renderNumberInput(configPathColumns, {
min: 0,
label: localize('config.common.display.grid_columns'),
})}
${this._renderNumberInput(configPathMaxColumns, {
min: 0,
label: localize('config.common.display.grid_max_columns'),
})}
`,
);
}
/** /**
* Render the next & previous controls. * Render the next & previous controls.
* @param domain The submenu domain. * @param domain The submenu domain.
@@ -1785,7 +1846,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
)}`, )}`,
)} )}
${this._renderMenuButton('play') /* */} ${this._renderMenuButton('play') /* */}
${this._renderMenuButton('mute')} ${this._renderMenuButton('mute') /* */}
${this._renderMenuButton('screenshot')} ${this._renderMenuButton('screenshot')}
</div> </div>
` `
@@ -1826,6 +1887,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_LIVE_SHOW_IMAGE_DURING_LOAD, CONF_LIVE_SHOW_IMAGE_DURING_LOAD,
this._defaults.live.show_image_during_load, this._defaults.live.show_image_during_load,
)} )}
${this._renderViewDisplay(
MENU_LIVE_DISPLAY,
CONF_LIVE_DISPLAY_MODE,
CONF_LIVE_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
CONF_LIVE_DISPLAY_GRID_COLUMNS,
CONF_LIVE_DISPLAY_GRID_MAX_COLUMNS,
)}
${this._putInSubmenu( ${this._putInSubmenu(
MENU_LIVE_CONTROLS, MENU_LIVE_CONTROLS,
true, true,
@@ -1957,6 +2025,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP, CONF_MEDIA_VIEWER_SNAPSHOT_CLICK_PLAYS_CLIP,
this._defaults.media_viewer.snapshot_click_plays_clip, this._defaults.media_viewer.snapshot_click_plays_clip,
)} )}
${this._renderViewDisplay(
MENU_MEDIA_VIEWER_DISPLAY,
CONF_MEDIA_VIEWER_DISPLAY_MODE,
CONF_MEDIA_VIEWER_DISPLAY_GRID_SELECTED_WIDTH_FACTOR,
CONF_MEDIA_VIEWER_DISPLAY_GRID_COLUMNS,
CONF_MEDIA_VIEWER_DISPLAY_GRID_MAX_COLUMNS,
)}
${this._putInSubmenu( ${this._putInSubmenu(
MENU_MEDIA_VIEWER_CONTROLS, MENU_MEDIA_VIEWER_CONTROLS,
true, true,
+16
View File
@@ -148,6 +148,13 @@
} }
} }
}, },
"display": {
"editor_label": "Display",
"grid_columns": "Exact number of grid columns",
"grid_max_columns": "Maximum number of grid columns",
"grid_selected_width_factor": "Increase selected media width by this factor",
"mode": "Mode"
},
"layout": { "layout": {
"fit": "Layout fit", "fit": "Layout fit",
"fits": { "fits": {
@@ -269,6 +276,7 @@
"cameras": "Cameras", "cameras": "Cameras",
"clips": "Clips", "clips": "Clips",
"download": "Download", "download": "Download",
"display_mode": "Display mode",
"enabled": "Button enabled", "enabled": "Button enabled",
"expand": "Expand", "expand": "Expand",
"frigate": "Frigate menu / Default view", "frigate": "Frigate menu / Default view",
@@ -364,6 +372,10 @@
} }
} }
}, },
"display_modes": {
"single": "Show single media viewer",
"grid": "Show media viewer for each camera in a grid"
},
"editor": { "editor": {
"add_new_camera": "Add new camera", "add_new_camera": "Add new camera",
"button": "Button", "button": "Button",
@@ -454,6 +466,9 @@
"what": "What", "what": "What",
"where": "Where" "where": "Where"
}, },
"media_viewer": {
"unseekable": "Seek time not found in media"
},
"media_filter": { "media_filter": {
"all": "All", "all": "All",
"camera": "Camera", "camera": "Camera",
@@ -501,6 +516,7 @@
"pan_behavior": { "pan_behavior": {
"pan": "Pan", "pan": "Pan",
"seek": "Pan seeks across all media", "seek": "Pan seeks across all media",
"seek-in-camera": "Pan seeks within selected camera only",
"seek-in-media": "Pan seeks within selected media item only" "seek-in-media": "Pan seeks within selected media item only"
}, },
"select_date": "Choose date" "select_date": "Choose date"
+16
View File
@@ -148,6 +148,13 @@
} }
} }
}, },
"display": {
"editor_label": "",
"grid_columns": "",
"grid_max_columns": "",
"grid_selected_width_factor": "",
"mode": ""
},
"layout": { "layout": {
"fit": "Adatta al layout", "fit": "Adatta al layout",
"fits": { "fits": {
@@ -267,6 +274,7 @@
"camera_ui": "Interfaccia utente della fotocamera", "camera_ui": "Interfaccia utente della fotocamera",
"cameras": "Telecamere", "cameras": "Telecamere",
"clips": "Clip", "clips": "Clip",
"display_mode": "",
"download": "Download", "download": "Download",
"enabled": "Pulsante abilitato", "enabled": "Pulsante abilitato",
"expand": "Espandere", "expand": "Espandere",
@@ -358,6 +366,10 @@
} }
} }
}, },
"display_modes": {
"grid": "",
"single": ""
},
"editor": { "editor": {
"add_new_camera": "Aggiungi nuova telecamera", "add_new_camera": "Aggiungi nuova telecamera",
"button": "Pulsante", "button": "Pulsante",
@@ -473,6 +485,9 @@
}, },
"where": "Dove" "where": "Dove"
}, },
"media_viewer": {
"unseekable": ""
},
"recording": { "recording": {
"camera": "Camera", "camera": "Camera",
"duration": "Durata", "duration": "Durata",
@@ -490,6 +505,7 @@
"pan_behavior": { "pan_behavior": {
"pan": "", "pan": "",
"seek": "", "seek": "",
"seek-in-camera": "",
"seek-in-media": "" "seek-in-media": ""
}, },
"select_date": "Scegli la data" "select_date": "Scegli la data"
+16
View File
@@ -148,6 +148,13 @@
} }
} }
}, },
"display": {
"editor_label": "",
"grid_columns": "",
"grid_max_columns": "",
"grid_selected_width_factor": "",
"mode": ""
},
"layout": { "layout": {
"fit": "Ajuste de layout", "fit": "Ajuste de layout",
"fits": { "fits": {
@@ -268,6 +275,7 @@
"camera_ui": "Interface de usuário da câmera", "camera_ui": "Interface de usuário da câmera",
"cameras": "Selecionar câmera", "cameras": "Selecionar câmera",
"clips": "Clipes", "clips": "Clipes",
"display_mode": "",
"download": "Baixe a mídia do evento", "download": "Baixe a mídia do evento",
"enabled": "Botão ativado", "enabled": "Botão ativado",
"expand": "Expandir", "expand": "Expandir",
@@ -363,6 +371,10 @@
} }
} }
}, },
"display_modes": {
"grid": "",
"single": ""
},
"editor": { "editor": {
"add_new_camera": "Adicionar nova câmera", "add_new_camera": "Adicionar nova câmera",
"button": "Botão", "button": "Botão",
@@ -482,6 +494,9 @@
}, },
"where": "Onde" "where": "Onde"
}, },
"media_viewer": {
"unseekable": ""
},
"recording": { "recording": {
"camera": "Câmera", "camera": "Câmera",
"duration": "Duração", "duration": "Duração",
@@ -500,6 +515,7 @@
"pan_behavior": { "pan_behavior": {
"pan": "", "pan": "",
"seek": "", "seek": "",
"seek-in-camera": "",
"seek-in-media": "" "seek-in-media": ""
}, },
"select_date": "Escolha a data" "select_date": "Escolha a data"
+19 -3
View File
@@ -148,6 +148,13 @@
} }
} }
}, },
"display": {
"editor_label": "",
"grid_columns": "",
"grid_max_columns": "",
"grid_selected_width_factor": "",
"mode": ""
},
"layout": { "layout": {
"fit": "Fit", "fit": "Fit",
"fits": { "fits": {
@@ -260,6 +267,7 @@
"camera_ui": "Camera", "camera_ui": "Camera",
"cameras": "Selecionar câmera", "cameras": "Selecionar câmera",
"clips": "Clipes", "clips": "Clipes",
"display_mode": "",
"download": "Descarregar mídia do evento", "download": "Descarregar mídia do evento",
"enabled": "Botão ativado", "enabled": "Botão ativado",
"expand": "Expandir", "expand": "Expandir",
@@ -351,6 +359,10 @@
} }
} }
}, },
"display_modes": {
"grid": "",
"single": ""
},
"editor": { "editor": {
"add_new_camera": "Adicionar nova câmera", "add_new_camera": "Adicionar nova câmera",
"button": "Botão", "button": "Botão",
@@ -465,6 +477,9 @@
}, },
"where": "Onde" "where": "Onde"
}, },
"media_viewer": {
"unseekable": ""
},
"recording": { "recording": {
"camera": "Camera", "camera": "Camera",
"duration": "Duração", "duration": "Duração",
@@ -480,9 +495,10 @@
}, },
"timeline": { "timeline": {
"pan_behavior": { "pan_behavior": {
"pan": "Pan", "pan": "",
"seek": "Pan seeks across all media", "seek": "",
"seek-in-media": "Pan seeks within selected media item only" "seek-in-camera": "",
"seek-in-media": ""
}, },
"select_date": "Selecionar a data" "select_date": "Selecionar a data"
} }
+3
View File
@@ -2,6 +2,9 @@
display: block; display: block;
height: 100%; height: 100%;
width: 100%; width: 100%;
// Keep carousel controls relative to the media carousel itself.
position: relative;
} }
.embla { .embla {
+4 -4
View File
@@ -13,14 +13,14 @@ div.control-surround {
padding-top: $drawer-padding-extend; padding-top: $drawer-padding-extend;
padding-bottom: $drawer-padding-extend; padding-bottom: $drawer-padding-extend;
} }
:host([location=left]) div.control-surround { :host([location='left']) div.control-surround {
@if $drawer-icon-size < 32 { @if $drawer-icon-size < 32 {
// Ensure the clickable area is at least 32px wide. // Ensure the clickable area is at least 32px wide.
padding-right: calc(32px - $drawer-icon-size); padding-right: calc(32px - $drawer-icon-size);
} }
left: 100%; left: 100%;
} }
:host([location=right]) div.control-surround { :host([location='right']) div.control-surround {
@if $drawer-icon-size < 32 { @if $drawer-icon-size < 32 {
// See note above. // See note above.
padding-left: calc(32px - $drawer-icon-size); padding-left: calc(32px - $drawer-icon-size);
@@ -54,12 +54,12 @@ ha-icon.control {
background-color: black; background-color: black;
} }
:host([location=left]) ha-icon.control { :host([location='left']) ha-icon.control {
border-top-right-radius: $drawer-icon-size; border-top-right-radius: $drawer-icon-size;
border-bottom-right-radius: $drawer-icon-size; border-bottom-right-radius: $drawer-icon-size;
} }
:host([location=right]) ha-icon.control { :host([location='right']) ha-icon.control {
border-top-left-radius: $drawer-icon-size; border-top-left-radius: $drawer-icon-size;
border-bottom-left-radius: $drawer-icon-size; border-bottom-left-radius: $drawer-icon-size;
} }
+13
View File
@@ -1,3 +1,16 @@
:host {
--video-max-height: none;
}
// If the carousel has an unselected attribute set on it, do not let the
// pointer interact (e.g. hover, scroll) with underlying elements. This is used
// when the carousel is part of a media-grid. Without this next/prev controls
// will enlarge on hover, and the wheel-gestures plugin may block scrolling.
// See matching in viewer-carousel.scss .
:host([unselected]) frigate-card-carousel {
pointer-events: none;
}
.embla__slide { .embla__slide {
height: 100%; height: 100%;
flex: 0 0 100%; flex: 0 0 100%;
-2
View File
@@ -2,6 +2,4 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
--video-max-height: none;
} }
-5
View File
@@ -1,5 +0,0 @@
:host {
width: 100%;
height: 100%;
display: block;
}
-9
View File
@@ -1,9 +0,0 @@
:host {
display: block;
width: 100%;
height: 100%;
--video-max-height: none;
// Keep the controls relative to the media carousel itself.
position: relative;
}
+54
View File
@@ -0,0 +1,54 @@
:host {
display: block;
width: 100%;
height: 100%;
--frigate-card-grid-border-size: 3px;
--frigate-card-grid-column-size: 100%;
--frigate-card-grid-selected-width-factor: 2;
// Allow the grid to scroll if necessary (e.g. fullscreen).
overflow: auto;
// Hide scrollbar: Firefox
scrollbar-width: none;
// Hide scrollbar: IE and Edge
-ms-overflow-style: none;
}
/* Hide scrollbar for Chrome, Safari and Opera */
:host::-webkit-scrollbar {
display: none;
}
::slotted(*) {
box-sizing: border-box;
border-radius: var(--ha-card-border-radius, 4px);
overflow: hidden;
width: var(--frigate-card-grid-column-size);
// Unselected items included a transparent border to act as the effective
// gutter between elements, and to ensure when the item is selected it does
// not change in size (even border-box sizing appears to allow size to change
// when the element has a non-fixed height).
border: var(--frigate-card-grid-border-size) solid transparent;
}
::slotted([selected]) {
border: var(--frigate-card-grid-border-size) solid var(--primary-color);
width: min(
100%,
calc(
var(--frigate-card-grid-selected-width-factor) *
var(--frigate-card-grid-column-size)
)
);
// When at item is selected, it may be enlarged -- it should render "in-front"
// of the other cameras that will then "move out of the way".
z-index: 2;
}
slot {
display: block;
}
-5
View File
@@ -1,5 +0,0 @@
:host {
width: 100%;
height: 100%;
display: block;
}
+1 -3
View File
@@ -84,7 +84,6 @@ div.timeline {
cursor: pointer; cursor: pointer;
} }
:host([recordings]) .vis-item.vis-background, :host([recordings]) .vis-item.vis-background,
:host([recordings]) .vis-labelset,
:host([recordings]) .vis-time-axis { :host([recordings]) .vis-time-axis {
cursor: crosshair; cursor: crosshair;
} }
@@ -119,8 +118,7 @@ div.timeline {
.vis-label { .vis-label {
transition: background-color 0.5s ease-out; transition: background-color 0.5s ease-out;
} }
:host([recordings]) .vis-text.vis-minor:hover, :host([recordings]) .vis-text.vis-minor:hover {
:host([recordings]) .vis-label:hover {
background-color: var(--primary-color); background-color: var(--primary-color);
} }
-5
View File
@@ -1,5 +0,0 @@
:host {
width: 100%;
height: 100%;
display: block;
}
+2
View File
@@ -1,6 +1,8 @@
:host { :host {
--paper-toast-background-color: rgba(0,0,0,0.6); --paper-toast-background-color: rgba(0,0,0,0.6);
--paper-toast-color: white; --paper-toast-color: white;
pointer-events: none;
} }
paper-toast { paper-toast {
+33
View File
@@ -1,3 +1,36 @@
:host {
// Center unseekable icon.
position: relative;
--video-max-height: none;
}
// If the carousel has an unselected attribute set on it, do not let the
// pointer interact (e.g. hover, scroll) with underlying elements. This is used
// when the carousel is part of a media-grid. Without this next/prev controls
// will enlarge on hover, and the wheel-gestures plugin may block scrolling.
// See matching in live-carousel.scss .
:host([unselected]) frigate-card-carousel,
:host([unselected]) .seek-warning {
pointer-events: none;
}
:host([unseekable]) frigate-card-carousel {
filter: brightness(50%);
}
:host([unseekable]) .seek-warning {
display: block;
}
.seek-warning {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
color: white;
}
.embla__slide { .embla__slide {
height: 100%; height: 100%;
flex: 0 0 100%; flex: 0 0 100%;
+33 -29
View File
@@ -105,6 +105,19 @@ export class FrigateCardError extends Error {
} }
} }
const viewDisplayModeSchema = z.enum(['single', 'grid']);
export type ViewDisplayMode = z.infer<typeof viewDisplayModeSchema>;
const viewDisplaySchema = z
.object({
mode: viewDisplayModeSchema,
grid_selected_width_factor: z.number().min(0).optional(),
grid_max_columns: z.number().min(0).optional(),
grid_columns: z.number().min(0).optional(),
})
.optional();
export type ViewDisplayConfig = z.infer<typeof viewDisplaySchema>;
/** /**
* Action Types (for "Picture Elements" / Menu) * Action Types (for "Picture Elements" / Menu)
*/ */
@@ -231,6 +244,7 @@ const FRIGATE_CARD_ACTIONS = [
'camera_select', 'camera_select',
'live_substream_select', 'live_substream_select',
'media_player', 'media_player',
'display_mode_select',
] as const; ] as const;
export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number]; export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number];
@@ -256,6 +270,12 @@ const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.ex
media_player: z.string(), media_player: z.string(),
media_player_action: z.enum(['play', 'stop']), media_player_action: z.enum(['play', 'stop']),
}); });
const frigateCardViewDisplayModeActionSchema = frigateCardCustomActionsBaseSchema.extend(
{
frigate_card_action: z.literal('display_mode_select'),
display_mode: viewDisplayModeSchema,
},
);
export const frigateCardCustomActionSchema = z.union([ export const frigateCardCustomActionSchema = z.union([
frigateCardViewActionSchema, frigateCardViewActionSchema,
@@ -263,6 +283,7 @@ export const frigateCardCustomActionSchema = z.union([
frigateCardCameraSelectActionSchema, frigateCardCameraSelectActionSchema,
frigateCardLiveDependencySelectActionSchema, frigateCardLiveDependencySelectActionSchema,
frigateCardMediaPlayerActionSchema, frigateCardMediaPlayerActionSchema,
frigateCardViewDisplayModeActionSchema,
]); ]);
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>; export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
@@ -647,6 +668,7 @@ export const frigateCardConditionSchema = z.object({
media_loaded: z.boolean().optional(), media_loaded: z.boolean().optional(),
state: stateConditions.optional(), state: stateConditions.optional(),
media_query: z.string().optional(), media_query: z.string().optional(),
display_mode: viewDisplayModeSchema.optional(),
}); });
export type FrigateCardCondition = z.infer<typeof frigateCardConditionSchema>; export type FrigateCardCondition = z.infer<typeof frigateCardConditionSchema>;
@@ -946,6 +968,7 @@ const liveConfigDefault = {
zoomable: true, zoomable: true,
transition_effect: 'slide' as const, transition_effect: 'slide' as const,
show_image_during_load: true, show_image_during_load: true,
mode: 'single' as const,
controls: { controls: {
builtin: true, builtin: true,
next_previous: { next_previous: {
@@ -954,10 +977,6 @@ const liveConfigDefault = {
}, },
thumbnails: liveThumbnailControlsDefaults, thumbnails: liveThumbnailControlsDefaults,
timeline: miniTimelineConfigDefault, timeline: miniTimelineConfigDefault,
title: {
mode: 'popup-bottom-right' as const,
duration_seconds: 2,
},
}, },
microphone: { microphone: {
...microphoneConfigDefault, ...microphoneConfigDefault,
@@ -990,16 +1009,7 @@ const liveOverridableConfigSchema = z
liveConfigDefault.controls.thumbnails, liveConfigDefault.controls.thumbnails,
), ),
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline), timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
title: titleControlConfigSchema title: titleControlConfigSchema.optional(),
.extend({
mode: titleControlConfigSchema.shape.mode.default(
liveConfigDefault.controls.title.mode,
),
duration_seconds: titleControlConfigSchema.shape.duration_seconds.default(
liveConfigDefault.controls.title.duration_seconds,
),
})
.default(liveConfigDefault.controls.title),
}) })
.default(liveConfigDefault.controls), .default(liveConfigDefault.controls),
show_image_during_load: z show_image_during_load: z
@@ -1008,6 +1018,7 @@ const liveOverridableConfigSchema = z
layout: mediaLayoutConfigSchema.optional(), layout: mediaLayoutConfigSchema.optional(),
microphone: microphoneConfigSchema.default(liveConfigDefault.microphone), microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
zoomable: z.boolean().default(liveConfigDefault.zoomable), zoomable: z.boolean().default(liveConfigDefault.zoomable),
display: viewDisplaySchema,
}) })
.merge(actionsSchema); .merge(actionsSchema);
@@ -1078,6 +1089,7 @@ const menuConfigDefault = {
play: hiddenButtonDefault, play: hiddenButtonDefault,
recordings: hiddenButtonDefault, recordings: hiddenButtonDefault,
screenshot: hiddenButtonDefault, screenshot: hiddenButtonDefault,
display_mode: visibleButtonDefault,
}, },
button_size: 40, button_size: 40,
}; };
@@ -1124,6 +1136,9 @@ const menuConfigSchema = z
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute), mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot), screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
display_mode: visibleButtonSchema.default(
menuConfigDefault.buttons.display_mode,
),
}) })
.default(menuConfigDefault.buttons), .default(menuConfigDefault.buttons),
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
@@ -1144,6 +1159,7 @@ const viewerConfigDefault = {
zoomable: true, zoomable: true,
transition_effect: 'slide' as const, transition_effect: 'slide' as const,
snapshot_click_plays_clip: true, snapshot_click_plays_clip: true,
display_mode: 'single' as const,
controls: { controls: {
builtin: true, builtin: true,
next_previous: { next_previous: {
@@ -1152,10 +1168,6 @@ const viewerConfigDefault = {
}, },
thumbnails: thumbnailControlsDefaults, thumbnails: thumbnailControlsDefaults,
timeline: miniTimelineConfigDefault, timeline: miniTimelineConfigDefault,
title: {
mode: 'popup-bottom-right' as const,
duration_seconds: 2,
},
}, },
}; };
const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({ const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({
@@ -1190,6 +1202,7 @@ const viewerConfigSchema = z
snapshot_click_plays_clip: z snapshot_click_plays_clip: z
.boolean() .boolean()
.default(viewerConfigDefault.snapshot_click_plays_clip), .default(viewerConfigDefault.snapshot_click_plays_clip),
display: viewDisplaySchema,
controls: z controls: z
.object({ .object({
builtin: z.boolean().default(viewerConfigDefault.controls.builtin), builtin: z.boolean().default(viewerConfigDefault.controls.builtin),
@@ -1202,16 +1215,7 @@ const viewerConfigSchema = z
timeline: miniTimelineConfigSchema.default( timeline: miniTimelineConfigSchema.default(
viewerConfigDefault.controls.timeline, viewerConfigDefault.controls.timeline,
), ),
title: titleControlConfigSchema title: titleControlConfigSchema.optional(),
.extend({
mode: titleControlConfigSchema.shape.mode.default(
viewerConfigDefault.controls.title.mode,
),
duration_seconds: titleControlConfigSchema.shape.duration_seconds.default(
viewerConfigDefault.controls.title.duration_seconds,
),
})
.default(viewerConfigDefault.controls.title),
}) })
.default(viewerConfigDefault.controls), .default(viewerConfigDefault.controls),
layout: mediaLayoutConfigSchema.optional(), layout: mediaLayoutConfigSchema.optional(),
@@ -1373,7 +1377,7 @@ const performanceConfigDefault = {
}, },
}; };
const performanceConfigSchema = z export const performanceConfigSchema = z
.object({ .object({
profile: z.enum(['low', 'high']).default(performanceConfigDefault.profile), profile: z.enum(['low', 'high']).default(performanceConfigDefault.profile),
features: z features: z
+13
View File
@@ -11,6 +11,7 @@ import {
FrigateCardCustomAction, FrigateCardCustomAction,
frigateCardCustomActionSchema, frigateCardCustomActionSchema,
FrigateCardViewAction, FrigateCardViewAction,
ViewDisplayMode,
} from '../types.js'; } from '../types.js';
/** /**
@@ -42,6 +43,7 @@ export function createFrigateCardCustomAction(
camera?: string; camera?: string;
media_player?: string; media_player?: string;
media_player_action?: 'play' | 'stop'; media_player_action?: 'play' | 'stop';
display_mode?: ViewDisplayMode;
}, },
): FrigateCardCustomAction | null { ): FrigateCardCustomAction | null {
if (action === 'camera_select' || action === 'live_substream_select') { if (action === 'camera_select' || action === 'live_substream_select') {
@@ -67,6 +69,17 @@ export function createFrigateCardCustomAction(
...(args.cardID && { card_id: args.cardID }), ...(args.cardID && { card_id: args.cardID }),
}; };
} }
if (action === 'display_mode_select') {
if (!args?.display_mode) {
return null;
}
return {
action: 'fire-dom-event',
frigate_card_action: action,
display_mode: args?.display_mode,
...(args.cardID && { card_id: args.cardID }),
};
}
return { return {
action: 'fire-dom-event', action: 'fire-dom-event',
frigate_card_action: action, frigate_card_action: action,
+19
View File
@@ -221,3 +221,22 @@ export const setOrRemoveAttribute = (
element.removeAttribute(name); element.removeAttribute(name);
} }
}; };
/**
* Allow typescript to narrow types based on truthy filter.
*/
export const isTruthy = <T>(x: T | false | undefined | null | '' | 0): x is T => !!x;
/**
* Allow typescript to narrow types for HTMLElements.
*/
export const isHTMLElement = (element: unknown): element is HTMLElement =>
element instanceof HTMLElement;
export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => {
const children =
parent instanceof HTMLSlotElement
? parent.assignedElements({ flatten: true })
: [...parent.children];
return children.filter(isHTMLElement);
};
-2
View File
@@ -65,8 +65,6 @@ export function getAllDependentCameras(
} }
} }
}; };
if (cameraID) {
getDependentCameras(cameraID); getDependentCameras(cameraID);
}
return cameraIDs; return cameraIDs;
} }
+178
View File
@@ -0,0 +1,178 @@
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import isEqual from 'lodash-es/isEqual';
import { TransitionEffect } from '../../types';
import { dispatchFrigateCardEvent, getChildrenFromElement } from '../basic.js';
export interface CarouselSelected {
index: number;
element: HTMLElement;
}
type EmblaCarouselPlugins = CreatePluginType<LoosePluginType, Record<string, unknown>>[];
export type CarouselDirection = 'vertical' | 'horizontal';
export class CarouselController {
protected _parent: HTMLElement;
protected _root: HTMLElement;
protected _direction: CarouselDirection;
protected _startIndex: number;
protected _transitionEffect: TransitionEffect;
protected _loop: boolean;
protected _dragFree: boolean;
protected _draggable: boolean;
protected _plugins: EmblaCarouselPlugins;
protected _carousel: EmblaCarouselType;
protected _mutationObserver = new MutationObserver(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(_mutations: MutationRecord[], _observer: MutationObserver) =>
this._refreshCarouselContents(),
);
constructor(
root: HTMLElement,
parent: HTMLElement,
options?: {
direction?: CarouselDirection;
transitionEffect?: TransitionEffect;
startIndex?: number;
loop?: boolean;
dragEnabled?: boolean;
dragFree?: boolean;
plugins?: EmblaCarouselPlugins;
},
) {
this._root = root;
this._parent = parent;
this._direction = options?.direction ?? 'horizontal';
this._transitionEffect = options?.transitionEffect ?? 'slide';
this._startIndex = options?.startIndex ?? 0;
this._dragFree = options?.dragFree ?? false;
this._loop = options?.loop ?? false;
this._draggable = options?.dragEnabled ?? true;
this._plugins = options?.plugins ?? [];
this._carousel = this._createCarousel(getChildrenFromElement(this._parent));
// Need to separately listen for slotchanges since mutation observer will
// not be called for shadom DOM slotted changes.
if (parent instanceof HTMLSlotElement) {
parent.addEventListener('slotchange', this._refreshCarouselContents);
}
this._mutationObserver.observe(this._parent, { childList: true });
}
public destroy() {
if (this._parent instanceof HTMLSlotElement) {
this._parent.removeEventListener('slotchange', this._refreshCarouselContents);
}
this._mutationObserver.disconnect();
this._carousel.destroy();
}
public getSlide(index: number): HTMLElement | null {
return this._carousel.slideNodes()[index] ?? null;
}
public getSelectedSlide(): HTMLElement | null {
return this.getSlide(this.getSelectedIndex());
}
public getSelectedIndex(): number {
return this._carousel.selectedScrollSnap();
}
public selectSlide(index: number): void {
this._carousel.scrollTo(index, this._transitionEffect === 'none');
}
protected _refreshCarouselContents = (): void => {
const newSlides = getChildrenFromElement(this._parent);
const slidesChanged = !isEqual(this._carousel.slideNodes(), newSlides);
if (slidesChanged) {
this._carousel.destroy();
this._carousel = this._createCarousel(newSlides);
}
};
protected _createCarousel(slides: HTMLElement[]): EmblaCarouselType {
const carousel = EmblaCarousel(
this._root,
{
slides: slides,
axis: this._direction === 'horizontal' ? 'x' : 'y',
duration: 20,
startIndex: this._startIndex,
dragFree: this._dragFree,
loop: this._loop,
containScroll: 'trimSnaps',
// This controller manages slide changes (including shadow DOM
// assignments, which the stock watcher does not handle).
watchSlides: false,
// We use the auto-size plugin to manage resizes without carousel resets
// mid-scroll.
watchResize: false,
watchDrag: this._draggable,
},
[
...this._plugins,
...(slides.length > 1
? [
WheelGesturesPlugin({
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
// gestures as scrolling for the carousel.
forceWheelAxis: 'y',
}),
]
: []),
],
);
const getCarouselSelectedObject = (): CarouselSelected | null => {
// Caution: Must use methods/accessors of the new carousel, not the public
// API of this controller which may use a different carousel.
const selectedIndex = carousel.selectedScrollSnap();
const slide = carousel.slideNodes()[selectedIndex] ?? null;
if (slide) {
return {
index: selectedIndex,
element: slide,
};
}
return null;
};
const selectSlide = (): void => {
const carouselSelected = getCarouselSelectedObject();
if (carouselSelected) {
dispatchFrigateCardEvent<CarouselSelected>(
this._parent,
'carousel:select',
carouselSelected,
);
}
};
carousel.on('select', () => selectSlide());
carousel.on('settle', () => {
const carouselSelected = getCarouselSelectedObject();
if (carouselSelected) {
dispatchFrigateCardEvent<CarouselSelected>(
this._parent,
'carousel:settle',
carouselSelected,
);
}
});
return carousel;
}
}
@@ -1,17 +1,26 @@
import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
import { CreateOptionsType } from 'embla-carousel/components/Options'; import { CreateOptionsType } from 'embla-carousel/components/Options';
import { CreatePluginType } from 'embla-carousel/components/Plugins'; import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel'; import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import { LazyUnloadCondition } from '../../types'; import { LazyUnloadCondition } from '../../../../types';
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
lazyload?: AutoLazyLoadType;
}
}
type OptionsType = CreateOptionsType<{ type OptionsType = CreateOptionsType<{
// Number of slides to lazyload left/right of selected (0 == only selected // Number of slides to lazyload left/right of selected (0 == only selected
// slide). // slide).
lazyLoadCount?: number; lazyLoadCount: number;
lazyUnloadCondition?: LazyUnloadCondition; lazyUnloadCondition?: LazyUnloadCondition;
lazyLoadCallback?: (index: number, slide: HTMLElement) => void; lazyLoadCallback?: (index: number, slide: HTMLElement) => void;
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void; lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
}>; }>;
type AutoLazyLoadOptionsType = Partial<OptionsType>;
type AutoLazyLoadType = CreatePluginType<LoosePluginType, AutoLazyLoadOptionsType>;
const defaultOptions: OptionsType = { const defaultOptions: OptionsType = {
active: true, active: true,
@@ -19,74 +28,54 @@ const defaultOptions: OptionsType = {
lazyLoadCount: 0, lazyLoadCount: 0,
}; };
type LazyloadOptionsType = Partial<OptionsType>; export function AutoLazyLoad(
userOptions: AutoLazyLoadOptionsType = {},
type LazyloadType = CreatePluginType< ): AutoLazyLoadType {
{ let options: OptionsType;
hasLazyloaded(index: number): boolean; let emblaApi: EmblaCarouselType;
},
LazyloadOptionsType
>;
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
lazyload?: LazyloadType;
}
}
export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
const optionsHandler = EmblaCarousel.optionsHandler();
const optionsBase = optionsHandler.merge(defaultOptions, Lazyload.globalOptions);
let options: LazyloadType['options'];
let carousel: EmblaCarouselType;
let slides: HTMLElement[]; let slides: HTMLElement[];
const lazyLoadedSlides: Set<number> = new Set(); const lazyLoadedSlides: Set<number> = new Set();
const loadEvents: EmblaEventType[] = ['init', 'select', 'resize']; const loadEvents: EmblaEventType[] = ['init', 'select'];
const unloadEvents: EmblaEventType[] = ['select']; const unloadEvents: EmblaEventType[] = ['select'];
/** function init(
* Initialize the plugin. emblaApiInstance: EmblaCarouselType,
*/ optionsHandler: OptionsHandlerType,
function init(embla: EmblaCarouselType): void { ): void {
carousel = embla; const { mergeOptions, optionsAtMedia } = optionsHandler;
options = optionsHandler.atMedia(self.options); const allOptions = mergeOptions(defaultOptions, userOptions);
slides = carousel.slideNodes(); options = optionsAtMedia(allOptions);
emblaApi = emblaApiInstance;
slides = emblaApi.slideNodes();
if (options.lazyLoadCallback) { if (options.lazyLoadCallback) {
loadEvents.forEach((evt) => carousel.on(evt, lazyLoadHandler)); loadEvents.forEach((evt) => emblaApi.on(evt, lazyLoadHandler));
} }
if ( if (
options.lazyUnloadCallback && options.lazyUnloadCallback &&
options.lazyUnloadCondition && options.lazyUnloadCondition &&
['all', 'unselected'].includes(options.lazyUnloadCondition) ['all', 'unselected'].includes(options.lazyUnloadCondition)
) { ) {
unloadEvents.forEach((evt) => carousel.on(evt, lazyUnloadPreviousHandler)); unloadEvents.forEach((evt) => emblaApi.on(evt, lazyUnloadPreviousHandler));
} }
document.addEventListener('visibilitychange', visibilityHandler); document.addEventListener('visibilitychange', visibilityHandler);
} }
/**
* Destroy the plugin.
*/
function destroy(): void { function destroy(): void {
if (options.lazyLoadCallback) { if (options.lazyLoadCallback) {
loadEvents.forEach((evt) => carousel.off(evt, lazyLoadHandler)); loadEvents.forEach((evt) => emblaApi.off(evt, lazyLoadHandler));
} }
if (options.lazyUnloadCallback) { if (options.lazyUnloadCallback) {
unloadEvents.forEach((evt) => carousel.off(evt, lazyUnloadPreviousHandler)); unloadEvents.forEach((evt) => emblaApi.off(evt, lazyUnloadPreviousHandler));
} }
document.removeEventListener('visibilitychange', visibilityHandler); document.removeEventListener('visibilitychange', visibilityHandler);
} }
/**
* Handle document visibility changes.
*/
function visibilityHandler(): void { function visibilityHandler(): void {
if ( if (
document.visibilityState === 'hidden' && document.visibilityState === 'hidden' &&
options.lazyUnloadCallback &&
options.lazyUnloadCondition && options.lazyUnloadCondition &&
['all', 'hidden'].includes(options.lazyUnloadCondition) ['all', 'hidden'].includes(options.lazyUnloadCondition)
) { ) {
@@ -96,21 +85,13 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
} }
} }
/**
* Determine if a slide index has been lazily loaded.
* @param index Slide index.
* @returns `true` if the slide has been lazily loaded.
*/
function hasLazyloaded(index: number): boolean { function hasLazyloaded(index: number): boolean {
return lazyLoadedSlides.has(index); return lazyLoadedSlides.has(index);
} }
/**
* Lazily load media in the carousel.
*/
function lazyLoadHandler(): void { function lazyLoadHandler(): void {
const lazyLoadCount = options.lazyLoadCount ?? 0; const lazyLoadCount = options.lazyLoadCount;
const currentIndex = carousel.selectedScrollSnap(); const currentIndex = emblaApi.selectedScrollSnap();
const slidesToLoad = new Set<number>(); const slidesToLoad = new Set<number>();
// Lazily load 'count' slides on either side of the slides in view. // Lazily load 'count' slides on either side of the slides in view.
@@ -130,9 +111,6 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
}); });
} }
/**
* Lazily unload all media in the carousel.
*/
function lazyUnloadAllHandler(): void { function lazyUnloadAllHandler(): void {
lazyLoadedSlides.forEach((index) => { lazyLoadedSlides.forEach((index) => {
if (options.lazyUnloadCallback) { if (options.lazyUnloadCallback) {
@@ -142,11 +120,8 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
}); });
} }
/**
* Lazily unload the previously selected media in the carousel.
*/
function lazyUnloadPreviousHandler(): void { function lazyUnloadPreviousHandler(): void {
const index = carousel.previousScrollSnap(); const index = emblaApi.previousScrollSnap();
if (hasLazyloaded(index) && options.lazyUnloadCallback) { if (hasLazyloaded(index) && options.lazyUnloadCallback) {
options.lazyUnloadCallback(index, slides[index]); options.lazyUnloadCallback(index, slides[index]);
@@ -154,14 +129,11 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
} }
} }
const self: LazyloadType = { const self: AutoLazyLoadType = {
name: 'lazyload', name: 'autoLazyLoad',
options: optionsHandler.merge(optionsBase, userOptions), options: userOptions,
init, init,
destroy, destroy,
hasLazyloaded,
}; };
return self; return self;
} }
Lazyload.globalOptions = <LazyloadOptionsType | undefined>undefined;
@@ -0,0 +1,232 @@
import { EmblaCarouselType } from 'embla-carousel';
import { CreateOptionsType } from 'embla-carousel/components/Options.js';
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler.js';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins.js';
import {
AutoMuteCondition,
AutoPauseCondition,
AutoPlayCondition,
AutoUnmuteCondition,
FrigateCardMediaPlayer,
} from '../../../../types.js';
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
autoMediaActions?: AutoMediaActionsType;
}
}
type OptionsType = CreateOptionsType<{
playerSelector?: string;
autoPlayCondition?: AutoPlayCondition;
autoUnmuteCondition?: AutoUnmuteCondition;
autoPauseCondition?: AutoPauseCondition;
autoMuteCondition?: AutoMuteCondition;
}>;
export type AutoMediaActionsOptionsType = Partial<OptionsType>;
const defaultOptions: OptionsType = {
active: true,
breakpoints: {},
};
export type AutoMediaActionsType = CreatePluginType<
LoosePluginType,
AutoMediaActionsOptionsType
>;
export function AutoMediaActions(
userOptions: AutoMediaActionsOptionsType = {},
): AutoMediaActionsType {
let options: OptionsType;
let emblaApi: EmblaCarouselType;
let slides: HTMLElement[];
let hadInitialIntersectionCall: boolean | null = false;
const intersectionObserver: IntersectionObserver = new IntersectionObserver(
intersectionHandler,
);
function init(
emblaApiInstance: EmblaCarouselType,
optionsHandler: OptionsHandlerType,
): void {
emblaApi = emblaApiInstance;
const { mergeOptions, optionsAtMedia } = optionsHandler;
options = optionsAtMedia(mergeOptions(defaultOptions, userOptions));
slides = emblaApi.slideNodes();
if (
options.autoPlayCondition &&
['all', 'selected'].includes(options.autoPlayCondition)
) {
// Auto play when the media loads not necessarily when the slide is
// selected (to allow for lazyloading).
emblaApi.containerNode().addEventListener('frigate-card:media:loaded', play);
}
if (
options.autoUnmuteCondition &&
['all', 'selected'].includes(options.autoUnmuteCondition)
) {
// Auto unmute when the media loads not necessarily when the slide is
// selected (to allow for lazyloading).
emblaApi.containerNode().addEventListener('frigate-card:media:loaded', unmute);
}
if (
options.autoPauseCondition &&
['all', 'unselected'].includes(options.autoPauseCondition)
) {
emblaApi.on('select', pausePrevious);
}
if (
options.autoMuteCondition &&
['all', 'unselected'].includes(options.autoMuteCondition)
) {
emblaApi.on('select', mutePrevious);
}
emblaApi.on('destroy', pause);
emblaApi.on('destroy', mute);
document.addEventListener('visibilitychange', visibilityHandler);
intersectionObserver.observe(emblaApi.containerNode());
}
function destroy(): void {
if (
options.autoPlayCondition &&
['all', 'selected'].includes(options.autoPlayCondition)
) {
emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', play);
}
if (
options.autoUnmuteCondition &&
['all', 'selected'].includes(options.autoUnmuteCondition)
) {
emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', unmute);
}
if (
options.autoPauseCondition &&
['all', 'unselected'].includes(options.autoPauseCondition)
) {
emblaApi.off('select', pausePrevious);
}
if (
options.autoMuteCondition &&
['all', 'unselected'].includes(options.autoMuteCondition)
) {
emblaApi.off('select', mutePrevious);
}
emblaApi.off('destroy', pause);
emblaApi.off('destroy', mute);
document.removeEventListener('visibilitychange', visibilityHandler);
intersectionObserver.disconnect();
}
function actOnVisibilityChange(visible: boolean): void {
if (visible) {
if (
options.autoPlayCondition &&
['all', 'visible'].includes(options.autoPlayCondition)
) {
play();
}
if (
options.autoUnmuteCondition &&
['all', 'visible'].includes(options.autoUnmuteCondition)
) {
unmute();
}
} else {
if (
options.autoPauseCondition &&
['all', 'hidden'].includes(options.autoPauseCondition)
) {
pauseAll();
}
if (
options.autoMuteCondition &&
['all', 'hidden'].includes(options.autoMuteCondition)
) {
muteAll();
}
}
}
function visibilityHandler(): void {
actOnVisibilityChange(document.visibilityState === 'visible');
}
function intersectionHandler(entries: IntersectionObserverEntry[]): void {
if (!hadInitialIntersectionCall) {
hadInitialIntersectionCall = true;
return;
}
// If the live view is preloaded (i.e. in the background) we may need to
// take media actions, e.g. muting a live stream that is now running in the
// background.
actOnVisibilityChange(entries.some((entry) => entry.isIntersecting));
}
function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null {
return options.playerSelector
? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null)
: null;
}
function play(): void {
getPlayer(slides[emblaApi.selectedScrollSnap()])?.play();
}
function pause(): void {
getPlayer(slides[emblaApi.selectedScrollSnap()])?.pause();
}
function pausePrevious(): void {
getPlayer(slides[emblaApi.previousScrollSnap()])?.pause();
}
function pauseAll(): void {
for (const slide of slides) {
getPlayer(slide)?.pause();
}
}
function unmute(): void {
getPlayer(slides[emblaApi.selectedScrollSnap()])?.unmute();
}
function mute(): void {
getPlayer(slides[emblaApi.selectedScrollSnap()])?.mute();
}
function mutePrevious(): void {
getPlayer(slides[emblaApi.previousScrollSnap()])?.mute();
}
function muteAll(): void {
for (const slide of slides) {
getPlayer(slide)?.mute();
}
}
const self: AutoMediaActionsType = {
name: 'autoMediaActions',
options: userOptions,
init,
destroy,
};
return self;
}
@@ -0,0 +1,94 @@
import { EmblaCarouselType } from 'embla-carousel';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import { MediaLoadedInfo } from '../../../../types';
import {
dispatchExistingMediaLoadedInfoAsEvent,
FrigateMediaLoadedEventTarget,
} from '../../../media-info';
import { LooseOptionsType } from 'embla-carousel/components/Options';
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
autoMediaLoadedInfo?: AutoMediaLoadedInfoType;
}
}
type AutoMediaLoadedInfoType = CreatePluginType<LoosePluginType, LooseOptionsType>;
function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType {
let emblaApi: EmblaCarouselType;
let slides: (HTMLElement & FrigateMediaLoadedEventTarget)[] = [];
const mediaLoadedInfo: MediaLoadedInfo[] = [];
function init(emblaApiInstance: EmblaCarouselType): void {
emblaApi = emblaApiInstance;
slides = emblaApi.slideNodes();
for (const slide of slides) {
slide.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
slide.addEventListener('frigate-card:media:unloaded', mediaUnloadedInfoHandler);
}
emblaApi.on('init', slideSelectHandler);
emblaApi.on('select', slideSelectHandler);
}
function destroy(): void {
for (const slide of slides) {
slide.removeEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
slide.removeEventListener('frigate-card:media:unloaded', mediaUnloadedInfoHandler);
}
emblaApi.off('init', slideSelectHandler);
emblaApi.off('select', slideSelectHandler);
}
function mediaLoadedInfoHandler(ev: CustomEvent<MediaLoadedInfo>): void {
const eventPath = ev.composedPath();
for (const [index, slide] of slides.entries()) {
if (eventPath.includes(slide)) {
mediaLoadedInfo[index] = ev.detail;
if (index !== emblaApi.selectedScrollSnap()) {
ev.stopPropagation();
}
break;
}
}
}
function mediaUnloadedInfoHandler(ev: CustomEvent): void {
const eventPath = ev.composedPath();
for (const [index, slide] of slides.entries()) {
if (eventPath.includes(slide)) {
delete mediaLoadedInfo[index];
if (index !== emblaApi.selectedScrollSnap()) {
ev.stopPropagation();
}
break;
}
}
}
function slideSelectHandler(): void {
const index = emblaApi.selectedScrollSnap();
const savedMediaLoadedInfo: MediaLoadedInfo | undefined = mediaLoadedInfo[index];
if (savedMediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(
emblaApi.containerNode(),
savedMediaLoadedInfo,
);
}
}
const self: AutoMediaLoadedInfoType = {
name: 'autoMediaLoadedInfo',
options: {},
init,
destroy,
};
return self;
}
export default AutoMediaLoadedInfo;
@@ -0,0 +1,159 @@
import { EmblaCarouselType } from 'embla-carousel';
import { LooseOptionsType } from 'embla-carousel/components/Options';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import debounce from 'lodash-es/debounce';
import { EmblaReInitController } from '../../reinit-controller';
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
AutoSize?: AutoSizeType;
}
}
type AutoSizeType = CreatePluginType<LoosePluginType, LooseOptionsType>;
interface SlideDimensions {
height: number;
width: number;
}
/**
* This plugin offers the following functionality:
* - Auto-height: Automatically resize the container to fit the largest slide on
* view. Unlike the stock `auto-height` plugin, this version will use active
* DOM sizing vs the internal engine sizes to account for pre-reinit resize
* detection.
* - Resize and intersection re-initializing: Re-initialize the carousel on
* slide or container resizes, or container intersection changes.
*/
function AutoSize(): AutoSizeType {
let emblaApi: EmblaCarouselType;
let reInitController: EmblaReInitController | null = null;
let previousContainerIntersecting: boolean | null = null;
const previousDimensions: Map<Element, SlideDimensions> = new Map();
const resizeObserver: ResizeObserver = new ResizeObserver(resizeHandler);
const intersectionObserver: IntersectionObserver = new IntersectionObserver(
intersectionHandler,
);
const debouncedSetContainerHeight = debounce(() => setContainerHeight(), 200, {
trailing: true,
});
function init(emblaApiInstance: EmblaCarouselType): void {
emblaApi = emblaApiInstance;
reInitController = new EmblaReInitController(emblaApi);
intersectionObserver.observe(emblaApi.containerNode());
resizeObserver.observe(emblaApi.containerNode());
for (const slide of emblaApi.slideNodes()) {
resizeObserver.observe(slide);
}
// Need to examine container size on both settle and media load, as settle
// may happen before the media is loaded (which they subsequently changes
// the size to large than the maxHeight is set).
emblaApi
.containerNode()
.addEventListener('frigate-card:media:loaded', debouncedSetContainerHeight);
emblaApi.on('settle', debouncedSetContainerHeight);
}
function destroy(): void {
intersectionObserver.disconnect();
resizeObserver.disconnect();
reInitController?.destroy();
emblaApi
.containerNode()
.removeEventListener('frigate-card:media:loaded', debouncedSetContainerHeight);
emblaApi.off('settle', debouncedSetContainerHeight);
}
function intersectionHandler(entries: IntersectionObserverEntry[]): void {
/**
* - If the DOM that contains this carousel changes such that it causes
* slides to entirely appear/disappear (e.g. `display: none` or hidden),
* then the displayed slide sizes will significantly change and the
* carousel will need to be reinitialized. Without this, odd bugs may
* occur for some users in some circumstances causing the carousel to
* appear 'stuck'.
* - Example bug when this reinitialization is not performed:
* https://github.com/dermotduffy/frigate-hass-card/issues/651
*/
const isContainerIntersectingNow = entries.some((entry) => entry.isIntersecting);
if (isContainerIntersectingNow !== previousContainerIntersecting) {
// Don't reinitialize on first call (intersectionHandler is always called
// on initial observation).
const callReInit = previousContainerIntersecting !== null;
previousContainerIntersecting = isContainerIntersectingNow;
if (callReInit) {
reInitController?.reinit();
}
}
}
function resizeHandler(entries: ResizeObserverEntry[]): void {
let resize = false;
for (const entry of entries) {
const newDimensions: SlideDimensions = {
height: entry.contentRect.height,
width: entry.contentRect.width,
};
const oldDimensions = previousDimensions.get(entry.target);
if (
newDimensions.width &&
newDimensions.height &&
(oldDimensions?.height !== newDimensions.height ||
oldDimensions?.width !== newDimensions.width)
) {
previousDimensions.set(entry.target, newDimensions);
resize = true;
}
}
if (resize) {
debouncedSetContainerHeight();
}
}
function setContainerHeight(): void {
const {
slideRegistry,
options: { axis },
} = emblaApi.internalEngine();
if (axis === 'y') {
return;
}
emblaApi.containerNode().style.removeProperty('max-height');
const selectedIndexes = slideRegistry[emblaApi.selectedScrollSnap()];
const slides = emblaApi.slideNodes();
const highest = Math.max(
...selectedIndexes.map((i) => slides[i].getBoundingClientRect().height),
);
if (!isNaN(highest) && highest > 0) {
emblaApi.containerNode().style.maxHeight = `${highest}px`;
}
reInitController?.reinit();
}
const self: AutoSizeType = {
name: 'autoSize',
options: {},
init,
destroy,
};
return self;
}
export default AutoSize;
+60
View File
@@ -0,0 +1,60 @@
import { EmblaCarouselType } from 'embla-carousel';
import debounce from 'lodash-es/debounce';
/**
* This class takes care of "safe re-initializing": Only re-initializing the
* carousel when it is not scrolling (unlike the builtin Embla reinitializations,
* e.g. slide additions or resizes). Without this class the carousel is visually
* jarring as in-progress transitions are skipped (vs completing prior to
* reinit).
*/
export class EmblaReInitController {
protected _emblaApi: EmblaCarouselType;
protected _scrolling = false;
protected _shouldReInitOnScrollStop = false;
constructor(emblaApi: EmblaCarouselType) {
this._emblaApi = emblaApi;
this._emblaApi.on('scroll', this._scrollingStart);
this._emblaApi.on('settle', this._scrollingStop);
this._emblaApi.on('destroy', this.destroy);
}
public destroy(): void {
this._emblaApi.off('scroll', this._scrollingStart);
this._emblaApi.off('settle', this._scrollingStop);
this._emblaApi.off('destroy', this.destroy);
}
public reinit(): void {
if (this._scrolling) {
this._shouldReInitOnScrollStop = true;
} else {
this._debouncedReInit();
}
}
protected _scrollingStart = (): void => {
this._scrolling = true;
};
protected _scrollingStop = (): void => {
this._scrolling = false;
if (this._shouldReInitOnScrollStop) {
this._shouldReInitOnScrollStop = false;
this._debouncedReInit();
}
};
protected _debouncedReInit = debounce(
() => {
this._scrolling = false;
this._shouldReInitOnScrollStop = false;
this._emblaApi?.reInit();
},
200,
{ trailing: true },
);
}
+352
View File
@@ -0,0 +1,352 @@
import isEqual from 'lodash-es/isEqual';
import throttle from 'lodash-es/throttle';
import Masonry from 'masonry-layout';
import { MediaLoadedInfo, ViewDisplayConfig } from '../types';
import {
dispatchFrigateCardEvent,
getChildrenFromElement,
setOrRemoveAttribute,
} from './basic';
import {
FrigateMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
} from './media-info';
// The default minimum cell width: if the columns are not specified this value
// is used to compute the number of columns, always trying to keep each cell as
// at least this width. On Android, a card in portrait mode is 396 pixels, and
// we'd like to support two cells wide in that configuration.
const MEDIA_GRID_DEFAULT_MIN_CELL_WIDTH = 190;
const MEDIA_GRID_DEFAULT_IDEAL_CELL_WIDTH = 600;
const MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR = 2.0;
type GridID = string;
type MediaGridChild = HTMLElement & FrigateMediaLoadedEventTarget;
type MediaGridContents = Map<GridID, MediaGridChild>;
export interface MediaGridSelected {
selected: GridID;
}
export interface MediaGridConstructorOptions {
selected?: GridID;
idAttribute?: string;
displayConfig?: ViewDisplayConfig;
}
export class MediaGridController {
protected _host: HTMLElement;
protected _selected: GridID | null;
protected _mediaLoadedInfoMap: Map<GridID, MediaLoadedInfo> = new Map();
protected _gridContents: MediaGridContents = new Map();
protected _masonry: Masonry | null = null;
protected _displayConfig: ViewDisplayConfig | null = null;
protected _hostWidth: number;
protected _idAttribute: string;
protected _throttledLayout = throttle(
() => this._masonry?.layout?.(),
// Throttle layout calls to larger than the masonry.js transitionDuration
// value specified below.
300,
{ trailing: true, leading: false },
);
// If the order in which the observers are declared changes, the unittest must
// be updated in triggerResizeObserver and triggerMutationObserver.
protected _hostMutationObserver = new MutationObserver(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(_mutations: MutationRecord[], _observer: MutationObserver) =>
this._calculateGridContentsFromHost(),
);
protected _cellMutationObserver = new MutationObserver(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(_mutations: MutationRecord[], _observer: MutationObserver) =>
this._calculateGridContentsFromHost(),
);
protected _hostResizeObserver = new ResizeObserver(this._hostResizeHandler.bind(this));
protected _cellResizeObserver = new ResizeObserver(this._cellResizeHandler.bind(this));
constructor(host: HTMLElement, options?: MediaGridConstructorOptions) {
this._host = host;
this._selected = options?.selected ?? null;
this._idAttribute = options?.idAttribute ?? 'grid-id';
this._hostWidth = this._host.getBoundingClientRect().width;
this._hostResizeObserver.observe(host);
this._displayConfig = options?.displayConfig ?? null;
this._hostMutationObserver.observe(host, {
childList: true,
});
// Need to separately listen for slotchanges since mutation observer will
// not be called for shadom DOM slotted changes.
if (host instanceof HTMLSlotElement) {
host.addEventListener('slotchange', this._calculateGridContentsFromHost);
}
this._calculateGridContentsFromHost();
}
public destroy(): void {
this._hostResizeObserver.disconnect();
this._cellResizeObserver.disconnect();
this._hostMutationObserver.disconnect();
this._cellMutationObserver.disconnect();
if (this._host instanceof HTMLSlotElement) {
this._host.removeEventListener('slotchange', this._calculateGridContentsFromHost);
}
this._mediaLoadedInfoMap.clear();
this._masonry?.destroy?.();
this._masonry = null;
for (const child of this._gridContents.values()) {
this._removeChildEventListeners(child);
}
this._gridContents.clear();
}
public setDisplayConfig(displayConfig: ViewDisplayConfig | null): void {
if (!isEqual(displayConfig, this._displayConfig)) {
this._displayConfig = displayConfig;
this._calculateGridContentsFromHost();
}
}
public getGridContents(): MediaGridContents {
return this._gridContents;
}
public getGridSize(): number {
return this._gridContents.size;
}
public getSelected(): GridID | null {
return this._selected;
}
public selectCell(id: GridID) {
if (this._selected === id) {
return;
}
this._selected = id;
dispatchFrigateCardEvent(this._host, 'media-grid:selected', { selected: id });
const mediaLoadedInfo = this._mediaLoadedInfoMap.get(id);
if (mediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(this._host, mediaLoadedInfo);
}
this._updateSelectedStylesOnElements();
// Sizes may change when an element is selected, so re-do the layout (must
// come after the call to _updateStylesOnElements in order to ensure the
// right styles are applied first).
this._throttledLayout();
}
public unselectAll() {
if (this._selected !== null) {
dispatchMediaUnloadedEvent(this._host);
dispatchFrigateCardEvent(this._host, 'media-grid:unselected');
}
this._selected = null;
this._updateSelectedStylesOnElements();
}
protected _calculateGridContentsFromHost = (): void => {
const children = getChildrenFromElement(this._host);
const gridContents: MediaGridContents = new Map();
for (const child of children) {
const id = child.getAttribute(this._idAttribute) || String(gridContents.size);
gridContents.set(id, child);
}
this._setGridContents(gridContents);
};
protected _setGridContents(gridContents: MediaGridContents): void {
this._gridContents = gridContents;
// Remove media loaded info objects that belong to objects no longer in the
// grid.
for (const key of this._mediaLoadedInfoMap.keys()) {
if (!gridContents.has(key)) {
this._mediaLoadedInfoMap.delete(key);
}
}
if (this._selected !== null && !this._gridContents.has(this._selected)) {
this.unselectAll();
}
for (const element of gridContents.values()) {
this._removeChildEventListeners(element);
this._addChildEventListeners(element);
}
this._setColumnSizeStyles();
this._createMasonry();
// Observe grid elements for size or id changes.
this._cellMutationObserver.disconnect();
this._cellResizeObserver.disconnect();
for (const child of gridContents.values()) {
this._cellMutationObserver.observe(child, {
attributeFilter: [this._idAttribute],
attributes: true,
});
this._cellResizeObserver.observe(child);
}
this._updateSelectedStylesOnElements();
this._setColumnSizeStyles();
}
protected _handleMediaLoadedInfoEvent = (ev: CustomEvent<MediaLoadedInfo>): void => {
const eventPath = ev.composedPath();
for (const [id, element] of this._gridContents.entries()) {
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (eventPath.includes(element)) {
this._mediaLoadedInfoMap.set(id, ev.detail);
if (id !== this._selected) {
ev.stopPropagation();
}
break;
}
}
};
protected _hostResizeHandler(): void {
const dimensions = this._host.getBoundingClientRect();
// Only resize things if the width has changed. It is expected that the
// height may change during the layout.
if (dimensions.width !== this._hostWidth) {
this._hostWidth = dimensions.width;
// Reset the column CSS sizes first.
this._setColumnSizeStyles();
// Need to recreate the masonry layout since the column width will differ.
this._createMasonry();
}
}
protected _cellResizeHandler(): void {
this._throttledLayout();
}
protected _addChildEventListeners(child: MediaGridChild): void {
child.addEventListener('click', this._handleSelectGridCellEvent, {
capture: true,
});
child.addEventListener(
'frigate-card:media:loaded',
this._handleMediaLoadedInfoEvent,
);
}
protected _removeChildEventListeners(child: MediaGridChild): void {
child.removeEventListener('click', this._handleSelectGridCellEvent, {
capture: true,
});
child.removeEventListener(
'frigate-card:media:loaded',
this._handleMediaLoadedInfoEvent,
);
}
protected _createMasonry(): void {
if (this._masonry) {
this._masonry.destroy?.();
}
this._masonry = new Masonry(this._host, {
columnWidth: this._getColumnSize(),
initLayout: false,
percentPosition: true,
transitionDuration: '0.2s',
});
this._masonry.addItems?.([...this._gridContents.values()]);
this._throttledLayout();
}
protected _handleSelectGridCellEvent = (ev: Event): void => {
const eventPath = ev.composedPath();
for (const [id, element] of this._gridContents.entries()) {
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (eventPath.includes(element)) {
if (this._selected !== id) {
this.selectCell(id);
ev.stopPropagation();
}
break;
}
}
};
protected _updateSelectedStylesOnElements(): void {
for (const [id, element] of this._gridContents.entries()) {
setOrRemoveAttribute(element, id === this._selected, 'selected');
// Explicitly use an 'unselected' attribute vs a :not(selected) such that
// a carousel with neither selected nor unselected will behave normally.
// This matches a css selector in viewer-carousel.scss .
setOrRemoveAttribute(element, id !== this._selected, 'unselected');
}
}
protected _getColumnSize(): number {
return Math.round(this._hostWidth / this._getColumns());
}
protected _getColumns(): number {
if (this._displayConfig?.grid_columns) {
return this._displayConfig?.grid_columns;
}
const maxColumns = this._displayConfig?.grid_max_columns ?? Infinity;
// See if we can get a multi-column layout using the ideal cell width.
const idealColumns = Math.min(
maxColumns,
Math.floor(this._hostWidth / MEDIA_GRID_DEFAULT_IDEAL_CELL_WIDTH),
);
if (idealColumns > 1) {
return idealColumns;
}
// If not, get a multi-column view using the minimum cell width.
const minColumns = Math.floor(
Math.min(maxColumns, this._hostWidth / MEDIA_GRID_DEFAULT_MIN_CELL_WIDTH),
);
// Last result use at least 1 column.
return Math.max(1, minColumns);
}
protected _setColumnSizeStyles(): void {
this._host.style.setProperty(
'--frigate-card-grid-column-size',
`${this._getColumnSize()}px`,
);
this._host.style.setProperty(
'--frigate-card-grid-selected-width-factor',
`${
this._displayConfig?.grid_selected_width_factor ??
MEDIA_GRID_DEFAULT_SELECTED_WIDTH_FACTOR
}`,
);
}
}
+40
View File
@@ -111,3 +111,43 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean {
info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF
); );
} }
// Facilitates correct Typescript typing of media:loaded/unloaded event handlers.
export interface FrigateMediaLoadedEventTarget extends EventTarget {
addEventListener(
event: 'frigate-card:media:loaded',
listener: (
this: FrigateMediaLoadedEventTarget,
ev: CustomEvent<MediaLoadedInfo>,
) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
event: 'frigate-card:media:unloaded',
listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): void;
removeEventListener(
event: 'frigate-card:media:loaded',
listener: (
this: FrigateMediaLoadedEventTarget,
ev: CustomEvent<MediaLoadedInfo>,
) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
event: 'frigate-card:media:unloaded',
listener: (this: FrigateMediaLoadedEventTarget, ev: CustomEvent) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
+44 -35
View File
@@ -1,20 +1,20 @@
import { HomeAssistant } from 'custom-card-helpers';
import { ViewContext } from 'view'; import { ViewContext } from 'view';
import { CameraManager } from '../camera-manager/manager';
import { MediaQuery } from '../camera-manager/types';
import { dispatchFrigateCardErrorEvent } from '../components/message';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const';
import { CardWideConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types'; import { CardWideConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
import { View } from '../view/view'; import { ViewMedia } from '../view/media';
import { import {
EventMediaQueries, EventMediaQueries,
MediaQueries, MediaQueries,
RecordingMediaQueries, RecordingMediaQueries,
} from '../view/media-queries'; } from '../view/media-queries';
import { CameraManager } from '../camera-manager/manager';
import { getAllDependentCameras } from './camera.js';
import { ViewMedia } from '../view/media';
import { HomeAssistant } from 'custom-card-helpers';
import { dispatchFrigateCardErrorEvent } from '../components/message';
import { MediaQueriesResults } from '../view/media-queries-results'; import { MediaQueriesResults } from '../view/media-queries-results';
import { View } from '../view/view';
import { errorToConsole } from './basic'; import { errorToConsole } from './basic';
import { MediaQuery } from '../camera-manager/types'; import { getAllDependentCameras } from './camera.js';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const';
type ResultSelectType = 'latest' | 'time' | 'none'; type ResultSelectType = 'latest' | 'time' | 'none';
@@ -25,13 +25,16 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
cardWideConfig: CardWideConfig, cardWideConfig: CardWideConfig,
view: View, view: View,
options?: { options?: {
allCameras?: boolean;
mediaType?: ClipsOrSnapshotsOrAll; mediaType?: ClipsOrSnapshotsOrAll;
targetView?: FrigateCardView; targetView?: FrigateCardView;
select?: ResultSelectType; select?: ResultSelectType;
}, },
): Promise<void> => { ): Promise<void> => {
const cameraIDs = getAllDependentCameras(cameraManager, view.camera); const cameraIDs = options?.allCameras
if (!cameraIDs) { ? cameraManager.getStore().getVisibleCameraIDs()
: getAllDependentCameras(cameraManager, view.camera);
if (!cameraIDs.size) {
return; return;
} }
@@ -84,12 +87,15 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
cardWideConfig: CardWideConfig, cardWideConfig: CardWideConfig,
view: View, view: View,
options?: { options?: {
allCameras?: boolean;
targetView?: 'recording' | 'recordings'; targetView?: 'recording' | 'recordings';
select?: ResultSelectType; select?: ResultSelectType;
}, },
): Promise<void> => { ): Promise<void> => {
const cameraIDs = getAllDependentCameras(cameraManager, view.camera); const cameraIDs = options?.allCameras
if (!cameraIDs) { ? cameraManager.getStore().getVisibleCameraIDs()
: getAllDependentCameras(cameraManager, view.camera);
if (!cameraIDs.size) {
return; return;
} }
@@ -110,21 +116,15 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
)?.dispatchChangeEvent(element); )?.dispatchChangeEvent(element);
}; };
export const createQueriesForRecordingsView = ( const createQueriesForRecordingsView = (
cameraManager: CameraManager, cameraManager: CameraManager,
cardWideConfig: CardWideConfig, cardWideConfig: CardWideConfig,
cameraIDs: Set<string>, cameraIDs: Set<string>,
options?: {
start?: Date;
end?: Date;
},
): RecordingMediaQueries | null => { ): RecordingMediaQueries | null => {
const limit = const limit =
cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT; cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT;
const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, { const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
limit: limit, limit: limit,
...(options?.start && { start: options.start }),
...(options?.end && { end: options.end }),
}); });
return recordingQueries ? new RecordingMediaQueries(recordingQueries) : null; return recordingQueries ? new RecordingMediaQueries(recordingQueries) : null;
}; };
@@ -161,17 +161,13 @@ export const executeMediaQueryForView = async (
return null; return null;
} }
const queryResults = new MediaQueriesResults( const queryResults = new MediaQueriesResults({ results: mediaArray });
mediaArray,
options?.select === 'latest' && mediaArray.length
? mediaArray.length - 1
: undefined,
);
let viewerContext: ViewContext | undefined = {}; let viewerContext: ViewContext | undefined = {};
const cameraID = options?.targetCameraID ?? view.camera;
if (options?.select === 'time' && options?.targetTime) { if (options?.select === 'time' && options?.targetTime) {
queryResults.selectBestResult((media) => queryResults.selectBestResult((media) =>
findBestMediaIndex(media, options.targetTime as Date), findBestMediaIndex(media, options.targetTime as Date, cameraID),
); );
viewerContext = { viewerContext = {
mediaViewer: { mediaViewer: {
@@ -180,16 +176,14 @@ export const executeMediaQueryForView = async (
}; };
} }
return ( return view
view .evolve({
?.evolve({
query: query, query: query,
queryResults: queryResults, queryResults: queryResults,
view: options?.targetView, view: options?.targetView,
camera: options?.targetCameraID, camera: cameraID,
}) })
.mergeInContext(viewerContext) ?? null .mergeInContext(viewerContext);
);
}; };
/** /**
@@ -201,12 +195,14 @@ export const executeMediaQueryForView = async (
*/ */
export const findBestMediaIndex = ( export const findBestMediaIndex = (
mediaArray: ViewMedia[], mediaArray: ViewMedia[],
targetTime: Date targetTime: Date,
favorCameraID?: string,
): number | null => { ): number | null => {
let bestMatch: let bestMatch:
| { | {
index: number; index: number;
duration: number; duration: number;
cameraID: string;
} }
| undefined; | undefined;
@@ -216,8 +212,21 @@ export const findBestMediaIndex = (
if (media.includesTime(targetTime) && start && end) { if (media.includesTime(targetTime) && start && end) {
const duration = end.getTime() - start.getTime(); const duration = end.getTime() - start.getTime();
if (!bestMatch || duration > bestMatch.duration) {
bestMatch = { index: i, duration: duration }; 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() };
} }
} }
} }
+4 -1
View File
@@ -9,6 +9,7 @@ const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1;
export type FrigateCardHTMLVideoElement = HTMLVideoElement & { export type FrigateCardHTMLVideoElement = HTMLVideoElement & {
_controlsHideTimer?: Timer; _controlsHideTimer?: Timer;
_controlsOriginalValue?: boolean;
}; };
/** /**
@@ -24,6 +25,7 @@ export const setControlsOnVideo = (
if (video._controlsHideTimer) { if (video._controlsHideTimer) {
video._controlsHideTimer.stop(); video._controlsHideTimer.stop();
delete video._controlsHideTimer; delete video._controlsHideTimer;
delete video._controlsOriginalValue;
} }
video.controls = value; video.controls = value;
}; };
@@ -38,9 +40,10 @@ export const hideMediaControlsTemporarily = (
video: FrigateCardHTMLVideoElement, video: FrigateCardHTMLVideoElement,
seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS, seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS,
): void => { ): void => {
const oldValue = video.controls; const oldValue = video._controlsOriginalValue ?? video.controls;
setControlsOnVideo(video, false); setControlsOnVideo(video, false);
video._controlsHideTimer ??= new Timer(); video._controlsHideTimer ??= new Timer();
video._controlsOriginalValue = oldValue;
video._controlsHideTimer.start(seconds, () => { video._controlsHideTimer.start(seconds, () => {
setControlsOnVideo(video, oldValue); setControlsOnVideo(video, oldValue);
}); });
+24 -3
View File
@@ -5,9 +5,9 @@ import { CameraManager } from '../camera-manager/manager';
import { FRIGATE_BUTTON_MENU_ICON } from '../const'; import { FRIGATE_BUTTON_MENU_ICON } from '../const';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
import { import {
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
FrigateCardConfig, FrigateCardConfig,
FrigateCardCustomAction, FrigateCardCustomAction,
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
MediaLoadedInfo, MediaLoadedInfo,
MenuButton, MenuButton,
} from '../types'; } from '../types';
@@ -82,7 +82,7 @@ export class MenuButtonController {
) as FrigateCardCustomAction, ) as FrigateCardCustomAction,
}); });
if (visibleCameras) { if (visibleCameras.size) {
const menuItems = Array.from(visibleCameras, ([cameraID, config]) => { const menuItems = Array.from(visibleCameras, ([cameraID, config]) => {
const action = createFrigateCardCustomAction('camera_select', { const action = createFrigateCardCustomAction('camera_select', {
camera: cameraID, camera: cameraID,
@@ -388,10 +388,31 @@ export class MenuButtonController {
...config.menu.buttons.screenshot, ...config.menu.buttons.screenshot,
type: 'custom:frigate-card-menu-icon', type: 'custom:frigate-card-menu-icon',
title: localize('config.menu.buttons.screenshot'), title: localize('config.menu.buttons.screenshot'),
tap_action: createFrigateCardCustomAction('screenshot') as FrigateCardCustomAction, tap_action: createFrigateCardCustomAction(
'screenshot',
) as FrigateCardCustomAction,
}); });
} }
if (view.supportsMultipleDisplayModes() && visibleCameras.size > 1) {
const isGrid = view.isGrid();
const action = createFrigateCardCustomAction('display_mode_select', {
display_mode: isGrid ? 'single' : 'grid',
});
/* istanbul ignore else: the else path cannot be reached -- @preserve */
if (action) {
buttons.push({
icon: isGrid ? 'mdi:grid-off' : 'mdi:grid',
...config.menu.buttons.display_mode,
type: 'custom:frigate-card-menu-icon',
title: isGrid
? localize('display_modes.single')
: localize('display_modes.grid'),
tap_action: action,
});
}
}
const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({ const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
style: this._getStyleFromActions(config, view, button), style: this._getStyleFromActions(config, view, button),
...button, ...button,
+11 -15
View File
@@ -5,7 +5,7 @@ import { DataSet } from 'vis-data';
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext'; import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
import { ClipsOrSnapshotsOrAll } from '../types'; import { ClipsOrSnapshotsOrAll } from '../types';
import { CameraManager } from '../camera-manager/manager'; import { CameraManager } from '../camera-manager/manager';
import { EventQuery, RecordingSegment } from '../camera-manager/types'; import { EventQuery, RecordingQuery, RecordingSegment } from '../camera-manager/types';
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util'; import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
import { ViewMedia } from '../view/media'; import { ViewMedia } from '../view/media';
import { import {
@@ -98,12 +98,6 @@ export class TimelineDataSource {
} }
} }
public getCacheFriendlyEventWindow(window: TimelineWindow): TimelineWindow {
return convertRangeToCacheFriendlyTimes(window, {
endCap: true,
});
}
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null { public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null {
return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, { return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, {
start: window.start, start: window.start,
@@ -113,6 +107,13 @@ export class TimelineDataSource {
}); });
} }
public getTimelineRecordingQueries(window: TimelineWindow): RecordingQuery[] | null {
return this._cameraManager.generateDefaultRecordingQueries(this._cameraIDs, {
start: window.start,
end: window.end,
});
}
protected async _refreshEvents( protected async _refreshEvents(
hass: HomeAssistant, hass: HomeAssistant,
window: TimelineWindow, window: TimelineWindow,
@@ -127,8 +128,7 @@ export class TimelineDataSource {
) { ) {
return; return;
} }
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window);
const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow); const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow);
if (!eventQueries) { if (!eventQueries) {
return; return;
@@ -137,7 +137,6 @@ export class TimelineDataSource {
const mediaArray = await this._cameraManager.executeMediaQueries(hass, eventQueries); const mediaArray = await this._cameraManager.executeMediaQueries(hass, eventQueries);
const data: FrigateCardTimelineItem[] = []; const data: FrigateCardTimelineItem[] = [];
for (const media of mediaArray ?? []) { for (const media of mediaArray ?? []) {
const endTime = media.getEndTime();
const startTime = media.getStartTime(); const startTime = media.getStartTime();
const id = media.getID(); const id = media.getID();
if (id && startTime) { if (id && startTime) {
@@ -148,7 +147,7 @@ export class TimelineDataSource {
media: media, media: media,
start: startTime.getTime(), start: startTime.getTime(),
type: 'range', type: 'range',
end: endTime?.getTime() ?? startTime.getTime(), end: media.getUsableEndTime()?.getTime() ?? startTime.getTime(),
}); });
} }
} }
@@ -217,10 +216,7 @@ export class TimelineDataSource {
return; return;
} }
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window, { const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
endCap: true,
});
const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries( const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries(
this._cameraIDs, this._cameraIDs,
{ {
+8 -9
View File
@@ -74,23 +74,22 @@ export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> | null
* available unions). This usually suggests the user specified an incorrect * available unions). This usually suggests the user specified an incorrect
* type name entirely. */ * type name entirely. */
const contenders = new Set<string>(); const contenders = new Set<string>();
if (error && error.issues) { if (error.issues.length) {
for (let i = 0; i < error.issues.length; i++) { for (const issue of error.issues) {
const issue = error.issues[i]; if (issue.code === 'invalid_union') {
if (issue.code == 'invalid_union') {
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors; const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
for (let j = 0; j < unionErrors.length; j++) { for (const unionError of unionErrors) {
const nestedErrors = getParseErrorPaths(unionErrors[j]); const nestedErrors = getParseErrorPaths(unionError);
if (nestedErrors && nestedErrors.size) { if (nestedErrors && nestedErrors.size) {
nestedErrors.forEach(contenders.add, contenders); nestedErrors.forEach(contenders.add, contenders);
} }
} }
} else if (issue.code == 'invalid_type') { } else if (issue.code === 'invalid_type') {
if (issue.path[issue.path.length - 1] == 'type') { if (issue.path[issue.path.length - 1] === 'type') {
return null; return null;
} }
contenders.add(getParseErrorPathString(issue.path)); contenders.add(getParseErrorPathString(issue.path));
} else if (issue.code != 'custom') { } else {
contenders.add(getParseErrorPathString(issue.path)); contenders.add(getParseErrorPathString(issue.path));
} }
} }
+10 -5
View File
@@ -141,15 +141,20 @@ export class Zoom {
} }
public deactivate(): void { public deactivate(): void {
const unregisterListener = (events: string[], func: (ev: Event) => void) => { const unregisterListener = (
events: string[],
func: (ev: Event) => void,
options?: EventListenerOptions,
) => {
events.forEach((eventName) => { events.forEach((eventName) => {
this._element.removeEventListener(eventName, func); this._element.removeEventListener(eventName, func, options);
}); });
}; };
unregisterListener(this._events['down'], this._downHandler); unregisterListener(this._events['down'], this._downHandler, { capture: true });
unregisterListener(this._events['move'], this._moveHandler); unregisterListener(this._events['move'], this._moveHandler, { capture: true });
unregisterListener(this._events['up'], this._upHandler); unregisterListener(this._events['up'], this._upHandler, { capture: true });
unregisterListener(['wheel'], this._wheelHandler); unregisterListener(['wheel'], this._wheelHandler);
unregisterListener(['click'], this._clickHandler, { capture: true });
} }
} }
+9
View File
@@ -1,5 +1,6 @@
import { EventMediaQueries, MediaQueries, RecordingMediaQueries } from './media-queries'; import { EventMediaQueries, MediaQueries, RecordingMediaQueries } from './media-queries';
export type MediaQueriesType = 'event' | 'recording';
export class MediaQueriesClassifier { export class MediaQueriesClassifier {
public static areEventQueries( public static areEventQueries(
queries?: MediaQueries | null, queries?: MediaQueries | null,
@@ -12,4 +13,12 @@ export class MediaQueriesClassifier {
): queries is RecordingMediaQueries { ): queries is RecordingMediaQueries {
return queries instanceof RecordingMediaQueries; return queries instanceof RecordingMediaQueries;
} }
public static getQueriesType(queries?: MediaQueries | null): MediaQueriesType | null {
return this.areEventQueries(queries)
? 'event'
: this.areRecordingQueries(queries)
? 'recording'
: null;
}
} }
+224 -57
View File
@@ -1,40 +1,151 @@
import clone from 'lodash-es/clone.js';
import { isSuperset } from '../utils/basic.js'; import { isSuperset } from '../utils/basic.js';
import { ViewMedia } from './media.js'; import { ViewMedia } from './media.js';
export class MediaQueriesResults { type CameraResultSlices = Map<string, ResultSlice>;
protected _results: ViewMedia[] | null = null; type SelectApproach = 'first' | 'last';
protected _resultsTimestamp: Date | null = null;
protected _selectedIndex: number | null = null;
constructor(results?: ViewMedia[], selectedIndex?: number | null) { interface ResultSliceOptions {
if (results) { results?: ViewMedia[];
this.setResults(results); selectedIndex?: number | null;
selectApproach?: SelectApproach;
} }
if (selectedIndex !== undefined) {
this.selectResult(selectedIndex); class ResultSlice {
protected _results: ViewMedia[];
protected _selectedIndex: number | null;
constructor(options?: ResultSliceOptions) {
this._results = options?.results ?? [];
this._selectedIndex = this._getInitialSelectedIndex(options);
}
protected _getInitialSelectedIndex(options?: ResultSliceOptions): number | null {
if (options?.selectedIndex !== undefined && options?.selectedIndex !== null) {
return options.selectedIndex;
}
if (options?.results && options.results.length) {
if (!options?.selectApproach || options?.selectApproach === 'last') {
return options.results.length - 1;
} else {
return 0;
}
}
return null;
}
public clone(): ResultSlice {
return new ResultSlice({
results: this._results,
selectedIndex: this._selectedIndex,
});
}
public getResults(): ViewMedia[] {
return this._results;
}
public getSelectedIndex(): number | null {
return this._selectedIndex;
}
public getResultsCount(): number {
return this.getResults().length;
}
public hasResults(): boolean {
return this.getResultsCount() !== 0;
}
public getResult(index?: number): ViewMedia | null {
return index === undefined ? null : this._results[index];
}
public getSelectedResult(): ViewMedia | null {
const index = this.getSelectedIndex();
return index !== null ? this.getResult(index) : null;
}
public hasSelectedResult(): boolean {
return this.getSelectedResult() !== null;
}
public resetSelectedResult(): void {
this._selectedIndex = null;
}
public selectIndex(index: number | null): void {
if (index === null || (index >= 0 && index < this._results.length)) {
this._selectedIndex = index;
}
}
public selectResultIfFound(func: (media: ViewMedia) => boolean): void {
for (const [index, result] of this._results.entries()) {
if (func(result)) {
this.selectIndex(index);
break;
}
}
}
public selectBestResult(func: (media: ViewMedia[]) => number | null): void {
const resultIndex = func(this._results);
if (resultIndex !== null) {
this.selectIndex(resultIndex);
}
}
}
interface ResultSliceSelectionCriteria {
main?: boolean;
cameraID?: string;
allCameras?: boolean;
}
export class MediaQueriesResults {
protected _resultsTimestamp: Date | null = null;
protected _main: ResultSlice;
protected _cameras: CameraResultSlices = new Map();
constructor(options?: ResultSliceOptions) {
this._resultsTimestamp = new Date();
this._main = new ResultSlice(options);
this._buildByCameraSlices(options?.selectApproach);
}
protected _buildByCameraSlices(selectApproach?: SelectApproach): void {
const cameraMap: Map<string, ViewMedia[]> = new Map();
for (const result of this._main.getResults()) {
const cameraID = result.getCameraID();
const media: ViewMedia[] = cameraMap.get(cameraID) ?? [];
media.push(result);
cameraMap.set(cameraID, media);
}
for (const [cameraID, media] of cameraMap.entries()) {
this._cameras.set(
cameraID,
new ResultSlice({
results: media,
selectApproach: selectApproach,
}),
);
} }
} }
public clone(): MediaQueriesResults { public clone(): MediaQueriesResults {
// Shallow clone -- will reuse the same _results object (as there are no // Shallow clone -- will reuse the same results object (as there are no
// methods that support modification of the results themselves, and since // methods that support modification of the results themselves, and since
// changing the selectedIndex on a consistent set of results is a common // changing the index on a consistent set of results is a very common
// operation). // operation).
return clone(this); const copy = new MediaQueriesResults();
copy._resultsTimestamp = this._resultsTimestamp;
copy._main = this._main.clone();
for (const [cameraID, slice] of this._cameras.entries()) {
copy._cameras.set(cameraID, slice.clone());
}
return copy;
} }
public isSupersetOf(that: MediaQueriesResults): boolean { public isSupersetOf(that: MediaQueriesResults): boolean {
if (!this._results || !that._results) { const thisMediaIDs = new Set(this._main.getResults()?.map((media) => media.getID()));
return false; const thatMediaIDs = new Set(that._main.getResults()?.map((media) => media.getID()));
}
const thisMediaIDs = new Set(this._results.map((media) => media.getID()));
const thatMediaIDs = new Set(that._results.map((media) => media.getID()));
if ( if (
!thisMediaIDs || !thisMediaIDs.size ||
!thatMediaIDs || !thatMediaIDs.size ||
// If either media sets contain a null identifier (i.e. a media item with // If either media sets contain a null identifier (i.e. a media item with
// no ID) we must assume this is not a subset as multiple media items may // no ID) we must assume this is not a subset as multiple media items may
// reduce to the same null identifier above. // reduce to the same null identifier above.
@@ -46,68 +157,124 @@ export class MediaQueriesResults {
return isSuperset(thisMediaIDs, thatMediaIDs); return isSuperset(thisMediaIDs, thatMediaIDs);
} }
public getResults(): ViewMedia[] | null { public getCameraIDs(): Set<string> {
return this._results; return new Set(this._cameras.keys());
} }
public getResultsCount(): number {
return this._results?.length ?? 0; public getSlice(cameraID?: string): ResultSlice | null {
return cameraID ? this._cameras.get(cameraID) ?? null : this._main;
} }
public hasResults(): boolean {
return !!this._results; public getResults(cameraID?: string): ViewMedia[] | null {
return this.getSlice(cameraID)?.getResults() ?? null;
} }
public setResults(results: ViewMedia[]) { public getResultsCount(cameraID?: string): number {
this._results = results; return this.getSlice(cameraID)?.getResultsCount() ?? 0;
this._resultsTimestamp = new Date();
} }
public getResult(index?: number): ViewMedia | null { public hasResults(cameraID?: string): boolean {
if (!this._results || index === undefined) { return this.getSlice(cameraID)?.getResultsCount() !== 0;
return null;
} }
return this._results[index]; public getResult(index?: number, cameraID?: string): ViewMedia | null {
return this.getSlice(cameraID)?.getResult(index) ?? null;
} }
public getSelectedResult(): ViewMedia | null { public getSelectedIndex(cameraID?: string): number | null {
return this._selectedIndex === null ? null : this.getResult(this._selectedIndex); return this.getSlice(cameraID)?.getSelectedIndex() ?? null;
} }
public getSelectedIndex(): number | null { public getSelectedResult(cameraID?: string): ViewMedia | null {
return this._selectedIndex; return this.getSlice(cameraID)?.getSelectedResult() ?? null;
} }
public hasSelectedResult(): boolean { public getMultipleSelectedResults(
return this.getSelectedResult() !== null; criteria?: ResultSliceSelectionCriteria,
): ViewMedia[] {
const results: ViewMedia[] = [];
if (!criteria || criteria.main) {
const mainResult = this.getSelectedResult();
if (mainResult) {
results.push(mainResult);
} }
public resetSelectedResult(): MediaQueriesResults { }
this._selectedIndex = null; const cameraIDs = this._getCameraIDsFromCriteria(criteria);
for (const cameraID of cameraIDs ?? []) {
const result = this.getSelectedResult(cameraID);
if (result) {
results.push(result);
}
}
return results;
}
public hasSelectedResult(cameraID?: string): boolean {
return this.getSlice(cameraID)?.hasSelectedResult() ?? false;
}
public resetSelectedResult(cameraID?: string): MediaQueriesResults {
this.getSlice(cameraID)?.resetSelectedResult();
return this; return this;
} }
public getResultsTimestamp(): Date | null { public getResultsTimestamp(): Date | null {
return this._resultsTimestamp; return this._resultsTimestamp;
} }
public selectResult(index: number | null): MediaQueriesResults { public selectIndex(index: number, cameraID?: string): MediaQueriesResults {
if ( this.getSlice(cameraID)?.selectIndex(index);
index === null || if (!cameraID) {
(this._results && index >= 0 && index < this._results.length) // If the main selection is changed, it must also change the matching
) { // camera selection.
this._selectedIndex = index; this.demoteMainSelectionToCameraSelection();
} }
return this; return this;
} }
public selectResultIfFound(func: (media: ViewMedia) => boolean): MediaQueriesResults {
for (const [index, result] of this._results?.entries() ?? []) { public demoteMainSelectionToCameraSelection(): MediaQueriesResults {
if (func(result)) { const selected = this.getSelectedResult();
this._selectedIndex = index; if (selected) {
break; const cameraID = selected.getCameraID();
this.resetSelectedResult(cameraID);
this.selectResultIfFound((media) => media === selected, { cameraID: cameraID });
} }
return this;
}
public promoteCameraSelectionToMainSelection(cameraID: string): MediaQueriesResults {
const selected = this.getSelectedResult(cameraID);
this.resetSelectedResult();
this.selectResultIfFound((media) => media === selected);
return this;
}
protected _getCameraIDsFromCriteria(
criteria?: ResultSliceSelectionCriteria,
): Set<string> | null {
return criteria?.allCameras
? this.getCameraIDs()
: criteria?.cameraID
? new Set([criteria.cameraID])
: null;
}
public selectResultIfFound(
func: (media: ViewMedia) => boolean,
criteria?: ResultSliceSelectionCriteria,
): MediaQueriesResults {
if (!criteria || criteria?.main) {
this._main.selectResultIfFound(func);
this.demoteMainSelectionToCameraSelection();
}
const cameraIDs = this._getCameraIDsFromCriteria(criteria);
for (const cameraID of cameraIDs ?? []) {
this.getSlice(cameraID)?.selectResultIfFound(func);
} }
return this; return this;
} }
public selectBestResult( public selectBestResult(
func: (media: ViewMedia[]) => number | null, func: (media: ViewMedia[]) => number | null,
criteria?: ResultSliceSelectionCriteria,
): MediaQueriesResults { ): MediaQueriesResults {
if (this._results) { if (!criteria || criteria.main) {
const resultIndex = func(this._results); this._main.selectBestResult(func);
if (resultIndex !== null) { this.demoteMainSelectionToCameraSelection();
this._selectedIndex = resultIndex;
} }
const cameraIDs = this._getCameraIDsFromCriteria(criteria);
for (const cameraID of cameraIDs ?? []) {
this.getSlice(cameraID)?.selectBestResult(func);
} }
return this; return this;
} }
+40 -7
View File
@@ -1,5 +1,8 @@
import cloneDeep from 'lodash-es/cloneDeep.js'; import cloneDeep from 'lodash-es/cloneDeep.js';
import isEqual from 'lodash-es/isEqual.js';
import uniqWith from 'lodash-es/uniqWith.js';
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js'; import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js';
import { setify } from '../utils/basic.js';
export type MediaQueries = EventMediaQueries | RecordingMediaQueries; export type MediaQueries = EventMediaQueries | RecordingMediaQueries;
@@ -12,7 +15,7 @@ class MediaQueriesBase<T extends MediaQuery> {
} }
} }
public clone(): MediaQueriesBase<T> { public clone(): this {
return cloneDeep(this); return cloneDeep(this);
} }
@@ -20,21 +23,51 @@ class MediaQueriesBase<T extends MediaQuery> {
return this._queries; return this._queries;
} }
public setQueries(queries: T[]): void { public setQueries(queries: T[]): this {
this._queries = queries; this._queries = queries;
return this;
}
public getQueryCameraIDs(): Set<string> | null {
if (!this._queries) {
return null;
}
const cameraIDs: Set<string> = new Set();
this._queries.forEach((query) =>
[...query.cameraIDs].forEach((cameraID) => cameraIDs.add(cameraID)),
);
return cameraIDs;
}
public setQueryCameraIDs(cameraIDs: string | Set<string>): this {
if (!this._queries) {
return this;
}
const rewrittenQueries: T[] = [];
this._queries.forEach((query) =>
rewrittenQueries.push({ ...query, cameraIDs: setify(cameraIDs) }),
);
this._queries = uniqWith(rewrittenQueries, isEqual);
return this;
}
public hasQueriesForCameraIDs(cameraIDs: Set<string>) {
for (const cameraID of cameraIDs) {
if (!this._queries?.some((query) => query.cameraIDs.has(cameraID))) {
return false;
}
}
return true;
} }
} }
export class EventMediaQueries extends MediaQueriesBase<EventQuery> { export class EventMediaQueries extends MediaQueriesBase<EventQuery> {
public convertToClipsQueries(): void { public convertToClipsQueries(): this {
for (const query of this._queries ?? []) { for (const query of this._queries ?? []) {
delete query.hasSnapshot; delete query.hasSnapshot;
query.hasClip = true; query.hasClip = true;
} }
} return this;
public clone(): EventMediaQueries {
return cloneDeep(this);
} }
} }
-3
View File
@@ -13,9 +13,6 @@ export class ViewMedia {
this._mediaType = mediaType; this._mediaType = mediaType;
this._cameraID = cameraID; this._cameraID = cameraID;
} }
public getContentType(): 'image' | 'video' {
return this._mediaType === 'snapshot' ? 'image' : 'video';
}
public getCameraID(): string { public getCameraID(): string {
return this._cameraID; return this._cameraID;
} }
+33 -7
View File
@@ -1,5 +1,5 @@
import { ViewContext } from 'view'; import { ViewContext } from 'view';
import { ClipsOrSnapshots, FrigateCardView } from '../types.js'; import { ClipsOrSnapshots, FrigateCardView, ViewDisplayMode } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js';
import { MediaQueries } from './media-queries'; import { MediaQueries } from './media-queries';
import { MediaQueriesClassifier } from './media-queries-classifier.js'; import { MediaQueriesClassifier } from './media-queries-classifier.js';
@@ -11,6 +11,7 @@ interface ViewEvolveParameters {
query?: MediaQueries | null; query?: MediaQueries | null;
queryResults?: MediaQueriesResults | null; queryResults?: MediaQueriesResults | null;
context?: ViewContext | null; context?: ViewContext | null;
displayMode?: ViewDisplayMode | null;
} }
export interface ViewParameters extends ViewEvolveParameters { export interface ViewParameters extends ViewEvolveParameters {
@@ -24,6 +25,7 @@ export class View {
public query: MediaQueries | null; public query: MediaQueries | null;
public queryResults: MediaQueriesResults | null; public queryResults: MediaQueriesResults | null;
public context: ViewContext | null; public context: ViewContext | null;
public displayMode: ViewDisplayMode | null;
constructor(params: ViewParameters) { constructor(params: ViewParameters) {
this.view = params.view; this.view = params.view;
@@ -31,6 +33,7 @@ export class View {
this.query = params.query ?? null; this.query = params.query ?? null;
this.queryResults = params.queryResults ?? null; this.queryResults = params.queryResults ?? null;
this.context = params.context ?? null; this.context = params.context ?? null;
this.displayMode = params.displayMode ?? null;
} }
/** /**
@@ -88,7 +91,12 @@ export class View {
let currentQueriesView: ClipsOrSnapshots | 'recordings' | null = null; let currentQueriesView: ClipsOrSnapshots | 'recordings' | null = null;
if (MediaQueriesClassifier.areEventQueries(curr.query)) { if (MediaQueriesClassifier.areEventQueries(curr.query)) {
const queries = curr.query.getQueries(); const queries = curr.query.getQueries();
if (queries?.every((query) => query.hasClip)) { if (
queries?.every((query) => query.hasClip) ||
queries?.every(
(query) => query.hasClip === undefined && query.hasSnapshot === undefined,
)
) {
currentQueriesView = 'clips'; currentQueriesView = 'clips';
} else if (queries?.every((query) => query.hasSnapshot)) { } else if (queries?.every((query) => query.hasSnapshot)) {
currentQueriesView = 'snapshots'; currentQueriesView = 'snapshots';
@@ -103,13 +111,9 @@ export class View {
const switchingToMediaFromMedia = curr?.is('media') && next.is('media'); const switchingToMediaFromMedia = curr?.is('media') && next.is('media');
if (hasNoQueryOrResults) { if (hasNoQueryOrResults) {
if (switchingToGalleryFromViewer) { if (switchingToGalleryFromViewer && curr.query && curr.queryResults) {
if (curr.query) {
next.query = curr.query; next.query = curr.query;
}
if (curr.queryResults) {
next.queryResults = curr.queryResults; next.queryResults = curr.queryResults;
}
} else if (switchingToMediaFromMedia && currentQueriesView) { } else if (switchingToMediaFromMedia && currentQueriesView) {
next.view = next.view =
currentQueriesView === 'clips' currentQueriesView === 'clips'
@@ -142,6 +146,7 @@ export class View {
query: this.query?.clone() ?? null, query: this.query?.clone() ?? null,
queryResults: this.queryResults?.clone() ?? null, queryResults: this.queryResults?.clone() ?? null,
context: this.context, context: this.context,
displayMode: this.displayMode,
}); });
} }
@@ -160,6 +165,8 @@ export class View {
? params.queryResults ? params.queryResults
: this.queryResults?.clone() ?? null, : this.queryResults?.clone() ?? null,
context: params.context !== undefined ? params.context : this.context, context: params.context !== undefined ? params.context : this.context,
displayMode:
params.displayMode !== undefined ? params.displayMode : this.displayMode,
}); });
} }
@@ -185,6 +192,17 @@ export class View {
return this; return this;
} }
public removeContextProperty(
contextKey: keyof ViewContext,
removeKey: PropertyKey,
): View {
const contextObj = this.context?.[contextKey];
if (contextObj) {
delete contextObj[removeKey];
}
return this;
}
/** /**
* Determine if current view matches a named view. * Determine if current view matches a named view.
*/ */
@@ -214,6 +232,10 @@ export class View {
return ['clip', 'snapshot', 'media', 'recording'].includes(this.view); return ['clip', 'snapshot', 'media', 'recording'].includes(this.view);
} }
public supportsMultipleDisplayModes(): boolean {
return this.isViewerView() || this.is('live');
}
/** /**
* Get the default media type for this view if available. * Get the default media type for this view if available.
* @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown * @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown
@@ -232,6 +254,10 @@ export class View {
return null; return null;
} }
public isGrid(): boolean {
return this.displayMode === 'grid';
}
/** /**
* Dispatch an event to request a view change. * Dispatch an event to request a view change.
* @param target The target dispatching the event. * @param target The target dispatching the event.
+23 -5
View File
@@ -59,7 +59,7 @@ describe('RecordingSegmentsCache', () => {
start: now, start: now,
end: add(now, { hours: 1 }), end: add(now, { hours: 1 }),
}; };
const badRange = { start: sub(now, { hours: 1 }), end: now }; const pastRange = { start: sub(now, { hours: 1 }), end: now };
const createSegment = (date: Date, id: string): RecordingSegment => { const createSegment = (date: Date, id: string): RecordingSegment => {
return { return {
start_time: date.getTime() / 1000, start_time: date.getTime() / 1000,
@@ -89,7 +89,7 @@ describe('RecordingSegmentsCache', () => {
}); });
it('should not get for other range', () => { it('should not get for other range', () => {
cache.add('camera-1', range, segments); cache.add('camera-1', range, segments);
expect(cache.get('camera-1', badRange)).toBeNull(); expect(cache.get('camera-1', pastRange)).toBeNull();
}); });
it('should have coverage when added', () => { it('should have coverage when added', () => {
@@ -102,7 +102,7 @@ describe('RecordingSegmentsCache', () => {
}); });
it('should not have coverage for other range', () => { it('should not have coverage for other range', () => {
cache.add('camera-1', range, segments); cache.add('camera-1', range, segments);
expect(cache.hasCoverage('camera-1', badRange)).toBeFalsy(); expect(cache.hasCoverage('camera-1', pastRange)).toBeFalsy();
}); });
it('should be empty when cleared', () => { it('should be empty when cleared', () => {
@@ -114,11 +114,11 @@ describe('RecordingSegmentsCache', () => {
it('should get size', () => { it('should get size', () => {
cache.add('camera-1', range, segments); cache.add('camera-1', range, segments);
expect(cache.getSize("camera-1")).toBe(3); expect(cache.getSize('camera-1')).toBe(3);
}); });
it('should not size for other camera', () => { it('should not size for other camera', () => {
cache.add('camera-1', range, segments); cache.add('camera-1', range, segments);
expect(cache.getSize("camera-2")).toBeNull(); expect(cache.getSize('camera-2')).toBeNull();
}); });
it('should return cameraIDs', () => { it('should return cameraIDs', () => {
@@ -127,6 +127,24 @@ describe('RecordingSegmentsCache', () => {
expect(sortBy(cache.getCameraIDs())).toEqual(sortBy(['camera-1', 'camera-2'])); expect(sortBy(cache.getCameraIDs())).toEqual(sortBy(['camera-1', 'camera-2']));
}); });
it('should add segments to existing range', () => {
cache.add('camera-1', range, segments);
cache.add('camera-1', range, [
createSegment(add(now, { seconds: 15 }), 'segment-2.5'),
]);
expect(cache.get('camera-1', range)?.length).toBe(4);
});
it('should not get segments that are outside range', () => {
cache.add('camera-1', range, segments);
expect(cache.get('camera-1', range)?.length).toBe(3);
// Add a segment before and after the desired range.
cache.add('camera-1', range, [
createSegment(sub(now, { seconds: 15 }), 'segment-0'),
createSegment(add(range.end, { seconds: 10 }), 'segment-4'),
]);
expect(cache.get('camera-1', range)?.length).toBe(3);
});
it('should remove expired matches', () => { it('should remove expired matches', () => {
cache.add('camera-1', range, segments); cache.add('camera-1', range, segments);
cache.expireMatches('camera-1', (segment) => segment === segments[0]); cache.expireMatches('camera-1', (segment) => segment === segments[0]);
@@ -100,6 +100,11 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
Engine.Frigate, Engine.Frigate,
); );
}); });
it('should get no engine from config with insufficient details', async () => {
const config = createCameraConfig({});
expect(await createFactory().getEngineForCamera(createHASS(), config)).toBeNull();
});
it('should throw error on invalid entity', async () => { it('should throw error on invalid entity', async () => {
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager(new EntityCache()); const entityRegistryManager = new EntityRegistryManager(new EntityCache());
+5
View File
@@ -135,6 +135,7 @@ describe('compressRanges', () => {
compressRanges([ compressRanges([
{ start: now, end: nowPlusOne }, { start: now, end: nowPlusOne },
{ start: nowPlusOne, end: nowPlusTwo }, { start: nowPlusOne, end: nowPlusTwo },
{ start: now, end: nowPlusOne },
]), ]),
).toEqual([{ start: now, end: nowPlusTwo }]); ).toEqual([{ start: now, end: nowPlusTwo }]);
}); });
@@ -162,4 +163,8 @@ describe('compressRanges', () => {
]; ];
expect(compressRanges(input)).toEqual([{ start: 1, end: 3 }]); expect(compressRanges(input)).toEqual([{ start: 1, end: 3 }]);
}); });
it('should return nothing with no input', () => {
expect(compressRanges([])).toEqual([]);
});
}); });
+20 -24
View File
@@ -76,30 +76,26 @@ describe('capEndDate', () => {
}); });
describe('sortMedia', () => { describe('sortMedia', () => {
const media_1 = new TestViewMedia( const media_1 = new TestViewMedia({
'id-1', id: 'id-1',
new Date('2023-04-29T14:25'), startTime: new Date('2023-04-29T14:25'),
'clip', cameraID: 'camera-1',
'camera-1', });
); const media_2 = new TestViewMedia({
const media_2 = new TestViewMedia( id: 'id-2',
'id-2', startTime: new Date('2023-04-29T14:26'),
new Date('2023-04-29T14:26'), cameraID: 'camera-1',
'clip', });
'camera-1', const media_3_dup_id = new TestViewMedia({
); id: 'id-2',
const media_3_dup_id = new TestViewMedia( startTime: new Date('2023-04-29T14:26'),
'id-2', cameraID: 'camera-1',
new Date('2023-04-29T14:26'), });
'clip', const media_4_no_id = new TestViewMedia({
'camera-1', id: null,
); startTime: new Date('2023-04-29T14:27'),
const media_4_no_id = new TestViewMedia( cameraID: 'camera-1',
null, });
new Date('2023-04-29T14:27'),
'clip',
'camera-1',
);
it('should sort sorted media', () => { it('should sort sorted media', () => {
const media = [media_1, media_2]; const media = [media_1, media_2];
+19 -1
View File
@@ -1,4 +1,4 @@
import { afterEach, describe, it, expect, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
ConditionController, ConditionController,
ConditionEvaluateRequestEvent, ConditionEvaluateRequestEvent,
@@ -6,6 +6,7 @@ import {
getOverriddenConfig, getOverriddenConfig,
getOverridesByKey, getOverridesByKey,
} from '../src/conditions'; } from '../src/conditions';
import { FrigateCardCondition } from '../src/types';
import { createCondition, createConfig, createStateEntity } from './test-utils'; import { createCondition, createConfig, createStateEntity } from './test-utils';
// @vitest-environment jsdom // @vitest-environment jsdom
@@ -98,6 +99,13 @@ describe('getOverriddenConfig', () => {
}, },
}); });
}); });
it('should do nothing without overrides', () => {
const controller = new ConditionController();
controller.setState({ fullscreen: true });
expect(getOverriddenConfig(controller, config)).toBe(config);
});
}); });
describe('getOverridesByKey', () => { describe('getOverridesByKey', () => {
@@ -347,4 +355,14 @@ describe('ConditionController', () => {
controller.destroy(); controller.destroy();
expect(removeEventListener).toBeCalled(); expect(removeEventListener).toBeCalled();
}); });
it('should evaluate conditions with display mode', () => {
const controller = new ConditionController();
const condition: FrigateCardCondition = { display_mode: 'grid' };
expect(controller.evaluateCondition(condition)).toBeFalsy();
controller.setState({ displayMode: 'grid' });
expect(controller.evaluateCondition(condition)).toBeTruthy();
controller.setState({ displayMode: 'single' });
expect(controller.evaluateCondition(condition)).toBeFalsy();
});
}); });
+107 -10
View File
@@ -9,6 +9,7 @@ import {
CameraConfigs, CameraConfigs,
CameraManagerCameraCapabilities, CameraManagerCameraCapabilities,
CameraManagerMediaCapabilities, CameraManagerMediaCapabilities,
QueryType,
} from '../src/camera-manager/types'; } from '../src/camera-manager/types';
import { import {
CameraConfig, CameraConfig,
@@ -16,10 +17,12 @@ import {
FrigateCardCondition, FrigateCardCondition,
FrigateCardConfig, FrigateCardConfig,
MediaLoadedInfo, MediaLoadedInfo,
PerformanceConfig,
RawFrigateCardConfig, RawFrigateCardConfig,
cameraConfigSchema, cameraConfigSchema,
frigateCardConditionSchema, frigateCardConditionSchema,
frigateCardConfigSchema, frigateCardConfigSchema,
performanceConfigSchema,
} from '../src/types'; } from '../src/types';
import { Entity } from '../src/utils/ha/entity-registry/types'; import { Entity } from '../src/utils/ha/entity-registry/types';
import { ViewMedia, ViewMediaType } from '../src/view/media'; import { ViewMedia, ViewMediaType } from '../src/view/media';
@@ -126,11 +129,25 @@ export const createCameraManager = (options?: {
const configs = options?.configs ?? new Map([['camera', createCameraConfig()]]); const configs = options?.configs ?? new Map([['camera', createCameraConfig()]]);
vi.mocked(store.getCameras).mockReturnValue(configs); vi.mocked(store.getCameras).mockReturnValue(configs);
vi.mocked(store.getVisibleCameras).mockReturnValue(configs); vi.mocked(store.getVisibleCameras).mockReturnValue(configs);
vi.mocked(store.getVisibleCameraIDs).mockReturnValue(new Set(configs.keys()));
vi.mocked(store.getCameraConfig).mockImplementation((cameraID): CameraConfig => { vi.mocked(store.getCameraConfig).mockImplementation((cameraID): CameraConfig => {
return configs.get(cameraID) ?? createCameraConfig(); return configs.get(cameraID) ?? createCameraConfig();
}); });
} }
vi.mocked(cameraManager.getStore).mockReturnValue(store); vi.mocked(cameraManager.getStore).mockReturnValue(store);
vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue([
{
cameraIDs: new Set(['camera']),
type: QueryType.Event,
},
]);
vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue([
{
cameraIDs: new Set(['camera']),
type: QueryType.Recording,
},
]);
return cameraManager; return cameraManager;
}; };
@@ -169,21 +186,44 @@ export const createMediaLoadedInfo = (
}; };
}; };
export const createPerformanceConfig = (config: unknown): PerformanceConfig => {
return performanceConfigSchema.parse(config);
};
export const generateViewMediaArray = (options?: {
cameraIDs?: string[];
count?: number;
}): ViewMedia[] => {
const media: ViewMedia[] = [];
for (let i = 0; i < (options?.count ?? 100); ++i) {
for (const cameraID of options?.cameraIDs ?? ['kitchen', 'office']) {
media.push(new TestViewMedia({ cameraID: cameraID, id: `id-${cameraID}-${i}` }));
}
}
return media;
};
// ViewMedia itself has no native way to set startTime and ID that aren't linked // ViewMedia itself has no native way to set startTime and ID that aren't linked
// to an engine. // to an engine.
export class TestViewMedia extends ViewMedia { export class TestViewMedia extends ViewMedia {
protected _id: string | null; protected _id: string | null;
protected _startTime: Date; protected _startTime: Date | null;
protected _endTime: Date | null;
protected _inProgress: boolean | null;
constructor( constructor(options?: {
id: string | null, id?: string | null;
startTime: Date, startTime?: Date;
mediaType: ViewMediaType, mediaType?: ViewMediaType;
cameraID: string, cameraID?: string;
) { endTime?: Date;
super(mediaType, cameraID); inProgress?: boolean;
this._id = id; }) {
this._startTime = startTime; super(options?.mediaType ?? 'clip', options?.cameraID ?? 'camera');
this._id = options?.id !== undefined ? options.id : 'id';
this._startTime = options?.startTime ?? null;
this._endTime = options?.endTime ?? null;
this._inProgress = options?.inProgress !== undefined ? options.inProgress : false;
} }
public getID(): string | null { public getID(): string | null {
return this._id; return this._id;
@@ -191,4 +231,61 @@ export class TestViewMedia extends ViewMedia {
public getStartTime(): Date | null { public getStartTime(): Date | null {
return this._startTime; return this._startTime;
} }
public getEndTime(): Date | null {
return this._endTime;
} }
public inProgress(): boolean | null {
return this._inProgress;
}
}
export const ResizeObserverMock = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));
export const IntersectionObserverMock = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));
export const MutationObserverMock = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));
export const requestAnimationFrameMock = (callback: FrameRequestCallback) => {
callback(new Date().getTime());
return 1;
};
export const createSlotHost = (options?: {
slot?: HTMLSlotElement;
children?: HTMLElement[];
}): HTMLElement => {
const parent = document.createElement('div');
parent.attachShadow({ mode: 'open' });
if (options?.slot) {
parent.shadowRoot?.append(options.slot);
}
if (options?.children) {
// Children will automatically be slotted into the default slot when it is
// created.
parent.append(...options.children);
}
return parent;
};
export const createSlot = (): HTMLSlotElement => {
return document.createElement('slot');
};
export const createParent = (options?: { children?: HTMLElement[] }): HTMLElement => {
const parent = document.createElement('div');
parent.append(...(options?.children ?? []));
return parent;
};
+18
View File
@@ -103,6 +103,24 @@ describe('createFrigateCardCustomAction', () => {
card_id: 'card_id', card_id: 'card_id',
}); });
}); });
it('should create display mode action', () => {
expect(
createFrigateCardCustomAction('display_mode_select', {
display_mode: 'grid',
cardID: 'card_id',
}),
).toEqual({
action: 'fire-dom-event',
frigate_card_action: 'display_mode_select',
display_mode: 'grid',
card_id: 'card_id',
});
});
it('should not create display mode action without display mode', () => {
expect(createFrigateCardCustomAction('display_mode_select')).toBeNull();
});
}); });
describe('getActionConfigGivenAction', () => { describe('getActionConfigGivenAction', () => {
+55 -4
View File
@@ -1,25 +1,29 @@
import { describe, it, expect, vi, afterAll } from 'vitest'; import { afterAll, describe, expect, it, vi } from 'vitest';
import { FrigateCardError } from '../../src/types'; import { FrigateCardError } from '../../src/types';
import { import {
allPromises, allPromises,
arrayify,
arrayMove, arrayMove,
arrayify,
contentsChanged, contentsChanged,
dayToDate, dayToDate,
dispatchFrigateCardEvent, dispatchFrigateCardEvent,
errorToConsole, errorToConsole,
formatDate, formatDate,
formatDateAndTime, formatDateAndTime,
getChildrenFromElement,
getDurationString, getDurationString,
isHTMLElement,
isHoverableDevice, isHoverableDevice,
isSuperset, isSuperset,
isTruthy,
isValidDate, isValidDate,
prettifyTitle, prettifyTitle,
runWhenIdleIfSupported, runWhenIdleIfSupported,
setify,
setOrRemoveAttribute, setOrRemoveAttribute,
setify,
sleep, sleep,
} from '../../src/utils/basic'; } from '../../src/utils/basic';
import { createSlot, createSlotHost } from '../test-utils';
// @vitest-environment jsdom // @vitest-environment jsdom
describe('dispatchFrigateCardEvent', () => { describe('dispatchFrigateCardEvent', () => {
@@ -181,6 +185,11 @@ describe('getDurationString', () => {
const end = new Date(2023, 3, 14, 15, 37, 20); const end = new Date(2023, 3, 14, 15, 37, 20);
expect(getDurationString(start, end)).toBe('2h 2m 20s'); expect(getDurationString(start, end)).toBe('2h 2m 20s');
}); });
it('should return very short duration', () => {
const start = new Date(2023, 3, 14, 13, 35, 10);
const end = new Date(2023, 3, 14, 13, 35, 12);
expect(getDurationString(start, end)).toBe('2s');
});
}); });
describe('allPromises', () => { describe('allPromises', () => {
@@ -227,7 +236,13 @@ describe('isValidDate', () => {
}); });
describe('setOrRemoveAttribute', () => { describe('setOrRemoveAttribute', () => {
it('should set attribute', () => { it('should set attribute without value', () => {
const element = document.createElement('div');
setOrRemoveAttribute(element, true, 'key');
expect(element.getAttribute('key')).toBe('');
});
it('should set attribute with value', () => {
const element = document.createElement('div'); const element = document.createElement('div');
setOrRemoveAttribute(element, true, 'key', 'value'); setOrRemoveAttribute(element, true, 'key', 'value');
expect(element.getAttribute('key')).toBe('value'); expect(element.getAttribute('key')).toBe('value');
@@ -240,3 +255,39 @@ describe('setOrRemoveAttribute', () => {
expect(element.getAttribute('key')).toBeFalsy(); expect(element.getAttribute('key')).toBeFalsy();
}); });
}); });
describe('isTruthy', () => {
it('should return true for true', () => {
expect(isTruthy(true)).toBeTruthy();
});
it('should return false for false', () => {
expect(isTruthy(false)).toBeFalsy();
});
});
describe('isHTMLElement', () => {
it('should return true for HTMLElement', () => {
const htmlElement = document.createElement('div');
expect(isHTMLElement(htmlElement)).toBeTruthy();
});
it('should return false for Element', () => {
const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
expect(isHTMLElement(svgElement)).toBeFalsy();
});
});
describe('getChildrenFromElement', () => {
it('should return children for simple parent', () => {
const children = [document.createElement('div'), document.createElement('div')];
const parent = document.createElement('div');
children.forEach((child) => parent.appendChild(child));
expect(getChildrenFromElement(parent)).toEqual(children);
});
it('should return children for slot', () => {
const children = [document.createElement('div'), document.createElement('div')];
const slot = createSlot();
createSlotHost({ slot: slot, children: children });
expect(getChildrenFromElement(slot)).toEqual(children);
});
});
+62 -37
View File
@@ -12,13 +12,66 @@ vi.mock('../../src/utils/ha');
const media = new ViewMedia('clip', 'camera-1'); const media = new ViewMedia('clip', 'camera-1');
// @vitest-environment jsdom // @vitest-environment jsdom
describe('downloadURL', () => {
afterEach(() => {
vi.restoreAllMocks();
global.window.location = mock<Location>();
});
it('should download same origin via link', () => {
const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo';
global.window.location = location;
const link = document.createElement('a');
link.click = vi.fn();
link.setAttribute = vi.fn();
vi.spyOn(document, 'createElement').mockReturnValue(link);
downloadURL('http://foo/url.mp4');
expect(link.href).toBe('http://foo/url.mp4');
expect(link.setAttribute).toBeCalledWith('download', 'download');
expect(link.click).toBeCalled();
});
it('should download data URL via link', () => {
const link = document.createElement('a');
link.click = vi.fn();
link.setAttribute = vi.fn();
vi.spyOn(document, 'createElement').mockReturnValue(link);
downloadURL('data:text/plain;charset=utf-8;base64,VEhJUyBJUyBEQVRB');
expect(link.href).toBe('data:text/plain;charset=utf-8;base64,VEhJUyBJUyBEQVRB');
expect(link.setAttribute).toBeCalledWith('download', 'download');
expect(link.click).toBeCalled();
});
it('should download in apps via window.open', () => {
// Set the origin to the same.
const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo';
global.window.location = location;
vi.stubGlobal('navigator', {
userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)',
});
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
downloadURL('http://foo/url.mp4');
expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank');
});
});
describe('downloadMedia', () => { describe('downloadMedia', () => {
afterEach(() => { afterEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
global.window.location = mock<Location>(); global.window.location = mock<Location>();
}); });
it('should throw error when no media', async () => { it('should throw error when no media', () => {
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue(null); mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue(null);
@@ -55,44 +108,16 @@ describe('downloadMedia', () => {
await downloadMedia(createHASS(), cameraManager, media); await downloadMedia(createHASS(), cameraManager, media);
expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank'); expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank');
}); });
it('should download media without signing', async () => {
const cameraManager = createCameraManager();
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue({
sign: false,
endpoint: 'https://foo/',
}); });
describe('downloadURL', () => {
afterEach(() => {
vi.resetAllMocks();
global.window.location = mock<Location>();
});
it('should download same origin via link', async () => {
const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo';
global.window.location = location;
const link = document.createElement('a');
link.click = vi.fn();
link.setAttribute = vi.fn();
vi.spyOn(document, 'createElement').mockReturnValue(link);
downloadURL('http://foo/url.mp4');
expect(link.href).toBe('http://foo/url.mp4');
expect(link.setAttribute).toBeCalledWith('download', 'download');
expect(link.click).toBeCalled();
});
it('should download in apps via window.open', async () => {
// Set the origin to the same.
const location: Location & { origin: string } = mock<Location>();
location.origin = 'http://foo';
global.window.location = location;
vi.stubGlobal('navigator', {
userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)',
});
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
downloadURL('http://foo/url.mp4'); await downloadMedia(createHASS(), cameraManager, media);
expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank'); expect(windowSpy).toBeCalledWith('https://foo/', '_blank');
}); });
}); });
@@ -0,0 +1,286 @@
import EmblaCarousel, { EmblaCarouselType } from 'embla-carousel';
import { MockedObject, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CarouselController } from '../../../src/utils/embla/carousel-controller';
import AutoMediaLoadedInfo from '../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
import {
MutationObserverMock,
createParent,
createSlot,
createSlotHost,
} from '../../test-utils';
import {
callEmblaHandler,
callMutationHandler,
createEmblaApiInstance,
createTestSlideNodes,
} from './test-utils';
vi.mock('embla-carousel', () => ({
default: vi.fn().mockImplementation(() => {
return createEmblaApiInstance();
}),
}));
// Get the nth most recently constructed EmblaAPI instance.
const getEmblaApi = (n = 0): MockedObject<EmblaCarouselType> | null => {
const constructions = vi.mocked(EmblaCarousel).mock.results;
const mostRecentResult = constructions[constructions.length - 1 - n] ?? null;
if (mostRecentResult && mostRecentResult.type === 'return') {
return vi.mocked(mostRecentResult.value);
}
return null;
};
const createRoot = (): HTMLElement => {
return document.createElement('div');
};
// @vitest-environment jsdom
describe('CarouselController', () => {
beforeAll(() => {
vi.stubGlobal('MutationObserver', MutationObserverMock);
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should construct', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const carousel = new CarouselController(createRoot(), parent);
expect(carousel).toBeTruthy();
});
it('should construct with slot parent', () => {
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: createTestSlideNodes() });
const carousel = new CarouselController(host, slot);
expect(carousel).toBeTruthy();
});
it('should destroy', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const carousel = new CarouselController(createRoot(), parent);
carousel.destroy();
expect(getEmblaApi()?.destroy).toBeCalled();
});
it('should destroy with slot', () => {
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: createTestSlideNodes() });
const carousel = new CarouselController(host, slot);
carousel.destroy();
expect(getEmblaApi()?.destroy).toBeCalled();
});
it('should get slide by index', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const carousel = new CarouselController(createRoot(), parent);
getEmblaApi()?.slideNodes.mockReturnValue(children);
expect(carousel.getSlide(2)).toBe(children[2]);
});
it('should get slide by index when index is invalid', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const carousel = new CarouselController(createRoot(), parent);
expect(carousel.getSlide(1000)).toBeNull();
});
it('should get selected slide', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const carousel = new CarouselController(createRoot(), parent);
getEmblaApi()?.slideNodes.mockReturnValue(children);
getEmblaApi()?.selectedScrollSnap.mockReturnValue(3);
expect(carousel.getSelectedIndex()).toBe(3);
expect(carousel.getSelectedSlide()).toBe(children[3]);
});
it('should select given slide', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const carousel = new CarouselController(createRoot(), parent);
carousel.selectSlide(4);
expect(getEmblaApi()?.scrollTo).toBeCalledWith(4, false);
});
it('should dispatch settle event', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
new CarouselController(createRoot(), parent);
const settleHandler = vi.fn();
parent.addEventListener('frigate-card:carousel:settle', settleHandler);
callEmblaHandler(getEmblaApi(), 'settle');
expect(settleHandler).toBeCalled();
});
it('should dispatch select event', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
new CarouselController(createRoot(), parent);
const selectHandler = vi.fn();
parent.addEventListener('frigate-card:carousel:select', selectHandler);
getEmblaApi()?.selectedScrollSnap.mockReturnValue(6);
getEmblaApi()?.slideNodes.mockReturnValue(children);
callEmblaHandler(getEmblaApi(), 'select');
expect(selectHandler).toBeCalledWith(
expect.objectContaining({
detail: {
index: 6,
element: children[6],
},
}),
);
});
it('should not dispatch anything with an invalid scroll snap', () => {
const children = createTestSlideNodes();
const parent = createParent({ children: children });
new CarouselController(createRoot(), parent);
const selectHandler = vi.fn();
parent.addEventListener('frigate-card:carousel:select', selectHandler);
getEmblaApi()?.selectedScrollSnap.mockReturnValue(1000);
getEmblaApi()?.slideNodes.mockReturnValue(children);
callEmblaHandler(getEmblaApi(), 'init');
callEmblaHandler(getEmblaApi(), 'select');
callEmblaHandler(getEmblaApi(), 'settle');
expect(selectHandler).not.toBeCalled();
});
it('should honor creation options', () => {
const children = createTestSlideNodes({ n: 1 });
const root = createRoot();
const parent = createParent({ children: children });
const plugins = [AutoMediaLoadedInfo()];
new CarouselController(root, parent, {
direction: 'vertical',
transitionEffect: 'none',
startIndex: 7,
dragFree: true,
loop: true,
dragEnabled: false,
plugins: plugins,
});
expect(EmblaCarousel).toBeCalledWith(
root,
{
slides: children,
axis: 'y',
duration: 20,
startIndex: 7,
dragFree: true,
loop: true,
containScroll: 'trimSnaps',
watchSlides: false,
watchResize: false,
watchDrag: false,
},
plugins,
);
});
it('should include wheel plugin when slides > 1', () => {
const children = createTestSlideNodes();
const root = createRoot();
const parent = createParent({ children: children });
new CarouselController(root, parent);
expect(EmblaCarousel).toBeCalledWith(
root,
expect.anything(),
expect.arrayContaining([
expect.objectContaining({
name: 'wheelGestures',
}),
]),
);
});
it('should recreate carousel when children are added', () => {
const children = createTestSlideNodes();
const root = createRoot();
const parent = createParent({ children: children });
new CarouselController(root, parent);
expect(EmblaCarousel).toBeCalledTimes(1);
const originalEmblaApi = getEmblaApi();
expect(originalEmblaApi).toBeTruthy();
originalEmblaApi?.slideNodes.mockReturnValue(children);
parent.appendChild(document.createElement('div'));
callMutationHandler();
expect(originalEmblaApi?.destroy).toBeCalled();
expect(getEmblaApi()).not.toBe(originalEmblaApi);
expect(EmblaCarousel).toBeCalledTimes(2);
});
it('should not recreate carousel when children have not changed', () => {
const children = createTestSlideNodes();
const root = createRoot();
const parent = createParent({ children: children });
new CarouselController(root, parent);
expect(EmblaCarousel).toBeCalledTimes(1);
const originalEmblaApi = getEmblaApi();
expect(originalEmblaApi).toBeTruthy();
originalEmblaApi?.slideNodes.mockReturnValue(children);
callMutationHandler();
expect(originalEmblaApi?.destroy).not.toBeCalled();
expect(getEmblaApi()).toBe(originalEmblaApi);
expect(EmblaCarousel).toBeCalledTimes(1);
});
it('should recreate carousel when children are added to slot', () => {
const children = createTestSlideNodes();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
new CarouselController(host, slot);
expect(EmblaCarousel).toBeCalledTimes(1);
const originalEmblaApi = getEmblaApi();
expect(originalEmblaApi).toBeTruthy();
originalEmblaApi?.slideNodes.mockReturnValue(children);
host.appendChild(document.createElement('div'));
slot.dispatchEvent(new Event('slotchange'));
expect(originalEmblaApi?.destroy).toBeCalled();
expect(getEmblaApi()).not.toBe(originalEmblaApi);
expect(EmblaCarousel).toBeCalledTimes(2);
});
});
@@ -0,0 +1,219 @@
import { describe, expect, it, vi } from 'vitest';
import { AutoLazyLoad } from '../../../../../src/utils/embla/plugins/auto-lazy-load/auto-lazy-load';
import {
callEmblaHandler,
callVisibilityHandler,
createEmblaApiInstance,
createTestEmblaOptionHandler,
createTestSlideNodes,
} from '../../test-utils';
// @vitest-environment jsdom
describe('AutoLazyLoad', () => {
it('should construct', () => {
const plugin = AutoLazyLoad();
expect(plugin.name).toBe('autoLazyLoad');
});
it('should destroy', () => {
const plugin = AutoLazyLoad({
lazyLoadCallback: vi.fn(),
lazyUnloadCallback: vi.fn(),
});
const emblaApi = createEmblaApiInstance();
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('init', expect.anything());
expect(emblaApi.off).toBeCalledWith('select', expect.anything());
});
it('should do nothing without callbacks', () => {
const plugin = AutoLazyLoad({
// No callbacks provided.
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({ slideNodes: children });
plugin.init(emblaApi, createTestEmblaOptionHandler());
expect(emblaApi.on).not.toBeCalled();
plugin.destroy();
expect(emblaApi.off).not.toBeCalled();
});
it('should lazy load single slide on select', () => {
const lazyLoadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: lazyLoadCallback,
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({ slideNodes: children });
plugin.init(emblaApi, createTestEmblaOptionHandler());
expect(emblaApi.on).toBeCalledWith('init', expect.anything());
expect(emblaApi.on).toBeCalledWith('select', expect.anything());
callEmblaHandler(emblaApi, 'init');
expect(lazyLoadCallback).toBeCalledWith(0, children[0]);
callEmblaHandler(emblaApi, 'select');
// The select call will not re-lazyload the same slide.
expect(lazyLoadCallback).toBeCalledTimes(1);
});
it('should lazy load multiple slides on select', () => {
const lazyLoadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: lazyLoadCallback,
lazyLoadCount: 3,
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
callEmblaHandler(emblaApi, 'select');
for (let i = 3; i <= 8; ++i) {
expect(lazyLoadCallback).toBeCalledWith(i, children[i]);
}
});
it('should lazy unload on select', () => {
const lazyUnloadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: vi.fn(),
lazyLoadCount: 3,
lazyUnloadCallback: lazyUnloadCallback,
lazyUnloadCondition: 'all',
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
selectedScrollSnap: 5,
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
callEmblaHandler(emblaApi, 'select');
// First call will not unload anything, since it was not lazy loaded.
expect(lazyUnloadCallback).not.toBeCalled();
vi.mocked(emblaApi.previousScrollSnap).mockReturnValue(5);
callEmblaHandler(emblaApi, 'select');
// Second call should lazy unload the previous slide.
expect(lazyUnloadCallback).toBeCalledWith(5, children[5]);
});
it('should lazy load on visibility', () => {
vi.spyOn(global.document, 'addEventListener');
const lazyLoadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: lazyLoadCallback,
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
callVisibilityHandler();
expect(lazyLoadCallback).toBeCalledWith(0, children[0]);
});
it('should lazy unload on visibility', () => {
vi.spyOn(global.document, 'addEventListener');
const lazyUnloadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: vi.fn(),
lazyUnloadCallback: lazyUnloadCallback,
lazyUnloadCondition: 'all',
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
callVisibilityHandler();
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
});
callVisibilityHandler();
expect(lazyUnloadCallback).toBeCalledWith(0, children[0]);
});
it('should not lazy unload on visibility without a callback', () => {
vi.spyOn(global.document, 'addEventListener');
const lazyLoadCallback = vi.fn();
const plugin = AutoLazyLoad({
lazyLoadCallback: lazyLoadCallback,
lazyUnloadCondition: 'all',
// No lazy unload callback.
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
callVisibilityHandler();
expect(lazyLoadCallback).toBeCalledTimes(1);
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
});
callVisibilityHandler();
expect(lazyLoadCallback).toBeCalledTimes(1);
});
it('should not lazy load or unload on visibility when no callback provided', () => {
vi.spyOn(global.document, 'addEventListener');
const plugin = AutoLazyLoad({
lazyUnloadCondition: 'all',
// No callbacks provided.
});
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
callVisibilityHandler();
});
});
@@ -0,0 +1,304 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { FrigateCardMediaPlayer } from '../../../../../src/types';
import {
AutoMediaActions,
AutoMediaActionsOptionsType,
AutoMediaActionsType,
} from '../../../../../src/utils/embla/plugins/auto-media-actions/auto-media-actions';
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../../../../src/utils/media-info';
import {
IntersectionObserverMock,
createMediaLoadedInfo,
createParent,
} from '../../../../test-utils';
import {
callEmblaHandler,
callIntersectionHandler,
callVisibilityHandler,
createEmblaApiInstance,
createTestEmblaOptionHandler,
createTestSlideNodes,
} from '../../test-utils';
const getPlayer = (
element: HTMLElement,
selector: string,
): (HTMLElement & FrigateCardMediaPlayer) | null => {
return element.querySelector(selector);
};
const createPlayerSlideNodes = (n = 10): HTMLElement[] => {
const slides = createTestSlideNodes({ n: n });
for (const slide of slides) {
const player = document.createElement('video');
player['play'] = vi.fn();
player['pause'] = vi.fn();
player['mute'] = vi.fn();
player['unmute'] = vi.fn();
player['isMuted'] = vi.fn().mockReturnValue(true);
player['seek'] = vi.fn();
player['getScreenshotURL'] = vi.fn();
player['setControls'] = vi.fn();
player['isPaused'] = vi.fn();
slide.appendChild(player);
}
return slides;
};
const createPlugin = (options?: AutoMediaActionsOptionsType): AutoMediaActionsType => {
return AutoMediaActions({
playerSelector: 'video',
autoPlayCondition: 'all',
autoUnmuteCondition: 'all',
autoPauseCondition: 'all',
autoMuteCondition: 'all',
...options,
});
};
// @vitest-environment jsdom
describe('AutoMediaActions', () => {
beforeAll(() => {
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should construct', () => {
const plugin = AutoMediaActions();
expect(plugin.name).toBe('autoMediaActions');
});
it('should init without any conditions', () => {
const plugin = AutoMediaActions();
const parent = createParent();
const addEventListener = vi.fn();
parent.addEventListener = addEventListener;
const emblaApi = createEmblaApiInstance({ containerNode: parent });
plugin.init(emblaApi, createTestEmblaOptionHandler());
expect(emblaApi.on).toBeCalledWith('destroy', expect.anything());
expect(emblaApi.on).not.toBeCalledWith('select', expect.anything());
expect(addEventListener).not.toBeCalled();
});
it('should destroy', () => {
const plugin = createPlugin();
const parent = createParent();
const removeEventListener = vi.fn();
parent.removeEventListener = removeEventListener;
const emblaApi = createEmblaApiInstance({ containerNode: parent });
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('destroy', expect.anything());
expect(emblaApi.off).toBeCalledWith('select', expect.anything());
expect(removeEventListener).toBeCalled();
});
it('should destroy without any conditions', () => {
const plugin = AutoMediaActions();
const parent = createParent();
const removeEventListener = vi.fn();
parent.removeEventListener = removeEventListener;
const emblaApi = createEmblaApiInstance({ containerNode: parent });
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('destroy', expect.anything());
expect(emblaApi.off).not.toBeCalledWith('select', expect.anything());
expect(removeEventListener).not.toBeCalled();
});
it('should mute and pause on destroy', () => {
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
slideNodes: children,
containerNode: parent,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
callEmblaHandler(emblaApi, 'destroy');
expect(getPlayer(children[5], 'video')?.pause).toBeCalled();
expect(getPlayer(children[5], 'video')?.mute).toBeCalled();
});
it('should play and unmute on media load', () => {
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
slideNodes: children,
containerNode: parent,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
dispatchExistingMediaLoadedInfoAsEvent(parent, createMediaLoadedInfo());
expect(getPlayer(children[5], 'video')?.play).toBeCalled();
expect(getPlayer(children[5], 'video')?.unmute).toBeCalled();
});
it('should not play or unmute on media load when player selecter not provided', () => {
const plugin = createPlugin({ playerSelector: undefined });
const children = createPlayerSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
slideNodes: children,
containerNode: parent,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
dispatchExistingMediaLoadedInfoAsEvent(parent, createMediaLoadedInfo());
expect(getPlayer(children[5], 'video')?.play).not.toBeCalled();
expect(getPlayer(children[5], 'video')?.unmute).not.toBeCalled();
});
it('should pause and mute previous on select', () => {
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
previousScrollSnap: 4,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
callEmblaHandler(emblaApi, 'select');
expect(getPlayer(children[4], 'video')?.pause).toBeCalled();
expect(getPlayer(children[4], 'video')?.mute).toBeCalled();
});
it('should play and unmute on visibility change to visible', () => {
vi.spyOn(global.document, 'addEventListener');
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
callVisibilityHandler();
expect(getPlayer(children[5], 'video')?.play).toBeCalled();
expect(getPlayer(children[5], 'video')?.unmute).toBeCalled();
});
it('should pause and unmute on visibility change to hidden', () => {
vi.spyOn(global.document, 'addEventListener');
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
});
callVisibilityHandler();
for (const child of children) {
expect(getPlayer(child, 'video')?.pause).toBeCalled();
expect(getPlayer(child, 'video')?.mute).toBeCalled();
}
});
describe('should take no action on visibility change without callbacks', () => {
it.each([['visible' as const], ['hidden' as const]])(
'%s',
(visibilityState: 'visible' | 'hidden') => {
vi.spyOn(global.document, 'addEventListener');
const plugin = AutoMediaActions();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
Object.defineProperty(document, 'visibilityState', {
value: visibilityState,
writable: true,
});
callVisibilityHandler();
for (const child of children) {
expect(getPlayer(child, 'video')?.play).not.toBeCalled();
expect(getPlayer(child, 'video')?.pause).not.toBeCalled();
expect(getPlayer(child, 'video')?.mute).not.toBeCalled();
expect(getPlayer(child, 'video')?.unmute).not.toBeCalled();
}
},
);
});
it('should play and unmute on intersection', () => {
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
selectedScrollSnap: 5,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
// Intersection observer always calls handler on creation (and we ignore
// these first calls).
callIntersectionHandler(true);
callIntersectionHandler(true);
expect(getPlayer(children[5], 'video')?.play).toBeCalled();
expect(getPlayer(children[5], 'video')?.unmute).toBeCalled();
});
it('should pause and mute on intersection', () => {
vi.spyOn(global.document, 'addEventListener');
const plugin = createPlugin();
const children = createPlayerSlideNodes();
const emblaApi = createEmblaApiInstance({
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
// Intersection observer always calls handler on creation (and we ignore
// these first calls).
callIntersectionHandler(true);
callIntersectionHandler(false);
for (const child of children) {
expect(getPlayer(child, 'video')?.pause).toBeCalled();
expect(getPlayer(child, 'video')?.mute).toBeCalled();
}
});
});
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import AutoMediaLoadedInfo from '../../../../../src/utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info';
import {
dispatchExistingMediaLoadedInfoAsEvent,
dispatchMediaUnloadedEvent,
} from '../../../../../src/utils/media-info';
import { createMediaLoadedInfo, createParent } from '../../../../test-utils';
import {
callEmblaHandler,
createEmblaApiInstance,
createTestEmblaOptionHandler,
createTestSlideNodes,
} from '../../test-utils';
// @vitest-environment jsdom
describe('AutoMediaLoadedInfo', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should construct', () => {
const plugin = AutoMediaLoadedInfo();
expect(plugin.name).toBe('autoMediaLoadedInfo');
});
it('should destroy', () => {
const plugin = AutoMediaLoadedInfo();
const emblaApi = createEmblaApiInstance();
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('init', expect.anything());
expect(emblaApi.off).toBeCalledWith('select', expect.anything());
});
describe('should correctly propogate media load/unload depending on whether media is currently selected', () => {
it.each([
['loaded' as const, true],
['unloaded' as const, true],
['loaded' as const, false],
['unloaded' as const, false],
])('%s', (type: string, selected: boolean) => {
const plugin = AutoMediaLoadedInfo();
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
containerNode: parent,
slideNodes: children,
selectedScrollSnap: selected ? 5 : 4,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
const mediaLoadedHandler = vi.fn();
parent.addEventListener('frigate-card:media:' + type, mediaLoadedHandler);
if (type === 'loaded') {
dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo());
} else if (type === 'unloaded') {
dispatchMediaUnloadedEvent(children[5]);
}
if (selected) {
expect(mediaLoadedHandler).toBeCalled();
} else {
expect(mediaLoadedHandler).not.toBeCalled();
}
});
});
it('selecting a slide should dispatch a previously saved media loaded info if present', () => {
const plugin = AutoMediaLoadedInfo();
const children = createTestSlideNodes();
const parent = createParent({ children: children });
const emblaApi = createEmblaApiInstance({
containerNode: parent,
slideNodes: children,
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
const mediaLoadedHandler = vi.fn();
parent.addEventListener('frigate-card:media:loaded', mediaLoadedHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[5], createMediaLoadedInfo());
vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(4);
callEmblaHandler(emblaApi, 'select');
expect(mediaLoadedHandler).not.toBeCalled();
vi.mocked(emblaApi.selectedScrollSnap).mockReturnValue(5);
callEmblaHandler(emblaApi, 'select');
expect(mediaLoadedHandler).toBeCalled();
});
});
@@ -0,0 +1,178 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import AutoSize from '../../../../../src/utils/embla/plugins/auto-size/auto-size';
import {
IntersectionObserverMock,
ResizeObserverMock,
createParent,
requestAnimationFrameMock,
} from '../../../../test-utils';
import {
callEmblaHandler,
callIntersectionHandler,
callResizeHandler,
createEmblaApiInstance,
createTestEmblaOptionHandler,
createTestSlideNodes,
} from '../../test-utils';
vi.mock('lodash-es/debounce', () => ({
default: vi.fn((fn) => fn),
}));
// @vitest-environment jsdom
describe('AutoSize', () => {
beforeAll(() => {
// Mock out requestAnimationFrame (used in the reinit controller).
window.requestAnimationFrame = requestAnimationFrameMock;
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should construct', () => {
const plugin = AutoSize();
expect(plugin.name).toBe('autoSize');
});
it('should destroy', () => {
const plugin = AutoSize();
const emblaApi = createEmblaApiInstance();
plugin.init(emblaApi, createTestEmblaOptionHandler());
plugin.destroy();
expect(emblaApi.off).toBeCalledWith('settle', expect.anything());
expect(
vi.mocked(IntersectionObserver).mock.results[0].value.disconnect,
).toBeCalled();
expect(vi.mocked(ResizeObserver).mock.results[0].value.disconnect).toBeCalled();
});
it('should correctly handle intersection', () => {
const plugin = AutoSize();
const emblaApi = createEmblaApiInstance();
plugin.init(emblaApi, createTestEmblaOptionHandler());
// First intersection handler call sets the state only.
callIntersectionHandler(true);
callIntersectionHandler(false);
callIntersectionHandler(false);
callIntersectionHandler(false);
expect(emblaApi.reInit).toBeCalledTimes(1);
});
it('should correctly handle resize', () => {
const plugin = AutoSize();
const parent = createParent();
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
containerNode: parent,
selectedScrollSnap: 0,
slideNodes: children,
slideRegistry: [[0]],
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
children[0].getBoundingClientRect = vi.fn().mockReturnValue({
width: 200,
height: 800,
});
callResizeHandler([{ target: parent, width: 10, height: 20 }]);
callResizeHandler([{ target: parent, width: 10, height: 20 }]);
callResizeHandler([{ target: parent, width: 10, height: 20 }]);
expect(parent.style.maxHeight).toBe('800px');
expect(emblaApi.reInit).toBeCalledTimes(1);
children[0].getBoundingClientRect = vi.fn().mockReturnValue({
width: 200,
height: 600,
});
callResizeHandler([{ target: parent, width: 20, height: 40 }]);
expect(parent.style.maxHeight).toBe('600px');
expect(emblaApi.reInit).toBeCalledTimes(2);
});
it('should set container height on slide settle', () => {
const plugin = AutoSize();
const parent = createParent();
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
containerNode: parent,
selectedScrollSnap: 0,
slideNodes: children,
// 0th scroll snap shows the 0th slide only.
slideRegistry: [[0]],
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
children[0].getBoundingClientRect = vi.fn().mockReturnValue({
width: 200,
height: 800,
});
// select should not do anything, we wait for it to have settled for
// smoothness.
callEmblaHandler(emblaApi, 'select');
expect(parent.style.maxHeight).toBeFalsy();
callEmblaHandler(emblaApi, 'settle');
expect(parent.style.maxHeight).toBe('800px');
});
it('should not set container height on horizontal carousel', () => {
const plugin = AutoSize();
const parent = createParent();
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
containerNode: parent,
selectedScrollSnap: 0,
slideNodes: children,
axis: 'y',
// 0th scroll snap shows the 0th slide only.
slideRegistry: [[0]],
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
children[0].getBoundingClientRect = vi.fn().mockReturnValue({
width: 200,
height: 800,
});
callEmblaHandler(emblaApi, 'settle');
expect(parent.style.maxHeight).toBeFalsy();
});
it('should not set container height when slide dimensions are invalid', () => {
const plugin = AutoSize();
const parent = createParent();
const children = createTestSlideNodes();
const emblaApi = createEmblaApiInstance({
containerNode: parent,
selectedScrollSnap: 0,
slideNodes: children,
axis: 'x',
// 0th scroll snap shows the 0th slide only.
slideRegistry: [[0]],
});
plugin.init(emblaApi, createTestEmblaOptionHandler());
children[0].getBoundingClientRect = vi.fn().mockReturnValue(NaN);
callEmblaHandler(emblaApi, 'settle');
children[0].getBoundingClientRect = vi.fn().mockReturnValue(0);
callEmblaHandler(emblaApi, 'settle');
expect(parent.style.maxHeight).toBeFalsy();
});
});
@@ -0,0 +1,59 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { EmblaReInitController } from '../../../src/utils/embla/reinit-controller';
import { requestAnimationFrameMock } from '../../test-utils';
import { callEmblaHandler, createEmblaApiInstance } from './test-utils';
vi.mock('lodash-es/debounce', () => ({
default: vi.fn((fn) => fn),
}));
// @vitest-environment jsdom
describe('EmblaReInitController', () => {
beforeAll(() => {
window.requestAnimationFrame = requestAnimationFrameMock;
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should construct', () => {
const emblaApi = createEmblaApiInstance();
new EmblaReInitController(emblaApi);
expect(emblaApi.on).toBeCalledWith('scroll', expect.anything());
expect(emblaApi.on).toBeCalledWith('settle', expect.anything());
expect(emblaApi.on).toBeCalledWith('destroy', expect.anything());
});
it('should destroy', () => {
const emblaApi = createEmblaApiInstance();
const controller = new EmblaReInitController(emblaApi);
controller.destroy();
expect(emblaApi.off).toBeCalledWith('scroll', expect.anything());
expect(emblaApi.off).toBeCalledWith('settle', expect.anything());
expect(emblaApi.off).toBeCalledWith('destroy', expect.anything());
});
it('should reinit when not scrolling', () => {
const emblaApi = createEmblaApiInstance();
const controller = new EmblaReInitController(emblaApi);
controller.reinit();
expect(emblaApi.reInit).toBeCalled();
});
it('should carefully reinit when scrolling', () => {
const emblaApi = createEmblaApiInstance();
const controller = new EmblaReInitController(emblaApi);
callEmblaHandler(emblaApi, 'scroll');
controller.reinit();
expect(emblaApi.reInit).not.toBeCalled();
callEmblaHandler(emblaApi, 'settle');
expect(emblaApi.reInit).toBeCalled();
});
});
+132
View File
@@ -0,0 +1,132 @@
import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
import { EngineType } from 'embla-carousel/components/Engine';
import { LooseOptionsType } from 'embla-carousel/components/Options';
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
import merge from 'lodash-es/merge';
import { vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
export const createTestEmblaOptionHandler = (): OptionsHandlerType => ({
mergeOptions: <TypeA extends LooseOptionsType, TypeB extends LooseOptionsType>(
optionsA: TypeA,
optionsB?: TypeB,
): TypeA => {
return merge({}, optionsA, optionsB);
},
optionsAtMedia: <Type extends LooseOptionsType>(options: Type): Type => {
return options;
},
optionsMediaQueries: (_optionsList: LooseOptionsType[]): MediaQueryList[] => [],
});
export const callEmblaHandler = (
emblaApi: EmblaCarouselType | null,
eventName: EmblaEventType,
): void => {
if (!emblaApi) {
return;
}
const mock = vi.mocked(emblaApi.on).mock;
for (const [evt, cb] of mock.calls) {
if (evt === eventName) {
cb(emblaApi, evt);
}
}
};
export const callVisibilityHandler = (): void => {
const mock = vi.mocked(global.document.addEventListener).mock;
for (const [evt, cb] of mock.calls) {
if (evt === 'visibilitychange' && typeof cb === 'function') {
cb(new Event('foo'));
}
}
};
export const callIntersectionHandler = (intersecting = true, n = 0): void => {
const mockResult = vi.mocked(IntersectionObserver).mock.results[n];
if (mockResult.type !== 'return') {
return;
}
const observer = mockResult.value;
vi.mocked(IntersectionObserver).mock.calls[n][0](
// Note this is a very incomplete / invalid IntersectionObserverEntry that
// just provides the bare basics current implementation uses.
intersecting ? [{ isIntersecting: true } as IntersectionObserverEntry] : [],
observer,
);
};
export const callMutationHandler = (n = 0): void => {
const mockResult = vi.mocked(MutationObserver).mock.results[n];
if (mockResult.type !== 'return') {
return;
}
const observer = mockResult.value;
vi.mocked(MutationObserver).mock.calls[n][0](
// Note this is a very incomplete / invalid IntersectionObserverEntry that
// just provides the bare basics current implementation uses.
[],
observer,
);
};
export const callResizeHandler = (
entries: {
target: HTMLElement;
width: number;
height: number;
}[],
n = 0,
): void => {
const mockResult = vi.mocked(ResizeObserver).mock.results[n];
if (mockResult.type !== 'return') {
return;
}
const observer = mockResult.value;
vi.mocked(ResizeObserver).mock.calls[n][0](
// Note this is a very incomplete / invalid ResizeObserverEntry that
// just provides the bare basics current implementation uses.
entries.map(
(entry) =>
({
target: entry.target,
contentRect: {
height: entry.height,
width: entry.width,
},
} as unknown as ResizeObserverEntry),
),
observer,
);
};
export const createEmblaApiInstance = (options?: {
slideNodes?: HTMLElement[];
selectedScrollSnap?: number;
previousScrollSnap?: number;
containerNode?: HTMLElement;
axis?: 'x' | 'y';
slideRegistry?: number[][];
}): EmblaCarouselType => {
const emblaApi = mock<EmblaCarouselType>();
emblaApi.slideNodes.mockReturnValue(options?.slideNodes ?? createTestSlideNodes());
emblaApi.selectedScrollSnap.mockReturnValue(options?.selectedScrollSnap ?? 0);
emblaApi.previousScrollSnap.mockReturnValue(options?.previousScrollSnap ?? 0);
emblaApi.containerNode.mockReturnValue(
options?.containerNode ?? document.createElement('div'),
);
emblaApi.internalEngine.mockReturnValue({
options: { axis: options?.axis ?? 'x' },
...(options?.slideRegistry && { slideRegistry: options.slideRegistry }),
} as EngineType);
return emblaApi;
};
export const createTestSlideNodes = (options?: {
n?: number;
}): HTMLElement[] => {
return [...Array(options?.n ?? 10).keys()].map((_) =>
document.createElement('div'),
);
};
+526
View File
@@ -0,0 +1,526 @@
import Masonry from 'masonry-layout';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { MediaLoadedInfo } from '../../src/types';
import {
MediaGridConstructorOptions,
MediaGridController,
} from '../../src/utils/media-grid-controller';
import { dispatchExistingMediaLoadedInfoAsEvent } from '../../src/utils/media-info';
import {
MutationObserverMock,
ResizeObserverMock,
createSlot,
createSlotHost,
} from '../test-utils';
vi.mock('lodash-es/throttle', () => ({
default: vi.fn((fn) => fn),
}));
const masonry = mock<Masonry>();
vi.mock('masonry-layout', () => ({
default: vi.fn().mockImplementation(() => {
return masonry;
}),
}));
const createChildren = (childIDs?: string[], idAttribute?: string): HTMLElement[] => {
const children: HTMLElement[] = [];
for (let i = 0; i < (childIDs?.length ?? 3); ++i) {
const child = document.createElement('div');
if (childIDs) {
child.setAttribute(idAttribute ?? 'grid-id', childIDs[i]);
}
children.push(child);
}
return children;
};
const setElementWidth = (element: HTMLElement, width: number): void => {
element.getBoundingClientRect = vi.fn().mockReturnValue({
width: width,
});
};
const createParent = (options?: {
children?: HTMLElement[];
width?: number;
}): HTMLElement => {
const host = document.createElement('div');
if (options?.children) {
host.append(...options.children);
}
// Default Lovelace card width is 492.
setElementWidth(host, options?.width ?? 492);
return host;
};
const createController = (host: HTMLElement, options?: MediaGridConstructorOptions) => {
return new MediaGridController(host, options);
};
const triggerMutationObserver = (hostOrCell: 'cell' | 'host'): void => {
const mutationObserverTrigger = vi.mocked(global.MutationObserver).mock.calls[
hostOrCell === 'host' ? 0 : 1
][0];
mutationObserverTrigger([], mock<MutationObserver>());
};
const triggerResizeObserver = (hostOrCell: 'cell' | 'host'): void => {
const resizeObserverTrigger = vi.mocked(global.ResizeObserver).mock.calls[
hostOrCell === 'host' ? 0 : 1
][0];
resizeObserverTrigger([], mock<ResizeObserver>());
};
// @vitest-environment jsdom
describe('MediaGridController', () => {
const mediaLoadedInfo: MediaLoadedInfo = {
width: 10,
height: 20,
};
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('MutationObserver', MutationObserverMock);
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
});
it('should be constructable', () => {
const controller = createController(createParent());
expect(controller).toBeTruthy();
expect(masonry.layout).toBeCalled();
});
it('should set grid contents correctly from regular elements', () => {
const children = createChildren();
const parent = createParent({ children: children });
const controller = createController(parent);
expect(controller.getGridContents()).toEqual(
new Map([
['0', children[0]],
['1', children[1]],
['2', children[2]],
]),
);
expect(controller.getGridSize()).toBe(3);
expect(masonry.layout).toBeCalled();
});
it('should set grid contents correctly from slotted elements', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host);
expect(controller.getGridContents()).toEqual(
new Map([
['0', children[0]],
['1', children[1]],
['2', children[2]],
]),
);
expect(controller.getGridSize()).toBe(3);
});
it('should select element', () => {
const children = createChildren();
const slot = createSlot();
createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
// All children should be unselected.
expect(controller.getSelected()).toBeNull();
for (const child of children) {
expect(child.getAttribute('selected')).toBeNull();
expect(child.getAttribute('unselected')).toEqual('');
}
controller.selectCell('0');
expect(controller.getSelected()).toBe('0');
// 1st child should now be selected.
expect(children[0].getAttribute('selected')).toEqual('');
expect(children[0].getAttribute('unselected')).toBeNull();
// 2nd and 3rd should be unselected.
for (const child of children.slice(1)) {
expect(child.getAttribute('selected')).toBeNull();
expect(child.getAttribute('unselected')).toEqual('');
}
});
it('should re-select element', () => {
const children = createChildren();
const slot = createSlot();
createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
// All children should be unselected.
expect(controller.getSelected()).toBeNull();
controller.selectCell('0');
expect(controller.getSelected()).toBe('0');
controller.selectCell('0');
expect(controller.getSelected()).toBe('0');
});
it('should dispatch media loaded info on selection', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
const mediaLoadedInfoHandler = vi.fn();
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
controller.selectCell('0');
expect(mediaLoadedInfoHandler).toBeCalledWith(
expect.objectContaining({
detail: mediaLoadedInfo,
}),
);
});
it('should dispatch media loaded info when cell is selected', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
controller.selectCell('0');
const mediaLoadedInfoHandler = vi.fn();
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
expect(mediaLoadedInfoHandler).toBeCalledWith(
expect.objectContaining({
detail: mediaLoadedInfo,
}),
);
});
it('should not dispatch media loaded info when cell is not selected', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host);
controller.selectCell('1');
const mediaLoadedInfoHandler = vi.fn();
host.addEventListener('frigate-card:media:loaded', mediaLoadedInfoHandler);
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
// Another element is selected, so the event should not have propagated.
expect(mediaLoadedInfoHandler).not.toBeCalled();
});
it('should unselect', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host);
const unselectedHandler = vi.fn();
const unloadMediaHandler = vi.fn();
host.addEventListener('frigate-card:media-grid:unselected', unselectedHandler);
host.addEventListener('frigate-card:media:unloaded', unloadMediaHandler);
controller.selectCell('0');
expect(controller.getSelected()).toBe('0');
// Unselect all elements.
controller.unselectAll();
// Expect selected to now be null.
expect(controller.getSelected()).toBeNull();
// Expect styles to have been updated.
for (const child of children) {
expect(child.getAttribute('selected')).toBeNull();
expect(child.getAttribute('unselected')).toEqual('');
}
// Expect handlers to have been called.
expect(unselectedHandler).toBeCalledTimes(1);
expect(unloadMediaHandler).toBeCalledTimes(1);
// Unselecting a second time should do nothing.
controller.unselectAll();
expect(unselectedHandler).toBeCalledTimes(1);
expect(unloadMediaHandler).toBeCalledTimes(1);
});
it('should select in constructor', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host, { selected: '2' });
expect(controller.getSelected()).toBe('2');
});
it('should respect grid attribute option', () => {
const children = createChildren(['one', 'two', 'three'], 'test-id');
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(host, { idAttribute: 'test-id' });
expect(controller.getGridContents()).toEqual(
new Map([
['one', children[0]],
['two', children[1]],
['three', children[2]],
]),
);
});
it('should destroy with regular elements', () => {
const children = createChildren();
const parent = createParent({ children: children });
const controller = createController(parent);
expect(controller.getGridSize()).toBe(3);
controller.destroy();
expect(controller.getGridSize()).toBe(0);
});
it('should destroy with slotted elements', () => {
const children = createChildren();
const slot = createSlot();
createSlotHost({ slot: slot, children: children });
const controller = createController(slot);
expect(controller.getGridSize()).toBe(3);
controller.destroy();
expect(controller.getGridSize()).toBe(0);
});
it('should replace children when they change', () => {
const children = createChildren();
const parent = createParent({ children: children });
const controller = createController(parent, { selected: '1' });
dispatchExistingMediaLoadedInfoAsEvent(children[0], mediaLoadedInfo);
expect(controller.getSelected()).toBe('1');
expect(controller.getGridSize()).toBe(3);
children.forEach((child) => parent.removeChild(child));
const newChildren = createChildren(['one', 'two', 'three']);
newChildren.forEach((child) => parent.appendChild(child));
triggerMutationObserver('host');
expect(controller.getGridContents()).toEqual(
new Map([
['one', newChildren[0]],
['two', newChildren[1]],
['three', newChildren[2]],
]),
);
expect(controller.getSelected()).toBeNull();
});
it('should re-calculate children when id attribute changes', () => {
const children = createChildren(['one', 'two', 'three'], 'test-id');
const parent = createParent({ children: children });
const controller = createController(parent, {
selected: 'one',
idAttribute: 'test-id',
});
expect(controller.getSelected()).toBe('one');
expect(controller.getGridSize()).toBe(3);
children[0].setAttribute('test-id', 'alpha');
children[1].setAttribute('test-id', 'beta');
children[2].setAttribute('test-id', 'gamma');
triggerMutationObserver('cell');
expect(controller.getGridContents()).toEqual(
new Map([
['alpha', children[0]],
['beta', children[1]],
['gamma', children[2]],
]),
);
expect(controller.getSelected()).toBeNull();
});
it('should replace children of a slot when they change', () => {
const children = createChildren();
const slot = createSlot();
const host = createSlotHost({ slot: slot, children: children });
const controller = createController(slot, { selected: '1' });
expect(controller.getSelected()).toBe('1');
expect(controller.getGridSize()).toBe(3);
children.forEach((child) => host.removeChild(child));
const newChildren = createChildren(['one', 'two', 'three']);
newChildren.forEach((child) => host.append(child));
slot.dispatchEvent(new Event('slotchange'));
expect(controller.getGridContents()).toEqual(
new Map([
['one', newChildren[0]],
['two', newChildren[1]],
['three', newChildren[2]],
]),
);
expect(controller.getSelected()).toBeNull();
});
it('should construct masonry correctly', () => {
const children = createChildren();
const parent = createParent({ children: children });
createController(parent);
expect(Masonry).toBeCalledWith(
parent,
expect.objectContaining({
initLayout: false,
percentPosition: true,
transitionDuration: '0.2s',
}),
);
});
it('should set default column size correctly', () => {
const parent = createParent({ children: createChildren() });
createController(parent);
expect(Masonry).toBeCalledWith(
parent,
expect.objectContaining({
columnWidth: 246,
}),
);
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
'246px',
);
});
it('should respect exact columns', () => {
const parent = createParent({ children: createChildren(), width: 2000 });
const controller = createController(parent);
controller.setDisplayConfig({ mode: 'grid', grid_columns: 2 });
// Will have been called once on construction, and then again when the
// number of columns changes.
expect(Masonry).toBeCalledTimes(2);
expect(Masonry).toBeCalledWith(
parent,
expect.objectContaining({
columnWidth: 1000,
}),
);
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
'1000px',
);
});
it('should respect selected width factor', () => {
const parent = createParent({ children: createChildren(), width: 2000 });
const controller = createController(parent);
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
expect(
parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
).toBe('3');
// Setting the same config again should do nothing.
controller.setDisplayConfig({ mode: 'grid', grid_selected_width_factor: 3 });
expect(
parent.style.getPropertyValue('--frigate-card-grid-selected-width-factor'),
).toBe('3');
});
it('should select cell when interacted with', () => {
const children = createChildren();
const parent = createParent({ children: children, width: 2000 });
const controller = createController(parent);
expect(controller.getSelected()).toBeNull();
const clickHandler = vi.fn();
parent.addEventListener('click', clickHandler);
children[1].click();
// Click will not be allowed through.
expect(clickHandler).not.toBeCalled();
expect(controller.getSelected()).toBe('1');
});
it('should ignore interaction events on already selected cell', () => {
const children = createChildren();
const parent = createParent({ children: children, width: 2000 });
const controller = createController(parent);
controller.selectCell('1');
const clickHandler = vi.fn();
parent.addEventListener('click', clickHandler);
children[1].click();
// Click will be allowed through.
expect(clickHandler).toBeCalled();
expect(controller.getSelected()).toBe('1');
});
it('should re-layout when child size changes', () => {
createController(createParent({ children: createChildren() }));
vi.mocked(masonry.layout)?.mockClear();
triggerResizeObserver('cell');
expect(masonry.layout).toBeCalled();
});
it('should re-create masonry when host size changes', () => {
const children = createChildren();
const parent = createParent({ children: children });
createController(parent);
expect(Masonry).toBeCalledWith(
parent,
expect.objectContaining({
columnWidth: 246,
}),
);
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
'246px',
);
// Clear mock state.
vi.mocked(Masonry).mockClear();
vi.mocked(masonry.layout)?.mockClear();
// Resize the host.
setElementWidth(parent, 2000);
triggerResizeObserver('host');
// Masonry should be reconstructed, styles set and layout called.
expect(Masonry).toBeCalledWith(
parent,
expect.objectContaining({
columnWidth: 667,
}),
);
expect(parent.style.getPropertyValue('--frigate-card-grid-column-size')).toBe(
'667px',
);
expect(masonry.layout).toBeCalled();
// Clear mock state.
vi.mocked(Masonry).mockClear();
vi.mocked(masonry.layout)?.mockClear();
// Triger with the same sizes.
triggerResizeObserver('host');
expect(Masonry).not.toBeCalled();
expect(masonry.layout).not.toBeCalled();
});
});
+426
View File
@@ -0,0 +1,426 @@
import add from 'date-fns/add';
import sub from 'date-fns/sub';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { CameraConfigs } from '../../src/camera-manager/types';
import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
executeMediaQueryForView,
findBestMediaIndex,
} from '../../src/utils/media-to-view';
import { ViewMedia } from '../../src/view/media';
import { EventMediaQueries } from '../../src/view/media-queries';
import {
createCameraManager,
createHASS,
createPerformanceConfig,
createView,
TestViewMedia,
} from '../test-utils';
vi.mock('../../src/camera-manager/manager.js');
const createElementListenForView = (): {
element: HTMLElement;
viewHandler: Mock<any, any>;
messageHandler: Mock<any, any>;
} => {
const element = document.createElement('div');
const viewHandler = vi.fn();
element.addEventListener('frigate-card:view:change', viewHandler);
const messageHandler = vi.fn();
element.addEventListener('frigate-card:message', messageHandler);
return {
element: element,
viewHandler: viewHandler,
messageHandler: messageHandler,
};
};
const getMediaFromHandlerCall = (handler: Mock<any, any>): ViewMedia[] | null => {
return handler.mock.calls[0][0].detail.queryResults.getResults();
};
const generateViewMedia = (
index: number,
base: Date,
durationSeconds: number,
cameraID?: string,
): ViewMedia => {
return new TestViewMedia({
id: `id-${index}`,
startTime: base,
endTime: add(base, { seconds: durationSeconds }),
...(cameraID && { cameraID: cameraID }),
});
};
// @vitest-environment jsdom
describe('changeViewToRecentEventsForCameraAndDependents', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should do nothing without camera config for selected camera', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should do nothing without camera configs for all cameras', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
{
allCameras: true,
},
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should do nothing unless queries can be created', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.generateDefaultEventQueries).mockReturnValue(null);
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
{
mediaType: 'clips',
},
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should dispatch new view on success', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
const mediaArray = [new ViewMedia('clip', 'camera')];
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
{
targetView: 'clips',
select: 'latest',
},
);
expect(elementHandler.viewHandler).toBeCalled();
expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray);
});
it('should dispatch error message on fail', async () => {
vi.spyOn(global.console, 'warn').mockImplementation(() => true);
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.executeMediaQueries).mockRejectedValue(new Error());
await changeViewToRecentEventsForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
);
expect(elementHandler.viewHandler).not.toBeCalled();
expect(elementHandler.messageHandler).toBeCalled();
});
it('should respect media chunk size', async () => {
const cameraManager = createCameraManager();
await changeViewToRecentEventsForCameraAndDependents(
createElementListenForView().element,
createHASS(),
cameraManager,
{
performance: createPerformanceConfig({
features: {
media_chunk_size: 1000,
},
}),
},
createView(),
);
expect(cameraManager.generateDefaultEventQueries).toBeCalledWith(
expect.anything(),
expect.objectContaining({
limit: 1000,
}),
);
});
describe('should respect request for media type', () => {
it.each([
['snapshots' as const, 'hasSnapshot'],
['clips' as const, 'hasClip'],
])('%s', async (mediaType, queryParameter) => {
const cameraManager = createCameraManager();
await changeViewToRecentEventsForCameraAndDependents(
createElementListenForView().element,
createHASS(),
cameraManager,
{},
createView(),
{
mediaType: mediaType,
},
);
expect(cameraManager.generateDefaultEventQueries).toBeCalledWith(
expect.anything(),
expect.objectContaining({
[queryParameter]: true,
}),
);
});
});
});
// @vitest-environment jsdom
describe('executeMediaQueryForView', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should not execute empty queries', async () => {
const elementHandler = createElementListenForView();
const cameraConfigs: CameraConfigs = new Map();
const cameraManager = createCameraManager({ configs: cameraConfigs });
expect(
await executeMediaQueryForView(
elementHandler.element,
createHASS(),
cameraManager,
createView(),
new EventMediaQueries(),
),
).toBeNull();
});
it('should select time-based result', async () => {
const elementHandler = createElementListenForView();
const cameraConfigs: CameraConfigs = new Map();
const cameraManager = createCameraManager({ configs: cameraConfigs });
const now = new Date();
const mediaArray = [
generateViewMedia(0, now, 60),
generateViewMedia(1, now, 120),
generateViewMedia(2, now, 10),
];
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
const view = await executeMediaQueryForView(
elementHandler.element,
createHASS(),
cameraManager,
createView(),
new EventMediaQueries(
cameraManager.generateDefaultEventQueries('camera') ?? undefined,
),
{
select: 'time',
targetTime: add(now, { seconds: 30 }),
},
);
// Should select the longest event.
expect(view?.queryResults?.getSelectedIndex()).toBe(1);
expect(view?.queryResults?.getResults()).toBe(mediaArray);
});
it('should select nothing when time-based selection does not match', async () => {
const elementHandler = createElementListenForView();
const cameraConfigs: CameraConfigs = new Map();
const cameraManager = createCameraManager({ configs: cameraConfigs });
const now = new Date();
const mediaArray = [
generateViewMedia(0, now, 60),
generateViewMedia(1, now, 120),
generateViewMedia(2, now, 10),
];
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
const view = await executeMediaQueryForView(
elementHandler.element,
createHASS(),
cameraManager,
createView(),
new EventMediaQueries(
cameraManager.generateDefaultEventQueries('camera') ?? undefined,
),
{
select: 'time',
targetTime: sub(now, { seconds: 30 }),
},
);
// Should leave selection untouched (last item will remain selected).
expect(view?.queryResults?.getSelectedIndex()).toBe(2);
expect(view?.queryResults?.getResults()).toBe(mediaArray);
});
});
// @vitest-environment jsdom
describe('changeViewToRecentRecordingForCameraAndDependents', () => {
beforeEach(() => {
vi.resetAllMocks();
});
it('should do nothing without camera config for selected camera', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentRecordingForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should do nothing without camera configs for all cameras', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager({ configs: new Map() });
await changeViewToRecentRecordingForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
{
allCameras: true,
},
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should do nothing unless queries can be created', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.generateDefaultRecordingQueries).mockReturnValue(null);
await changeViewToRecentRecordingForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
);
expect(elementHandler.viewHandler).not.toBeCalled();
});
it('should dispatch new view on success', async () => {
const elementHandler = createElementListenForView();
const cameraManager = createCameraManager();
const mediaArray = [new ViewMedia('recording', 'camera')];
vi.mocked(cameraManager.executeMediaQueries).mockResolvedValue(mediaArray);
await changeViewToRecentRecordingForCameraAndDependents(
elementHandler.element,
createHASS(),
cameraManager,
{},
createView(),
{
targetView: 'recordings',
select: 'latest',
},
);
expect(elementHandler.viewHandler).toBeCalled();
expect(getMediaFromHandlerCall(elementHandler.viewHandler)).toBe(mediaArray);
});
it('should respect media chunk size', async () => {
const cameraManager = createCameraManager();
await changeViewToRecentRecordingForCameraAndDependents(
createElementListenForView().element,
createHASS(),
cameraManager,
{
performance: createPerformanceConfig({
features: {
media_chunk_size: 1000,
},
}),
},
createView(),
);
expect(cameraManager.generateDefaultRecordingQueries).toBeCalledWith(
expect.anything(),
expect.objectContaining({
limit: 1000,
}),
);
});
});
// @vitest-environment jsdom
describe('findBestMediaIndex', () => {
it('should find best media index', async () => {
const now = new Date();
const mediaArray = [
generateViewMedia(0, now, 60),
generateViewMedia(1, now, 120),
generateViewMedia(2, now, 10),
];
expect(findBestMediaIndex(mediaArray, add(now, { seconds: 30 }))).toBe(1);
});
it('should find best media index respecting favored cameraID', async () => {
const now = new Date();
const mediaArray = [
generateViewMedia(0, now, 60, 'less-good-camera'),
generateViewMedia(1, now, 120, 'less-good-camera'),
generateViewMedia(2, now, 10, 'favored-camera'),
generateViewMedia(3, now, 35, 'favored-camera'),
generateViewMedia(4, now, 40, 'favored-camera'),
generateViewMedia(5, now, 30, 'favored-camera'),
generateViewMedia(6, now, 300, 'less-good-camera'),
];
expect(
findBestMediaIndex(mediaArray, add(now, { seconds: 30 }), 'favored-camera'),
).toBe(4);
});
});
+17
View File
@@ -3,6 +3,7 @@ import { mock } from 'vitest-mock-extended';
import { FrigateCardMediaPlayer } from '../../src/types.js'; import { FrigateCardMediaPlayer } from '../../src/types.js';
import { import {
FrigateCardHTMLVideoElement, FrigateCardHTMLVideoElement,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
playMediaMutingIfNecessary, playMediaMutingIfNecessary,
setControlsOnVideo, setControlsOnVideo,
@@ -107,4 +108,20 @@ describe('playMediaMutingIfNecessary', () => {
expect(player.isMuted).toBeCalled(); expect(player.isMuted).toBeCalled();
expect(player.mute).toBeCalled(); expect(player.mute).toBeCalled();
}); });
it('should ignore calls without a video', async () => {
const player = mock<FrigateCardMediaPlayer>();
player.isMuted.mockReturnValue(false);
await playMediaMutingIfNecessary(player);
expect(player.isMuted).not.toBeCalled();
expect(player.mute).not.toBeCalled();
});
});
describe('constants', () => {
it('MEDIA_LOAD_CONTROLS_HIDE_SECONDS', () => {
expect(MEDIA_LOAD_CONTROLS_HIDE_SECONDS).toBe(2);
});
}); });
+66 -3
View File
@@ -1,4 +1,5 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import isEqual from 'lodash-es/isEqual';
import screenfull from 'screenfull'; import screenfull from 'screenfull';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
@@ -9,6 +10,7 @@ import {
FrigateCardMediaPlayer, FrigateCardMediaPlayer,
MediaLoadedInfo, MediaLoadedInfo,
MenuButton, MenuButton,
ViewDisplayMode,
} from '../../src/types'; } from '../../src/types';
import { createFrigateCardCustomAction } from '../../src/utils/action'; import { createFrigateCardCustomAction } from '../../src/utils/action';
import { MenuButtonController } from '../../src/utils/menu-controller'; import { MenuButtonController } from '../../src/utils/menu-controller';
@@ -67,6 +69,7 @@ const calculateButtons = (
); );
}; };
// @vitest-environment jsdom
describe('MenuButtonController', () => { describe('MenuButtonController', () => {
let controller: MenuButtonController; let controller: MenuButtonController;
const dynamicButton: MenuButton = { const dynamicButton: MenuButton = {
@@ -158,6 +161,26 @@ describe('MenuButtonController', () => {
}); });
}); });
it('should not have a cameras menu without a visible camera', () => {
const cameraManager = createCameraManager({
configs: new Map([
['camera-1', createCameraConfig()],
['camera-2', createCameraConfig()],
]),
});
vi.mocked(cameraManager.getStore()).getVisibleCameras.mockReturnValue(new Map());
const buttons = calculateButtons(controller, { cameraManager: cameraManager });
expect(buttons).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
title: 'Cameras',
}),
]),
);
});
it('should have substream button with single dependency', () => { it('should have substream button with single dependency', () => {
const cameraManager = createCameraManager({ const cameraManager = createCameraManager({
configs: new Map([ configs: new Map([
@@ -612,7 +635,10 @@ describe('MenuButtonController', () => {
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
const view = createView({ const view = createView({
queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0), queryResults: new MediaQueriesResults({
results: [new ViewMedia('clip', 'camera-1')],
selectedIndex: 0,
}),
}); });
mock<CameraManager>(cameraManager).getMediaCapabilities.mockReturnValue( mock<CameraManager>(cameraManager).getMediaCapabilities.mockReturnValue(
createMediaCapabilities({ canDownload: true }), createMediaCapabilities({ canDownload: true }),
@@ -640,7 +666,10 @@ describe('MenuButtonController', () => {
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
const view = createView({ const view = createView({
queryResults: new MediaQueriesResults([new ViewMedia('clip', 'camera-1')], 0), queryResults: new MediaQueriesResults({
results: [new ViewMedia('clip', 'camera-1')],
selectedIndex: 0,
}),
}); });
mock<CameraManager>(cameraManager).getMediaCapabilities.mockReturnValue( mock<CameraManager>(cameraManager).getMediaCapabilities.mockReturnValue(
createMediaCapabilities({ canDownload: true }), createMediaCapabilities({ canDownload: true }),
@@ -1084,13 +1113,47 @@ describe('MenuButtonController', () => {
}); });
}); });
describe('should have grid button when display mode is', () => {
it.each([['single' as const], ['grid' as const]])(
'%s',
(displayMode: ViewDisplayMode) => {
const view = createView({ view: 'live', displayMode: displayMode });
expect(calculateButtons(controller, { view: view })).toContainEqual({
icon: displayMode === 'single' ? 'mdi:grid' : 'mdi:grid-off',
enabled: true,
priority: 50,
type: 'custom:frigate-card-menu-icon',
title:
displayMode === 'grid'
? 'Show single media viewer'
: 'Show media viewer for each camera in a grid',
tap_action: {
action: 'fire-dom-event',
frigate_card_action: 'display_mode_select',
display_mode: displayMode === 'single' ? 'grid' : 'single',
},
});
},
);
});
it('should handle dynamic buttons', () => { it('should handle dynamic buttons', () => {
const button: MenuButton = { const button: MenuButton = {
...dynamicButton, ...dynamicButton,
style: {}, style: {},
}; };
controller.addDynamicMenuButton(button); controller.addDynamicMenuButton(button);
expect(calculateButtons(controller)).toContainEqual(button); expect(
calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton))
.length,
).toBe(1);
// Adding it again will have no effect.
controller.addDynamicMenuButton(button);
expect(
calculateButtons(controller).filter((menuButton) => isEqual(button, menuButton))
.length,
).toBe(1);
controller.removeDynamicMenuButton(button); controller.removeDynamicMenuButton(button);
expect(calculateButtons(controller)).not.toContainEqual(button); expect(calculateButtons(controller)).not.toContainEqual(button);
+12 -14
View File
@@ -62,32 +62,30 @@ describe('generateScreenshotTitle', () => {
}); });
it('should get title for media viewer view with id', () => { it('should get title for media viewer view with id', () => {
const media = new TestViewMedia( const media = new TestViewMedia({
'id1', id: 'id1',
new Date('2023-06-16T18:52'), startTime: new Date('2023-06-16T18:52'),
'clip', cameraID: 'camera-1',
'camera-1', });
);
const view = createView({ const view = createView({
view: 'media', view: 'media',
camera: 'camera-1', camera: 'camera-1',
queryResults: new MediaQueriesResults([media], 0), queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }),
}); });
expect(generateScreenshotTitle(view)).toBe('media-camera-1-id1.jpg'); expect(generateScreenshotTitle(view)).toBe('media-camera-1-id1.jpg');
}); });
it('should get title for media viewer view without id', () => { it('should get title for media viewer view without id', () => {
const media = new TestViewMedia( const media = new TestViewMedia({
null, id: null,
new Date('2023-06-16T18:52'), startTime: new Date('2023-06-16T18:52'),
'clip', cameraID: 'camera-1',
'camera-1', });
);
const view = createView({ const view = createView({
view: 'media', view: 'media',
camera: 'camera-1', camera: 'camera-1',
queryResults: new MediaQueriesResults([media], 0), queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }),
}); });
expect(generateScreenshotTitle(view)).toBe('media-camera-1.jpg'); expect(generateScreenshotTitle(view)).toBe('media-camera-1.jpg');
+18 -4
View File
@@ -52,6 +52,20 @@ describe('createViewWithoutSubstream', () => {
const newView = createViewWithoutSubstream(view); const newView = createViewWithoutSubstream(view);
expect(newView?.context?.live?.overrides).toEqual(new Map()); expect(newView?.context?.live?.overrides).toEqual(new Map());
}); });
it('should create view with overrides untouched', () => {
const view = new View({
view: 'live',
camera: 'camera-1',
context: {
live: {
overrides: new Map([['camera-2', 'camera-3']]),
},
},
});
const newView = createViewWithoutSubstream(view);
expect(newView?.context?.live?.overrides).toEqual(view.context?.live?.overrides);
});
}); });
describe('hasSubstream', () => { describe('hasSubstream', () => {
@@ -95,7 +109,7 @@ describe('createViewWithNextStream', () => {
camera: 'camera', camera: 'camera',
}); });
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera'])); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
const cameraManager = createCameraManager() const cameraManager = createCameraManager();
const newView = createViewWithNextStream(cameraManager, view); const newView = createViewWithNextStream(cameraManager, view);
expect(newView.camera).toBe(view.camera); expect(newView.camera).toBe(view.camera);
expect(newView.view).toBe(view.view); expect(newView.view).toBe(view.view);
@@ -107,7 +121,7 @@ describe('createViewWithNextStream', () => {
camera: 'camera', camera: 'camera',
}); });
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
const cameraManager = createCameraManager() const cameraManager = createCameraManager();
const newView = createViewWithNextStream(cameraManager, view); const newView = createViewWithNextStream(cameraManager, view);
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']])); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
}); });
@@ -122,7 +136,7 @@ describe('createViewWithNextStream', () => {
}, },
}); });
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
const cameraManager = createCameraManager() const cameraManager = createCameraManager();
const newView = createViewWithNextStream(cameraManager, view); const newView = createViewWithNextStream(cameraManager, view);
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']])); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
}); });
@@ -137,7 +151,7 @@ describe('createViewWithNextStream', () => {
}, },
}); });
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2'])); vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
const cameraManager = createCameraManager() const cameraManager = createCameraManager();
const newView = createViewWithNextStream(cameraManager, view); const newView = createViewWithNextStream(cameraManager, view);
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']])); expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
}); });
+4 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { z } from 'zod'; import { z, ZodError } from 'zod';
import { import {
deepRemoveDefaults, deepRemoveDefaults,
getParseErrorKeys, getParseErrorKeys,
@@ -88,4 +88,7 @@ describe('getParseErrorPaths', () => {
new Set(['array[0] -> type', 'array[0] -> data']), new Set(['array[0] -> type', 'array[0] -> data']),
); );
}); });
it('should get no paths for empty error', () => {
expect(getParseErrorPaths(new ZodError([]))).toEqual(new Set());
});
}); });
+67
View File
@@ -92,6 +92,27 @@ describe('Zoom', () => {
expect(panzoom.handleUp).toBeCalledWith(ev_5); expect(panzoom.handleUp).toBeCalledWith(ev_5);
}); });
it('should not respond to pointer when not zoomed', () => {
const element = document.createElement('div');
const panzoom = createMockPanZoom();
vi.mocked(Panzoom).mockReturnValueOnce(panzoom);
createAndRegisterZoom(element);
const ev_1 = new PointerEvent('pointerdown');
element.dispatchEvent(ev_1);
expect(panzoom.handleDown).not.toBeCalledWith(ev_1);
const ev_2 = new PointerEvent('pointermove');
element.dispatchEvent(ev_2);
expect(panzoom.handleDown).not.toBeCalledWith(ev_2);
const ev_3 = new PointerEvent('pointerup');
element.dispatchEvent(ev_3);
expect(panzoom.handleDown).not.toBeCalledWith(ev_3);
});
it('should respond with touch', () => { it('should respond with touch', () => {
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: false }); mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: false });
@@ -252,4 +273,50 @@ describe('Zoom', () => {
element.dispatchEvent(ev_2); element.dispatchEvent(ev_2);
expect(element.style.touchAction).toBeFalsy(); expect(element.style.touchAction).toBeFalsy();
}); });
it('should not fire frigate cards when state has not changed or spurious events received', () => {
const element = document.createElement('div');
const zoomedFunc = vi.fn();
const unzoomedFunc = vi.fn();
element.addEventListener('frigate-card:zoom:zoomed', zoomedFunc);
element.addEventListener('frigate-card:zoom:unzoomed', unzoomedFunc);
vi.mocked(Panzoom).mockReturnValueOnce(createMockPanZoom());
createAndRegisterZoom(element);
const ev_1 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
detail: {
x: 0,
y: 0,
scale: 1,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_1);
// Unzoomed event with scale === 1, this._zoomed will already be false.
expect(unzoomedFunc).not.toBeCalled();
expect(zoomedFunc).not.toBeCalled();
const ev_2 = new CustomEvent<PanzoomEventDetail>('panzoomzoom', {
detail: {
x: 0,
y: 0,
scale: 1.2,
isSVG: false,
originalEvent: new PointerEvent('pointermove'),
},
});
element.dispatchEvent(ev_2);
expect(zoomedFunc).toBeCalledTimes(1);
expect(unzoomedFunc).not.toBeCalled();
// Another call when already zoomed will be ignored.
element.dispatchEvent(ev_2);
expect(zoomedFunc).toBeCalledTimes(1);
});
}); });
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { ViewMediaType } from '../../src/view/media';
import { ViewMediaClassifier } from '../../src/view/media-classifier';
import { TestViewMedia } from '../test-utils';
describe('ViewMediaClassifier', () => {
describe('isEvent', () => {
it.each([
['clip' as const, true],
['snapshot' as const, true],
['recording' as const, false],
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
expect(
ViewMediaClassifier.isEvent(new TestViewMedia({ mediaType: mediaType })),
).toBe(expectedResult);
});
});
describe('isRecording', () => {
it.each([
['clip' as const, false],
['snapshot' as const, false],
['recording' as const, true],
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
expect(
ViewMediaClassifier.isRecording(new TestViewMedia({ mediaType: mediaType })),
).toBe(expectedResult);
});
});
describe('isClip', () => {
it.each([
['clip' as const, true],
['snapshot' as const, false],
['recording' as const, false],
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
expect(
ViewMediaClassifier.isClip(new TestViewMedia({ mediaType: mediaType })),
).toBe(expectedResult);
});
});
describe('isSnapshot', () => {
it.each([
['clip' as const, false],
['snapshot' as const, true],
['recording' as const, false],
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
expect(
ViewMediaClassifier.isSnapshot(new TestViewMedia({ mediaType: mediaType })),
).toBe(expectedResult);
});
});
describe('isVideo', () => {
it.each([
['clip' as const, true],
['snapshot' as const, false],
['recording' as const, true],
])('%s', (mediaType: ViewMediaType, expectedResult: boolean) => {
expect(
ViewMediaClassifier.isVideo(new TestViewMedia({ mediaType: mediaType })),
).toBe(expectedResult);
});
});
});
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
import { MediaQueriesClassifier } from '../../src/view/media-queries-classifier';
describe('MediaQueriesClassifier', () => {
it('areEventQueries', () => {
expect(MediaQueriesClassifier.areEventQueries(new EventMediaQueries())).toBeTruthy();
expect(
MediaQueriesClassifier.areEventQueries(new RecordingMediaQueries()),
).toBeFalsy();
});
it('areRecordingQueries', () => {
expect(
MediaQueriesClassifier.areRecordingQueries(new RecordingMediaQueries()),
).toBeTruthy();
expect(
MediaQueriesClassifier.areRecordingQueries(new EventMediaQueries()),
).toBeFalsy();
});
it('getQueriesType', () => {
expect(MediaQueriesClassifier.getQueriesType(new EventMediaQueries())).toBe('event');
expect(MediaQueriesClassifier.getQueriesType(new RecordingMediaQueries())).toBe(
'recording',
);
expect(MediaQueriesClassifier.getQueriesType()).toBeNull();
});
});
+282
View File
@@ -0,0 +1,282 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ViewMedia } from '../../src/view/media';
import { MediaQueriesResults } from '../../src/view/media-queries-results';
import { generateViewMediaArray } from '../test-utils';
describe('dispatchViewContextChangeEvent', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('should function with empty results', () => {
const fakeNow = new Date('2023-08-07T20:44');
vi.useFakeTimers();
vi.setSystemTime(fakeNow);
const results = new MediaQueriesResults();
expect(results.isSupersetOf(results)).toBeFalsy();
expect(results.getCameraIDs()).toEqual(new Set());
expect(results.getResults()).toEqual([]);
expect(results.getResultsCount()).toEqual(0);
expect(results.hasResults()).toBeFalsy();
expect(results.getResult(0)).toBeNull();
expect(results.getSelectedIndex()).toBeNull();
expect(results.getSelectedResult()).toBeNull();
expect(results.hasSelectedResult()).toBeFalsy();
expect(results.resetSelectedResult()).toBe(results);
expect(results.getResultsTimestamp()).toEqual(fakeNow);
expect(results.selectIndex(0)).toEqual(results);
expect(results.getSelectedResult()).toBeNull();
expect(results.selectResultIfFound((_media: ViewMedia) => true)).toEqual(results);
expect(results.getSelectedResult()).toBeNull();
expect(results.selectBestResult((_media: ViewMedia[]) => null)).toEqual(results);
expect(results.getSelectedResult()).toBeNull();
expect(results.getMultipleSelectedResults()).toEqual([]);
});
it('should function with basic results', () => {
const testResults = generateViewMediaArray();
const results = new MediaQueriesResults({ results: testResults });
expect(results.isSupersetOf(results)).toBeTruthy();
expect(results.getCameraIDs()).toEqual(new Set(['kitchen', 'office']));
expect(results.getResults()).toEqual(testResults);
expect(results.getResultsCount()).toEqual(200);
expect(results.hasResults()).toBeTruthy();
expect(results.getResult(0)).not.toBeNull();
expect(results.getSelectedIndex()).toBe(199);
expect(results.getSelectedResult()).not.toBeNull();
expect(results.hasSelectedResult()).toBeTruthy();
expect(results.resetSelectedResult()).toBe(results);
expect(results.getSelectedResult()).toBeNull();
expect(results.selectIndex(100)).toEqual(results);
expect(results.getSelectedIndex()).toBe(100);
expect(
results.selectResultIfFound(
(media: ViewMedia) => media.getID() === 'id-kitchen-42',
),
).toEqual(results);
expect(results.getSelectedResult()?.getID()).toBe('id-kitchen-42');
expect(
results.selectBestResult((mediaArray: ViewMedia[]) =>
mediaArray.findIndex((media) => media.getID() === 'id-kitchen-43'),
),
).toEqual(results);
expect(results.getSelectedResult()?.getID()).toBe('id-kitchen-43');
});
it('should function with camera slice', () => {
const testResults = generateViewMediaArray();
const results = new MediaQueriesResults({ results: testResults });
const slice = results.getSlice('office');
expect(slice).not.toBeNull();
expect(slice!.getResults()).toEqual(
testResults.filter((media) => media.getCameraID() === 'office'),
);
expect(slice!.getResultsCount()).toEqual(100);
expect(slice!.hasResults()).toBeTruthy();
expect(slice!.getResult(0)).not.toBeNull();
expect(slice!.getResult()).toBeNull();
expect(slice!.getSelectedIndex()).toBe(99);
expect(slice!.getSelectedResult()?.getID()).toEqual('id-office-99');
expect(slice!.hasSelectedResult()).toBeTruthy();
expect(slice!.resetSelectedResult());
expect(slice!.getSelectedResult()).toBeNull();
expect(slice!.selectIndex(10));
expect(slice!.getSelectedIndex()).toBe(10);
expect(slice!.selectIndex(10000));
expect(slice!.getSelectedIndex()).toBe(10);
expect(slice!.selectIndex(-10000));
expect(slice!.getSelectedIndex()).toBe(10);
slice!.selectResultIfFound((media: ViewMedia) => media.getID() === 'id-office-42');
expect(slice!.getSelectedResult()?.getID()).toBe('id-office-42');
slice!.selectBestResult((mediaArray: ViewMedia[]) =>
mediaArray.findIndex((media) => media.getID() === 'id-office-43'),
);
expect(slice!.getSelectedResult()?.getID()).toBe('id-office-43');
});
describe('should respect select approach during construction', () => {
it.each([
['first' as const, 0],
['last' as const, 199],
])('%s', async (selectApproach, expectedIndex) => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
selectApproach: selectApproach,
});
expect(results.getSelectedIndex()).toBe(expectedIndex);
});
});
it('should respect selectIndex during construction', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
selectedIndex: 42,
});
expect(results.getSelectedIndex()).toBe(42);
});
it('should correctly clone a slice', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
const slice = results.getSlice('office');
const clone = slice?.clone();
expect(clone?.getResults()).toBe(slice?.getResults());
expect(clone?.getSelectedIndex()).toBe(slice?.getSelectedIndex());
});
it('should not get slice for non-existent camera', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
expect(results.getSlice('not-a-camera')).toBeNull();
});
it('should get main slice', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
expect(results.getSlice()?.getResults()).toBe(results.getResults());
});
it('should correctly clone', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
const clone = results.clone();
expect(results.getResultsTimestamp()).toBe(clone.getResultsTimestamp());
expect(results.getResults()).toBe(clone.getResults());
for (const cameraID of results.getCameraIDs()) {
expect(results.getSlice(cameraID)?.getResults()).toBe(
clone.getSlice(cameraID)?.getResults(),
);
}
});
it('should not getResults on invalid slice', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
expect(results.getResults('not-a-camera')).toBeNull();
expect(results.getResultsCount('not-a-camera')).toBe(0);
expect(results.hasSelectedResult('not-a-camera')).toBeFalsy();
});
it('should always demote main selection', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
results
.getSlice('office')
?.selectResultIfFound((media) => media.getID() === 'id-office-42');
// Verify main and office selections are as expected.
expect(results.getSelectedIndex()).toBe(199);
expect(results.getSelectedResult('office')?.getID()).toBe('id-office-42');
// Select a different main result...
results?.selectResultIfFound((media) => media.getID() === 'id-office-80');
// ... and ensure that selection has been demoted into the camera slice.
expect(results.getSelectedResult('office')?.getID()).toBe('id-office-80');
});
it('should promote camera selection', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
results
.getSlice('office')
?.selectResultIfFound((media) => media.getID() === 'id-office-42');
expect(results.getSelectedIndex()).toBe(199);
results.promoteCameraSelectionToMainSelection('office');
expect(results.getSelectedIndex()).not.toBe(199);
expect(results.getSelectedResult()?.getID()).toBe('id-office-42');
});
it('should selectBestResult via advanced selection criteria', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
results.selectBestResult(
(mediaArray: ViewMedia[]) => {
const index = mediaArray.findIndex((media) => media.getID()?.endsWith('-42'));
return index < 0 ? null : index;
},
{ allCameras: true },
);
expect(results.getSelectedResult('office')?.getID()).toBe('id-office-42');
expect(results.getSelectedResult('kitchen')?.getID()).toBe('id-kitchen-42');
});
it('should get multiple selected results', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
expect(
results
.getMultipleSelectedResults({ main: true, allCameras: true })
.map((media) => media.getID()),
).toEqual(['id-office-99', 'id-kitchen-99', 'id-office-99']);
});
it('should get multiple selected results without main', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
expect(
results
.getMultipleSelectedResults({ main: false, allCameras: true })
.map((media) => media.getID()),
).toEqual(['id-kitchen-99', 'id-office-99']);
});
it('should get no results with invalid camera ID without main', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
expect(
results
.getMultipleSelectedResults({ main: false, cameraID: 'not-a-real-camera' })
.map((media) => media.getID()),
).toEqual([]);
});
it('should not demote main selection when selecting from a specific camera', () => {
const results = new MediaQueriesResults({
results: generateViewMediaArray(),
});
results.selectIndex(42);
results.selectIndex(24, 'office');
expect(results.getSelectedIndex()).toBe(42);
expect(results.getSelectedIndex('office')).toBe(24);
});
});
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest';
import {
EventQuery,
PartialEventQuery,
PartialRecordingQuery,
QueryType,
RecordingQuery,
} from '../../src/camera-manager/types';
import { setify } from '../../src/utils/basic';
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
describe('EventMediaQueries', () => {
const createRawEventQueries = (
cameraIDs: string | Set<string>,
query?: PartialEventQuery,
): EventQuery[] => {
return [
{
type: QueryType.Event,
cameraIDs: setify(cameraIDs),
...query,
},
];
};
it('should construct', () => {
const rawQueries = createRawEventQueries('office');
const queries = new EventMediaQueries(rawQueries);
expect(queries.getQueries()).toBe(rawQueries);
});
it('should set', () => {
const rawQueries = createRawEventQueries('office');
const queries = new EventMediaQueries(rawQueries);
const newRawQueries = createRawEventQueries('kitchen');
queries.setQueries(newRawQueries);
expect(queries.getQueries()).toBe(newRawQueries);
});
it('should determine if queries exist for CameraIDs', () => {
const rawQueries = createRawEventQueries(new Set(['office', 'kitchen']));
const queries = new EventMediaQueries(rawQueries);
expect(queries.hasQueriesForCameraIDs(new Set(['office']))).toBeTruthy();
expect(queries.hasQueriesForCameraIDs(new Set(['office', 'kitchen']))).toBeTruthy();
expect(
queries.hasQueriesForCameraIDs(new Set(['office', 'front_door'])),
).toBeFalsy();
});
it('should convert to clips querys', () => {
const rawQueries = createRawEventQueries('office', { hasSnapshot: true });
const queries = new EventMediaQueries(rawQueries);
expect(queries.convertToClipsQueries().getQueries()).toEqual([
{
type: QueryType.Event,
cameraIDs: new Set(['office']),
hasClip: true,
},
]);
});
it('should convert when queries are null', () => {
const queries = new EventMediaQueries();
expect(queries.convertToClipsQueries().getQueries()).toBeNull();
});
it('should clone', () => {
const rawQueries = createRawEventQueries('office', { hasSnapshot: true });
const queries = new EventMediaQueries(rawQueries);
expect(queries.clone().getQueries()).toEqual(queries.getQueries());
});
it('should get camera IDs when queries are null', () => {
expect(new EventMediaQueries().getQueryCameraIDs()).toBeNull();
});
it('should get camera IDs', () => {
const cameraIDs = ['office', 'kitchen'];
const queries = new EventMediaQueries(createRawEventQueries(new Set(cameraIDs)));
expect(queries.getQueryCameraIDs()).toEqual(new Set(cameraIDs));
});
it('should set camera IDs when queries are null', () => {
expect(
new EventMediaQueries().setQueryCameraIDs(new Set(['office'])).getQueryCameraIDs(),
).toBeNull();
});
it('should set camera IDs', () => {
const queries = new EventMediaQueries(createRawEventQueries('sitting_room'));
const newCameraIDs = new Set(['office', 'kitchen']);
expect(queries.setQueryCameraIDs(newCameraIDs).getQueryCameraIDs()).toEqual(
newCameraIDs,
);
});
});
describe('RecordingMediaQueries', () => {
const createRawRecordingQueries = (
cameraIDs: string | Set<string>,
query?: PartialRecordingQuery,
): RecordingQuery[] => {
return [
{
type: QueryType.Recording,
cameraIDs: setify(cameraIDs),
...query,
},
];
};
it('should construct', () => {
const rawQueries = createRawRecordingQueries('office');
const queries = new RecordingMediaQueries(rawQueries);
expect(queries.getQueries()).toBe(rawQueries);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { VideoContentType, ViewMedia } from '../../src/view/media';
import { TestViewMedia } from '../test-utils';
describe('ViewMedia', () => {
beforeEach(() => {
vi.useRealTimers();
});
it('should construct', () => {
const media = new ViewMedia('clip', 'camera');
expect(media.getCameraID()).toBe('camera');
expect(media.getMediaType()).toBe('clip');
expect(media.getVideoContentType()).toBeNull();
expect(media.getID()).toBeNull();
expect(media.getStartTime()).toBeNull();
expect(media.getEndTime()).toBeNull();
expect(media.getUsableEndTime()).toBeNull();
expect(media.inProgress()).toBeNull();
expect(media.getContentID()).toBeNull();
expect(media.getTitle()).toBeNull();
expect(media.getThumbnail()).toBeNull();
expect(media.getTitle()).toBeNull();
expect(media.includesTime(new Date())).toBeFalsy();
expect(media.getWhere()).toBeNull();
expect(media.setFavorite(true)).toBeUndefined();
expect(media.isFavorite()).toBeNull();
});
it('should correctly determine if a media item includes a time', () => {
const media = new TestViewMedia({
startTime: new Date('2023-08-08T17:00:00'),
endTime: new Date('2023-08-08T17:59:59'),
});
expect(media.includesTime(new Date('2023-08-08T17:30:30'))).toBeTruthy();
expect(media.includesTime(new Date('2023-08-08T18:00:00'))).toBeFalsy();
});
it('should correctly get usable end time for in-progress event', () => {
const media = new TestViewMedia({
startTime: new Date('2023-08-08T17:00:00'),
inProgress: true,
});
vi.useFakeTimers();
const fakeNow = new Date('2023-08-08T17:15:00');
vi.setSystemTime(fakeNow);
expect(media.getUsableEndTime()).toEqual(fakeNow)
});
});
describe('VideoContentType', () => {
it('MP4', () => {
expect(VideoContentType.MP4).toBe('mp4');
});
it('HLS', () => {
expect(VideoContentType.HLS).toBe('hls');
});
});
+126 -7
View File
@@ -1,4 +1,4 @@
import { describe, expect, it, test, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { QueryType } from '../../src/camera-manager/types'; import { QueryType } from '../../src/camera-manager/types';
import { ViewMedia } from '../../src/view/media'; import { ViewMedia } from '../../src/view/media';
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries'; import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
@@ -52,6 +52,7 @@ describe('View Basics', () => {
query: new EventMediaQueries(), query: new EventMediaQueries(),
queryResults: new MediaQueriesResults(), queryResults: new MediaQueriesResults(),
context: {}, context: {},
displayMode: 'single',
}); });
const evolved = view.evolve({ const evolved = view.evolve({
@@ -60,12 +61,14 @@ describe('View Basics', () => {
query: new EventMediaQueries(), query: new EventMediaQueries(),
queryResults: new MediaQueriesResults(), queryResults: new MediaQueriesResults(),
context: {}, context: {},
displayMode: 'grid',
}); });
expect(evolved.view).not.toBe(view.view); expect(evolved.view).not.toBe(view.view);
expect(evolved.camera).not.toBe(view.camera); expect(evolved.camera).not.toBe(view.camera);
expect(evolved.query).not.toBe(view.query); expect(evolved.query).not.toBe(view.query);
expect(evolved.queryResults).not.toBe(view.queryResults); expect(evolved.queryResults).not.toBe(view.queryResults);
expect(evolved.context).not.toBe(view.context); expect(evolved.context).not.toBe(view.context);
expect(evolved.displayMode).not.toBe(view.displayMode);
}); });
it('should evolve with nothing set', () => { it('should evolve with nothing set', () => {
@@ -119,6 +122,28 @@ describe('View Basics', () => {
expect(view.context).toEqual({}); expect(view.context).toEqual({});
}); });
it('should not remove context when no context', () => {
const view = createView();
expect(view.context).toBeNull();
view.removeContext('live');
expect(view.context).toBeNull();
});
it('should remove context property', () => {
const view = createView({ context: { live: { overrides: new Map() } } });
view.removeContextProperty('live', 'overrides');
expect(view.context).toEqual({ live: {} });
});
it('should not remove context property that does not exist', () => {
const view = createView({ context: {} });
view.removeContextProperty('live', 'overrides');
expect(view.context).toEqual({});
});
it('should detect gallery views', () => { it('should detect gallery views', () => {
expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy(); expect(createView({ view: 'clips' }).isGalleryView()).toBeTruthy();
expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy(); expect(createView({ view: 'snapshots' }).isGalleryView()).toBeTruthy();
@@ -240,8 +265,8 @@ describe('View.isMajorMediaChange', () => {
it('should consider result change as major in other view', () => { it('should consider result change as major in other view', () => {
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')]; const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
const queryResults_1 = new MediaQueriesResults(media, 0); const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
const queryResults_2 = new MediaQueriesResults(media, 1); const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
expect( expect(
View.isMajorMediaChange( View.isMajorMediaChange(
createView({ view: 'media', queryResults: queryResults_1 }), createView({ view: 'media', queryResults: queryResults_1 }),
@@ -252,8 +277,8 @@ describe('View.isMajorMediaChange', () => {
it('should not consider selected result change as major in live view', () => { 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 media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
const queryResults_1 = new MediaQueriesResults(media, 0); const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
const queryResults_2 = new MediaQueriesResults(media, 1); const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
expect( expect(
View.isMajorMediaChange( View.isMajorMediaChange(
createView({ queryResults: queryResults_1 }), createView({ queryResults: queryResults_1 }),
@@ -282,7 +307,29 @@ describe('View.adoptFromViewIfAppropriate', () => {
expect(next.queryResults).toBe(queryResults); expect(next.queryResults).toBe(queryResults);
}); });
test.each([ it('should not adopt for gallery case if neither query nor results in current view', () => {
const current = createView({
view: 'clip',
query: null,
queryResults: null,
});
const nextQuery = new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
]);
const nextResults = new MediaQueriesResults();
const next = createView({
view: 'clips',
query: nextQuery,
queryResults: nextResults,
});
View.adoptFromViewIfAppropriate(next, current);
expect(next.view).toBe('clips');
expect(next.query).toBe(nextQuery);
expect(next.queryResults).toBe(nextResults);
});
it.each([
[ [
new EventMediaQueries([ new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
@@ -301,7 +348,7 @@ describe('View.adoptFromViewIfAppropriate', () => {
]), ]),
'recording', 'recording',
], ],
])('should adopt for media case', (mediaQueries, expectedView) => { ])('should adopt in media case', (mediaQueries, expectedView) => {
const current = createView({ const current = createView({
view: 'media', view: 'media',
query: mediaQueries, query: mediaQueries,
@@ -314,6 +361,54 @@ describe('View.adoptFromViewIfAppropriate', () => {
expect(next.queryResults).toBeFalsy(); expect(next.queryResults).toBeFalsy();
}); });
it('should not adopt for mixed queries in media case', () => {
const query = new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasSnapshot: true },
]);
const results = new MediaQueriesResults();
const current = createView({
view: 'media',
query: query,
queryResults: results,
});
const next = createView({ view: 'media' });
View.adoptFromViewIfAppropriate(next, current);
expect(next.view).toBe('media');
expect(next.query).toBeNull();
expect(next.queryResults).toBeNull();
});
it('should not adopt when queries and results present in next view in media case', () => {
const currentQuery = new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasClip: true },
{ type: QueryType.Event, cameraIDs: new Set(['camera-1']), hasSnapshot: true },
]);
const currentResults = new MediaQueriesResults();
const current = createView({
view: 'media',
query: currentQuery,
queryResults: currentResults,
});
const nextQuery = new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasClip: true },
{ type: QueryType.Event, cameraIDs: new Set(['camera-2']), hasSnapshot: true },
]);
const nextResults = new MediaQueriesResults();
const next = createView({
view: 'media',
query: nextQuery,
queryResults: nextResults,
});
View.adoptFromViewIfAppropriate(next, current);
expect(next.view).toBe('media');
expect(next.query).toBe(nextQuery);
expect(next.queryResults).toBe(nextResults);
});
it('should not adopt for other case', () => { it('should not adopt for other case', () => {
const query = new EventMediaQueries([ const query = new EventMediaQueries([
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true }, { type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
@@ -409,6 +504,30 @@ describe('View.adoptFromViewIfAppropriate', () => {
View.adoptFromViewIfAppropriate(next, current); View.adoptFromViewIfAppropriate(next, current);
expect(next.context?.live).toEqual(current.context?.live); expect(next.context?.live).toEqual(current.context?.live);
}); });
it('should determine if display mode is grid', () => {
expect(createView({ displayMode: 'grid' }).isGrid()).toBeTruthy();
expect(createView({ displayMode: 'single' }).isGrid()).toBeFalsy();
expect(createView().isGrid()).toBeFalsy();
});
it('should determine if view supports multiple display modes', () => {
expect(createView({ view: 'live' }).supportsMultipleDisplayModes()).toBeTruthy();
expect(createView({ view: 'media' }).supportsMultipleDisplayModes()).toBeTruthy();
expect(createView({ view: 'clip' }).supportsMultipleDisplayModes()).toBeTruthy();
expect(createView({ view: 'snapshot' }).supportsMultipleDisplayModes()).toBeTruthy();
expect(
createView({ view: 'recording' }).supportsMultipleDisplayModes(),
).toBeTruthy();
expect(createView({ view: 'clips' }).supportsMultipleDisplayModes()).toBeFalsy();
expect(createView({ view: 'snapshots' }).supportsMultipleDisplayModes()).toBeFalsy();
expect(
createView({ view: 'recordings' }).supportsMultipleDisplayModes(),
).toBeFalsy();
expect(createView({ view: 'image' }).supportsMultipleDisplayModes()).toBeFalsy();
expect(createView({ view: 'timeline' }).supportsMultipleDisplayModes()).toBeFalsy();
});
}); });
// @vitest-environment jsdom // @vitest-environment jsdom
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config';
// ts-prune-ignore-next
export default defineConfig({
test: {
coverage: {
// Favor istanbul for coverage over v8 due to better accuracy.
provider: 'istanbul',
},
},
});
+658 -309
View File
File diff suppressed because it is too large Load Diff