Merge pull request #1251 from dermotduffy/grid
Add ability to show media in a multi-camera grid
This commit is contained in:
Vendored
+2
-1
@@ -5,5 +5,6 @@
|
||||
"i18n-ally.keepFulfilled": true,
|
||||
"i18n-ally.editor.preferEditor": true,
|
||||
"i18n-ally.translate.saveAsCandidates": true,
|
||||
"vitest.commandLine": "npx vitest --root ."
|
||||
"vitest.commandLine": "npx vitest --root .",
|
||||
"diffEditor.experimental.useVersion2": true
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
| `layout` | | :white_check_mark: | See [media layout](#media-layout) below.|
|
||||
| `microphone` | | :white_check_mark: | See [microphone](#microphone) below.|
|
||||
| `display` | | :white_check_mark: | See [display](#live-display) below.|
|
||||
|
||||
#### 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.
|
||||
|
||||
<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
|
||||
|
||||
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` . |
|
||||
| `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>
|
||||
|
||||
### Media Gallery Options
|
||||
@@ -806,6 +854,17 @@ timeline:
|
||||
|
||||
<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
|
||||
|
||||
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">
|
||||
|
||||
### 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
|
||||
|
||||
### Illustrative Expanded Configuration Reference
|
||||
@@ -1946,6 +2011,10 @@ live:
|
||||
microphone:
|
||||
always_connected: false
|
||||
disconnect_seconds: 60
|
||||
display:
|
||||
mode: single
|
||||
grid_selected_width_factor: 2
|
||||
grid_max_columns: 4
|
||||
actions:
|
||||
entity: light.office_main_lights
|
||||
tap_action:
|
||||
@@ -2007,6 +2076,10 @@ media_viewer:
|
||||
position:
|
||||
x: 50
|
||||
y: 50
|
||||
display:
|
||||
mode: single
|
||||
grid_selected_width_factor: 2
|
||||
grid_max_columns: 4
|
||||
actions:
|
||||
entity: light.office_main_lights
|
||||
tap_action:
|
||||
@@ -3823,6 +3896,32 @@ automations:
|
||||
```
|
||||
</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>
|
||||
|
||||
## Card Refreshes
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 MiB |
+7
-5
@@ -27,13 +27,14 @@
|
||||
"custom-card-helpers": "^1.9.0",
|
||||
"date-fns": "^2.29.2",
|
||||
"date-fns-tz": "^1.3.7",
|
||||
"embla-carousel": "^7.0.9",
|
||||
"embla-carousel-wheel-gestures": "^3.0.0",
|
||||
"embla-carousel": "8.0.0-rc12",
|
||||
"embla-carousel-wheel-gestures": "8.0.0-rc04",
|
||||
"home-assistant-js-websocket": "^8.0.0",
|
||||
"keycharm": "^0.4.0",
|
||||
"lit": "^2.3.1",
|
||||
"lit-flatpickr": "^0.4.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"masonry-layout": "^4.2.2",
|
||||
"moment": "^2.29.4",
|
||||
"propagating-hammerjs": "^2.0.1",
|
||||
"quick-lru": "^6.1.0",
|
||||
@@ -58,9 +59,10 @@
|
||||
"@rollup/plugin-node-resolve": "^13.3.0",
|
||||
"@rollup/plugin-replace": "^4.0.0",
|
||||
"@types/lodash-es": "^4.17.5",
|
||||
"@types/masonry-layout": "^4.2.5",
|
||||
"@typescript-eslint/eslint-plugin": "^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-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
@@ -78,8 +80,8 @@
|
||||
"sass": "^1.54.9",
|
||||
"ts-prune": "^0.10.3",
|
||||
"typescript": "^4.9.5",
|
||||
"vitest": "^0.29.8",
|
||||
"vitest-mock-extended": "^1.1.3"
|
||||
"vitest": "^0.34.3",
|
||||
"vitest-mock-extended": "^1.2.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "rollup -c --watch",
|
||||
|
||||
@@ -6,9 +6,9 @@ import type {
|
||||
import { noChange } from 'lit';
|
||||
import {
|
||||
AttributePart,
|
||||
directive,
|
||||
Directive,
|
||||
DirectiveParameters,
|
||||
directive,
|
||||
} from 'lit/directive.js';
|
||||
import { stopEventFromActivatingCardWideActions } from './utils/action.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) {
|
||||
// Reset the options on an existing actionHandler.
|
||||
element.actionHandlerOptions = options;
|
||||
|
||||
@@ -69,13 +69,11 @@ export class CachedValueController<T> implements ReactiveController {
|
||||
public startTimer(): void {
|
||||
this.stopTimer();
|
||||
|
||||
if (this._timerSeconds > 0) {
|
||||
this._timerStartCallback?.();
|
||||
this._timer.startRepeated(this._timerSeconds, () => {
|
||||
this.updateValue();
|
||||
this._host.requestUpdate();
|
||||
});
|
||||
}
|
||||
this._timerStartCallback?.();
|
||||
this._timer.startRepeated(this._timerSeconds, () => {
|
||||
this.updateValue();
|
||||
this._host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
public hasTimer(): boolean {
|
||||
|
||||
@@ -66,7 +66,7 @@ export class CameraManagerEngineFactory {
|
||||
engine = Engine.MotionEye;
|
||||
} else if (cameraConfig.engine === 'generic') {
|
||||
engine = Engine.Generic;
|
||||
} else if (cameraConfig.engine === 'auto') {
|
||||
} else {
|
||||
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
|
||||
|
||||
if (cameraEntity) {
|
||||
|
||||
@@ -58,8 +58,8 @@ export class ExpiringMemoryRangeSet
|
||||
}
|
||||
|
||||
public add(range: ExpiringRange<Date>): void {
|
||||
this._expireOldRanges();
|
||||
this._ranges.push(range);
|
||||
this._expireOldRanges();
|
||||
}
|
||||
|
||||
protected _expireOldRanges(): void {
|
||||
@@ -95,26 +95,25 @@ export const compressRanges = <T extends Date | number>(
|
||||
ranges = orderBy(ranges, (range) => range.start, 'asc');
|
||||
|
||||
let current: Range<T> | null = null;
|
||||
for (let i = 0; i < ranges.length; ++i) {
|
||||
const item = ranges[i];
|
||||
const itemStartSeconds =
|
||||
item.start instanceof Date ? item.start.getTime() : item.start;
|
||||
for (const range of ranges) {
|
||||
const rangeStartSeconds: number =
|
||||
range.start instanceof Date ? range.start.getTime() : range.start;
|
||||
|
||||
if (!current) {
|
||||
current = { ...item };
|
||||
current = { ...range };
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentEndSeconds =
|
||||
current.end instanceof Date ? current.end.getTime() : (current.end as number);
|
||||
|
||||
if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) {
|
||||
if (item.end > current.end) {
|
||||
current.end = item.end;
|
||||
if (currentEndSeconds + toleranceSeconds * 1000 >= rangeStartSeconds) {
|
||||
if (range.end > current.end) {
|
||||
current.end = range.end;
|
||||
}
|
||||
} else {
|
||||
compressedRanges.push(current);
|
||||
current = { ...item };
|
||||
current = { ...range };
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
|
||||
+80
-10
@@ -48,17 +48,18 @@ import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FRIGATE_CARD_VIEW_DEFAULT,
|
||||
FrigateCardConfig,
|
||||
frigateCardConfigSchema,
|
||||
FrigateCardCustomAction,
|
||||
FrigateCardError,
|
||||
FrigateCardView,
|
||||
FRIGATE_CARD_VIEW_DEFAULT,
|
||||
MediaLoadedInfo,
|
||||
MenuButton,
|
||||
Message,
|
||||
MESSAGE_TYPE_PRIORITIES,
|
||||
RawFrigateCardConfig,
|
||||
ViewDisplayMode,
|
||||
} from './types.js';
|
||||
import {
|
||||
convertActionToFrigateCardCustomAction,
|
||||
@@ -86,6 +87,7 @@ import { supportsFeature } from './utils/ha/update.js';
|
||||
import { FrigateCardInitializer } from './utils/initializer.js';
|
||||
import { MediaLoadedInfoController } from './utils/media-info-controller';
|
||||
import { isValidMediaLoadedInfo } from './utils/media-info.js';
|
||||
import { executeMediaQueryForView } from './utils/media-to-view';
|
||||
import { MenuButtonController } from './utils/menu-controller';
|
||||
import { MicrophoneController } from './utils/microphone';
|
||||
import { getActionsFromQueryString } from './utils/querystring.js';
|
||||
@@ -97,6 +99,7 @@ import {
|
||||
} from './utils/substream';
|
||||
import { Timer } from './utils/timer';
|
||||
import { getParseErrorPaths } from './utils/zod.js';
|
||||
import { ViewMediaClassifier } from './view/media-classifier';
|
||||
import { View } from './view/view.js';
|
||||
|
||||
/** A note on media callbacks:
|
||||
@@ -414,6 +417,7 @@ class FrigateCard extends LitElement {
|
||||
state: this._hass.states,
|
||||
}),
|
||||
media_loaded: this._mediaLoadedInfoController.has(),
|
||||
displayMode: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -466,6 +470,7 @@ class FrigateCard extends LitElement {
|
||||
this._conditionController?.setState({
|
||||
view: this._view.view,
|
||||
camera: this._view.camera,
|
||||
displayMode: this._view.displayMode ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -493,10 +498,14 @@ class FrigateCard extends LitElement {
|
||||
}
|
||||
|
||||
if (cameraID) {
|
||||
const viewName = args?.viewName ?? this._getConfig().view.default;
|
||||
const displayMode =
|
||||
this._view?.displayMode ?? this._getDefaultDisplayModeForView(viewName);
|
||||
changeView(
|
||||
new View({
|
||||
view: args?.viewName ?? this._getConfig().view.default,
|
||||
view: viewName,
|
||||
camera: cameraID,
|
||||
displayMode: displayMode,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -641,7 +650,10 @@ class FrigateCard extends LitElement {
|
||||
targetCamera &&
|
||||
(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;
|
||||
}
|
||||
}
|
||||
@@ -1003,7 +1015,7 @@ class FrigateCard extends LitElement {
|
||||
|
||||
if (this._view.isViewerView() && media) {
|
||||
media_content_id = media.getContentID();
|
||||
media_content_type = media.getContentType();
|
||||
media_content_type = ViewMediaClassifier.isVideo(media) ? 'video' : 'image';
|
||||
title = media.getTitle();
|
||||
thumbnail = media.getThumbnail();
|
||||
} 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 {
|
||||
// Note: This function needs to process (view-related) commands even when
|
||||
// _view has not yet been initialized (since it may be used to set a view
|
||||
// via the querystring).
|
||||
if (!this._cameraManager) {
|
||||
if (!this._cameraManager || !this._hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1119,7 +1150,8 @@ class FrigateCard extends LitElement {
|
||||
? targetView
|
||||
: FRIGATE_CARD_VIEW_DEFAULT;
|
||||
this._changeView({
|
||||
view: new View({ view: actualView, camera: selectCameraID }),
|
||||
viewName: actualView,
|
||||
cameraID: selectCameraID,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -1203,6 +1235,38 @@ class FrigateCard extends LitElement {
|
||||
}
|
||||
});
|
||||
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:
|
||||
console.warn(`Frigate card received unknown card action: ${action}`);
|
||||
}
|
||||
@@ -1501,15 +1565,21 @@ class FrigateCard extends LitElement {
|
||||
? `${lastKnown.width} / ${lastKnown.height}`
|
||||
: 'unset',
|
||||
);
|
||||
// Non-media mays have no intrinsic dimensions and so we need to explicit
|
||||
// request the dialog to use all available space.
|
||||
// Non-media may have no intrinsic dimensions (or multiple media items in a
|
||||
// grid) and so we need to explicit request the dialog to use all available
|
||||
// space.
|
||||
const isGrid = this._view?.isGrid();
|
||||
this.style.setProperty(
|
||||
'--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(
|
||||
'--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
@@ -1,29 +1,20 @@
|
||||
import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType } from 'embla-carousel';
|
||||
import { EmblaNodesType } from 'embla-carousel/components';
|
||||
import {
|
||||
CreatePluginType,
|
||||
EmblaPluginsType,
|
||||
LoosePluginType,
|
||||
} from 'embla-carousel/components/Plugins';
|
||||
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
html,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import { Ref, createRef, ref } from 'lit/directives/ref.js';
|
||||
import carouselStyle from '../scss/carousel.scss';
|
||||
import { TransitionEffect } from '../types';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
|
||||
export interface CarouselSelect {
|
||||
index: number;
|
||||
element: HTMLElement;
|
||||
}
|
||||
import {
|
||||
CarouselController,
|
||||
CarouselDirection,
|
||||
} from '../utils/embla/carousel-controller';
|
||||
|
||||
export type EmblaCarouselPlugins = CreatePluginType<
|
||||
LoosePluginType,
|
||||
@@ -33,249 +24,89 @@ export type EmblaCarouselPlugins = CreatePluginType<
|
||||
@customElement('frigate-card-carousel')
|
||||
export class FrigateCardCarousel extends LitElement {
|
||||
@property({ attribute: true, reflect: true })
|
||||
public direction: 'vertical' | 'horizontal' = 'horizontal';
|
||||
|
||||
@property({ attribute: false })
|
||||
public carouselOptions?: EmblaOptionsType;
|
||||
|
||||
@property({ attribute: false })
|
||||
public carouselPlugins?: EmblaCarouselPlugins;
|
||||
|
||||
@property({ attribute: false })
|
||||
public selected = 0;
|
||||
public direction: CarouselDirection = 'horizontal';
|
||||
|
||||
@property({ attribute: true })
|
||||
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.
|
||||
protected _scrolling = false;
|
||||
@property({ attribute: false })
|
||||
public dragEnabled = true;
|
||||
|
||||
// Whether to reinit the carousel when it settles.
|
||||
protected _reInitOnSettle = false;
|
||||
@property({ attribute: false })
|
||||
public plugins?: EmblaCarouselPlugins;
|
||||
|
||||
protected _carouselReInitInPlace = throttle(
|
||||
this._carouselReInitInPlaceInternal.bind(this),
|
||||
500,
|
||||
{ trailing: true },
|
||||
);
|
||||
@property({ attribute: false })
|
||||
public selected = 0;
|
||||
|
||||
protected _refParent: Ref<HTMLSlotElement> = createRef();
|
||||
protected _refRoot: Ref<HTMLElement> = createRef();
|
||||
protected _carousel: CarouselController | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
// Guarantee a re-render if the component is reconnected. See note in
|
||||
// disconnectedCallback().
|
||||
// Guarantee recreation of carousel if the component is reconnected.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
// Destroy the carousel when the component is disconnected, which forces the
|
||||
// plugins (which may have registered event handlers) to also be destroyed.
|
||||
// The carousel will automatically reconstruct if the component is re-rendered.
|
||||
this._destroyCarousel();
|
||||
this._carousel?.destroy();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the carousel if certain properties change.
|
||||
* @param changedProps The changed properties
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
const destroyProperties = [
|
||||
'direction',
|
||||
'carouselOptions',
|
||||
'carouselPlugins',
|
||||
] as const;
|
||||
if (changedProps.has('direction')) {
|
||||
this.setAttribute('direction', this.direction);
|
||||
}
|
||||
|
||||
const destroyProperties = ['direction', 'dragFree', 'transitionEffect'] as const;
|
||||
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 {
|
||||
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">
|
||||
${showPrevious ? html`<slot name="previous"></slot>` : ``}
|
||||
<div class="embla__viewport">
|
||||
<slot name="previous"></slot>
|
||||
<div ${ref(this._refRoot)} class="embla__viewport">
|
||||
<div class="embla__container">
|
||||
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged.bind(this)}></slot>
|
||||
<slot ${ref(this._refParent)}></slot>
|
||||
</div>
|
||||
</div>
|
||||
${showNext ? html`<slot name="next"></slot>` : ``}
|
||||
<slot name="next"></slot>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
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 {
|
||||
return unsafeCSS(carouselStyle);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SideDrawer } from 'side-drawer';
|
||||
import drawerInjectStyle from '../scss/drawer-inject.scss';
|
||||
import drawerStyle from '../scss/drawer.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action';
|
||||
import { isHoverableDevice } from '../utils/basic';
|
||||
import { getChildrenFromElement, isHoverableDevice } from '../utils/basic';
|
||||
|
||||
export interface DrawerIcons {
|
||||
open?: string;
|
||||
@@ -67,12 +67,14 @@ export class FrigateCardDrawer extends LitElement {
|
||||
* Called when the slotted children in the drawer change.
|
||||
*/
|
||||
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.
|
||||
this._resizeObserver.disconnect();
|
||||
for (const element of elements ?? []) {
|
||||
this._resizeObserver.observe(element);
|
||||
for (const child of children) {
|
||||
this._resizeObserver.observe(child);
|
||||
}
|
||||
this._hideDrawerIfNecessary();
|
||||
}
|
||||
@@ -86,11 +88,13 @@ export class FrigateCardDrawer extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = this._refSlot.value?.assignedElements({ flatten: true });
|
||||
const children = this._refSlot.value
|
||||
? getChildrenFromElement(this._refSlot.value)
|
||||
: null;
|
||||
this.empty =
|
||||
!elements ||
|
||||
!elements.length ||
|
||||
elements.every((element) => {
|
||||
!children ||
|
||||
!children.length ||
|
||||
children.every((element) => {
|
||||
const box = element.getBoundingClientRect();
|
||||
return !box.width || !box.height;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -368,7 +368,9 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
this.view
|
||||
?.evolve({
|
||||
query: newMediaQueries,
|
||||
queryResults: new MediaQueriesResults(extension.results).selectResultIfFound(
|
||||
queryResults: new MediaQueriesResults({
|
||||
results: extension.results,
|
||||
}).selectResultIfFound(
|
||||
(media) => media === this.view?.queryResults?.getSelectedResult(),
|
||||
),
|
||||
})
|
||||
@@ -468,7 +470,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
this.view
|
||||
.evolve({
|
||||
view: 'media',
|
||||
queryResults: this.view.queryResults?.clone().selectResult(
|
||||
queryResults: this.view.queryResults?.clone().selectIndex(
|
||||
// Media in the gallery is reversed vs the queryResults (see
|
||||
// note above).
|
||||
this._media.length - index - 1,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import liveImageStyle from '../../scss/live-image.scss';
|
||||
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||
import { CameraConfig, FrigateCardMediaPlayer } from '../../types.js';
|
||||
import '../image.js';
|
||||
import { getStateObjOrDispatchError } from './live.js';
|
||||
@@ -50,7 +50,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
||||
}
|
||||
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
return await this._refImage.value?.getScreenshotURL() ?? null;
|
||||
return (await this._refImage.value?.getScreenshotURL()) ?? null;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
@@ -77,7 +77,7 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveImageStyle);
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+230
-180
@@ -1,6 +1,4 @@
|
||||
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 {
|
||||
CSSResultGroup,
|
||||
@@ -13,15 +11,16 @@ import {
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { keyed } from 'lit/directives/keyed.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.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 { localize } from '../../localize/localize.js';
|
||||
import basicBlockStyle from '../../scss/basic-block.scss';
|
||||
import liveCarouselStyle from '../../scss/live-carousel.scss';
|
||||
import liveProviderStyle from '../../scss/live-provider.scss';
|
||||
import liveStyle from '../../scss/live.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
@@ -37,25 +36,30 @@ import {
|
||||
} from '../../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.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 {
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
dispatchMediaUnloadedEvent,
|
||||
} from '../../utils/media-info.js';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
||||
import { playMediaMutingIfNecessary } from '../../utils/media.js';
|
||||
import { Timer } from '../../utils/timer.js';
|
||||
import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
|
||||
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
|
||||
import {
|
||||
FrigateCardMediaCarousel,
|
||||
wrapMediaLoadedEventForCarousel,
|
||||
wrapMediaUnloadedEventForCarousel,
|
||||
} from '../media-carousel.js';
|
||||
import { EmblaCarouselPlugins } from '../carousel.js';
|
||||
import { dispatchErrorMessageEvent, dispatchMessageEvent } from '../message.js';
|
||||
import '../next-prev-control.js';
|
||||
import '../surround.js';
|
||||
import '../title-control.js';
|
||||
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
|
||||
import { Lazyload } from './../embla-plugins/lazyload.js';
|
||||
import {
|
||||
FrigateCardTitleControl,
|
||||
getDefaultTitleConfigForView,
|
||||
showTitleControlAfterDelay,
|
||||
} from '../title-control.js';
|
||||
|
||||
interface LiveViewContext {
|
||||
// A cameraID override (used for dependencies/substreams to force a different
|
||||
@@ -126,7 +130,10 @@ export class FrigateCardLive extends LitElement {
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public liveConfig?: LiveConfig;
|
||||
public nonOverriddenLiveConfig?: LiveConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public overriddenLiveConfig?: LiveConfig;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
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
|
||||
protected shouldUpdate(_changedProps: PropertyValues): boolean {
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component connected callback.
|
||||
*/
|
||||
connectedCallback(): void {
|
||||
this._intersectionObserver.observe(this);
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._intersectionObserver.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.liveConfig || !this.cameraManager || !this.view) {
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.nonOverriddenLiveConfig ||
|
||||
!this.cameraManager ||
|
||||
!this.view
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Notes:
|
||||
// - See use of liveConfig and not config below -- the carousel will
|
||||
// independently override the liveConfig to reflect the camera in the
|
||||
// - See use of liveConfig and not config below -- the underlying carousel
|
||||
// will independently override the liveConfig to reflect the camera in the
|
||||
// carousel (not necessarily the selected camera).
|
||||
// - Various events are captured to prevent them propagating upwards if the
|
||||
// card is in the background.
|
||||
@@ -244,10 +241,11 @@ export class FrigateCardLive extends LitElement {
|
||||
const result = html`${keyed(
|
||||
this._renderKey,
|
||||
html`
|
||||
<frigate-card-live-carousel
|
||||
<frigate-card-live-grid
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.liveConfig=${this.liveConfig}
|
||||
.nonOverriddenLiveConfig=${this.nonOverriddenLiveConfig}
|
||||
.overriddenLiveConfig=${this.overriddenLiveConfig}
|
||||
.inBackground=${this._inBackground}
|
||||
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||
.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveStyle);
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-live-carousel')
|
||||
export class FrigateCardLiveCarousel extends LitElement {
|
||||
@customElement('frigate-card-live-grid')
|
||||
export class FrigateCardLiveGrid extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@@ -301,14 +296,14 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public liveConfig?: LiveConfig;
|
||||
public nonOverriddenLiveConfig?: LiveConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public overriddenLiveConfig?: LiveConfig;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public liveOverrides?: LiveOverrides;
|
||||
|
||||
@property({ attribute: false })
|
||||
public inBackground?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public conditionControllerEpoch?: ConditionControllerEpoch;
|
||||
|
||||
@@ -321,108 +316,160 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public microphoneStream?: MediaStream;
|
||||
|
||||
// Index between camera name and slide number.
|
||||
protected _cameraToSlide: Record<string, number> = {};
|
||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The updated lifecycle callback for this element.
|
||||
* @param changedProperties The properties that were changed in this render.
|
||||
*/
|
||||
updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
protected _gridSelectCamera(cameraID: string, view?: View): void {
|
||||
(view ?? this.view)
|
||||
?.evolve({
|
||||
camera: cameraID,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the transition effect to use.
|
||||
* @returns An TransitionEffect object.
|
||||
*/
|
||||
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.
|
||||
protected _cameraToSlide: Record<string, number> = {};
|
||||
protected _titleTimer = new Timer();
|
||||
protected _refTitleControl: Ref<FrigateCardTitleControl> = createRef();
|
||||
|
||||
protected _getTransitionEffect(): TransitionEffect {
|
||||
return (
|
||||
this.liveConfig?.transition_effect ??
|
||||
this.overriddenLiveConfig?.transition_effect ??
|
||||
frigateCardConfigDefaults.live.transition_effect
|
||||
);
|
||||
}
|
||||
|
||||
protected _getSelectedCameraIndex(): number {
|
||||
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 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 {
|
||||
const cameras = this.cameraManager?.getStore().getVisibleCameraIDs();
|
||||
return [
|
||||
// Only enable wheel plugin if there is more than one camera.
|
||||
...(cameras && cameras.size > 1
|
||||
? [
|
||||
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 && {
|
||||
AutoLazyLoad({
|
||||
...(this.overriddenLiveConfig?.lazy_load && {
|
||||
lazyLoadCallback: (index, slide) =>
|
||||
this._lazyloadOrUnloadSlide('load', index, slide),
|
||||
}),
|
||||
|
||||
lazyUnloadCondition: this.liveConfig?.lazy_unload,
|
||||
lazyUnloadCondition: this.overriddenLiveConfig?.lazy_unload,
|
||||
lazyUnloadCallback: (index, slide) =>
|
||||
this._lazyloadOrUnloadSlide('unload', index, slide),
|
||||
}),
|
||||
AutoMediaPlugin({
|
||||
AutoMediaLoadedInfo(),
|
||||
AutoMediaActions({
|
||||
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
|
||||
...(this.liveConfig?.auto_play && {
|
||||
autoPlayCondition: this.liveConfig.auto_play,
|
||||
...(this.overriddenLiveConfig?.auto_play && {
|
||||
autoPlayCondition: this.overriddenLiveConfig.auto_play,
|
||||
}),
|
||||
...(this.liveConfig?.auto_pause && {
|
||||
autoPauseCondition: this.liveConfig.auto_pause,
|
||||
...(this.overriddenLiveConfig?.auto_pause && {
|
||||
autoPauseCondition: this.overriddenLiveConfig.auto_pause,
|
||||
}),
|
||||
...(this.liveConfig?.auto_mute && {
|
||||
autoMuteCondition: this.liveConfig.auto_mute,
|
||||
...(this.overriddenLiveConfig?.auto_mute && {
|
||||
autoMuteCondition: this.overriddenLiveConfig.auto_mute,
|
||||
}),
|
||||
...(this.liveConfig?.auto_unmute && {
|
||||
autoUnmuteCondition: this.liveConfig.auto_unmute,
|
||||
...(this.overriddenLiveConfig?.auto_unmute && {
|
||||
autoUnmuteCondition: this.overriddenLiveConfig.auto_unmute,
|
||||
}),
|
||||
}),
|
||||
AutoSize(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -435,24 +482,29 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
*/
|
||||
protected _getLazyLoadCount(): number | null {
|
||||
// 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>] {
|
||||
const visibleCameras = this.cameraManager?.getStore().getVisibleCameras();
|
||||
if (!visibleCameras) {
|
||||
let cameras: CameraConfigs | null = null;
|
||||
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 [[], {}];
|
||||
}
|
||||
|
||||
const slides: TemplateResult[] = [];
|
||||
const cameraToSlide: Record<string, number> = {};
|
||||
|
||||
for (const [cameraID, cameraConfig] of visibleCameras) {
|
||||
for (const [cameraID, cameraConfig] of cameras) {
|
||||
const liveCameraID =
|
||||
this.view?.context?.live?.overrides?.get(cameraID) ?? cameraID;
|
||||
const liveCameraConfig =
|
||||
@@ -461,7 +513,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
: this.cameraManager?.getStore().getCameraConfig(liveCameraID);
|
||||
|
||||
const slide = liveCameraConfig
|
||||
? this._renderLive(liveCameraID, liveCameraConfig, slides.length)
|
||||
? this._renderLive(liveCameraID, liveCameraConfig)
|
||||
: null;
|
||||
if (slide) {
|
||||
cameraToSlide[cameraID] = slides.length;
|
||||
@@ -471,10 +523,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
return [slides, cameraToSlide];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the user selecting a new slide in the carousel.
|
||||
*/
|
||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
||||
protected _setViewHandler(ev: CustomEvent<CarouselSelected>): void {
|
||||
const cameras = this.cameraManager?.getStore().getVisibleCameras();
|
||||
if (cameras && ev.detail.index !== this._getSelectedCameraIndex()) {
|
||||
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(
|
||||
action: 'load' | 'unload',
|
||||
_index: number,
|
||||
@@ -515,17 +559,17 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
FRIGATE_CARD_LIVE_PROVIDER,
|
||||
) as FrigateCardLiveProvider | null;
|
||||
if (liveProvider) {
|
||||
liveProvider.disabled = action !== 'load';
|
||||
liveProvider.load = action === 'load';
|
||||
}
|
||||
}
|
||||
|
||||
protected _renderLive(
|
||||
cameraID: string,
|
||||
cameraConfig: CameraConfig,
|
||||
slideIndex: number,
|
||||
): TemplateResult | void {
|
||||
if (
|
||||
!this.liveConfig ||
|
||||
!this.overriddenLiveConfig ||
|
||||
!this.nonOverriddenLiveConfig ||
|
||||
!this.hass ||
|
||||
!this.cameraManager ||
|
||||
!this.conditionControllerEpoch
|
||||
@@ -538,7 +582,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
// stateOverride to evaluate the condition in that context.
|
||||
const config = getOverriddenConfig(
|
||||
this.conditionControllerEpoch.controller,
|
||||
this.liveConfig,
|
||||
this.nonOverriddenLiveConfig,
|
||||
this.liveOverrides,
|
||||
{ camera: cameraID },
|
||||
) as LiveConfig;
|
||||
@@ -548,7 +592,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
return html`
|
||||
<div class="embla__slide">
|
||||
<frigate-card-live-provider
|
||||
?disabled=${this.liveConfig.lazy_load}
|
||||
?load=${!config.lazy_load}
|
||||
.microphoneStream=${this.view?.camera === cameraID
|
||||
? this.microphoneStream
|
||||
: undefined}
|
||||
@@ -561,12 +605,6 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
.liveConfig=${config}
|
||||
.hass=${this.hass}
|
||||
.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>
|
||||
</div>
|
||||
@@ -575,11 +613,13 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
|
||||
protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
|
||||
const cameras = this.cameraManager?.getStore().getVisibleCameras();
|
||||
if (!cameras || !this.view || !this.hass) {
|
||||
if (this.viewFilterCameraID || !cameras || !this.view || !this.hass) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const cameraID = this.viewFilterCameraID ?? this.view.camera;
|
||||
const keys = Array.from(cameras.keys());
|
||||
const currentIndex = keys.indexOf(this.view.camera);
|
||||
const currentIndex = keys.indexOf(cameraID);
|
||||
|
||||
if (currentIndex < 0 || cameras.size <= 1) {
|
||||
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 {
|
||||
if (!this.liveConfig || !this.view || !this.hass || !this.cameraManager) {
|
||||
if (!this.overriddenLiveConfig || !this.view || !this.hass || !this.cameraManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -606,6 +642,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasMultipleCameras = slides.length > 1;
|
||||
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
|
||||
|
||||
const overrideCameraID = (cameraID: string): string => {
|
||||
@@ -617,12 +654,17 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
: null;
|
||||
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
|
||||
this.hass,
|
||||
overrideCameraID(this.view.camera),
|
||||
overrideCameraID(this.viewFilterCameraID ?? this.view.camera),
|
||||
);
|
||||
const cameraMetadataNext = nextID
|
||||
? this.cameraManager.getCameraMetadata(this.hass, overrideCameraID(nextID))
|
||||
: null;
|
||||
|
||||
const titleConfig = getDefaultTitleConfigForView(
|
||||
this.view,
|
||||
this.overriddenLiveConfig?.controls.title,
|
||||
);
|
||||
|
||||
// Notes on the below:
|
||||
// - guard() is used to avoid reseting the carousel unless the
|
||||
// options/plugins actually change.
|
||||
@@ -634,34 +676,31 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
// little later).
|
||||
|
||||
return html`
|
||||
<frigate-card-media-carousel
|
||||
${ref(this._refMediaCarousel)}
|
||||
.carouselOptions=${guard(
|
||||
[this.cameraManager, this.liveConfig],
|
||||
this._getOptions.bind(this),
|
||||
)}
|
||||
.carouselPlugins=${guard(
|
||||
[this.cameraManager, this.liveConfig],
|
||||
<frigate-card-carousel
|
||||
.loop=${hasMultipleCameras}
|
||||
.dragEnabled=${hasMultipleCameras && this.overriddenLiveConfig?.draggable}
|
||||
.plugins=${guard(
|
||||
[this.cameraManager, this.overriddenLiveConfig],
|
||||
this._getPlugins.bind(this),
|
||||
) as EmblaCarouselPlugins}
|
||||
.label="${cameraMetadataCurrent
|
||||
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
||||
: ''}"
|
||||
.logo="${cameraMetadataCurrent?.engineLogo}"
|
||||
.titlePopupConfig=${this.liveConfig.controls.title}
|
||||
)}
|
||||
.selected=${this._getSelectedCameraIndex()}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||
@frigate-card:carousel:select=${this._setViewHandler.bind(this)}
|
||||
@frigate-card:carousel:settle=${() => {
|
||||
// Fetch the thumbnails after the carousel has settled.
|
||||
dispatchViewContextChangeEvent(this, { thumbnails: { fetch: true } });
|
||||
}}
|
||||
@frigate-card:media:loaded=${() => {
|
||||
if (this._refTitleControl.value) {
|
||||
showTitleControlAfterDelay(this._refTitleControl.value, this._titleTimer);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<frigate-card-next-previous-control
|
||||
slot="previous"
|
||||
.hass=${this.hass}
|
||||
.direction=${'previous'}
|
||||
.controlConfig=${this.liveConfig.controls.next_previous}
|
||||
.controlConfig=${this.overriddenLiveConfig.controls.next_previous}
|
||||
.label=${cameraMetadataPrevious?.title ?? ''}
|
||||
.icon=${cameraMetadataPrevious?.icon}
|
||||
?disabled=${prevID === null}
|
||||
@@ -676,7 +715,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
slot="next"
|
||||
.hass=${this.hass}
|
||||
.direction=${'next'}
|
||||
.controlConfig=${this.liveConfig.controls.next_previous}
|
||||
.controlConfig=${this.overriddenLiveConfig.controls.next_previous}
|
||||
.label=${cameraMetadataNext?.title ?? ''}
|
||||
.icon=${cameraMetadataNext?.icon}
|
||||
?disabled=${nextID === null}
|
||||
@@ -686,13 +725,22 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
}}
|
||||
>
|
||||
</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 {
|
||||
return unsafeCSS(liveCarouselStyle);
|
||||
}
|
||||
@@ -715,10 +763,11 @@ export class FrigateCardLiveProvider
|
||||
@property({ attribute: false })
|
||||
public liveConfig?: LiveConfig;
|
||||
|
||||
// Whether or not to disable this entity. If `true`, no contents are rendered
|
||||
// until this attribute is set to `false` (this is useful for lazy loading).
|
||||
// Whether or not to load the video for this camera. If `false`, no contents
|
||||
// are rendered until this attribute is set to `true` (this is useful for lazy
|
||||
// loading).
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public disabled = false;
|
||||
public load = false;
|
||||
|
||||
// Label that is used for ARIA support and as tooltip.
|
||||
@property({ attribute: false })
|
||||
@@ -794,7 +843,7 @@ export class FrigateCardLiveProvider
|
||||
public async getScreenshotURL(): Promise<string | null> {
|
||||
await this.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.
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('disabled')) {
|
||||
if (this.disabled) {
|
||||
if (changedProps.has('load')) {
|
||||
if (!this.load) {
|
||||
this._isVideoMediaLoaded = false;
|
||||
dispatchMediaUnloadedEvent(this);
|
||||
}
|
||||
@@ -909,7 +958,7 @@ export class FrigateCardLiveProvider
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (this.disabled || !this.hass || !this.liveConfig || !this.cameraConfig) {
|
||||
if (!this.load || !this.hass || !this.liveConfig || !this.cameraConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1002,6 +1051,7 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
FRIGATE_CARD_LIVE_PROVIDER: FrigateCardLiveProvider;
|
||||
'frigate-card-live-carousel': FrigateCardLiveCarousel;
|
||||
'frigate-card-live-grid': FrigateCardLiveGrid;
|
||||
'frigate-card-live': FrigateCardLive;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -7,7 +7,8 @@ import {
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
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 {
|
||||
CardWideConfig,
|
||||
ClipsOrSnapshotsOrAll,
|
||||
@@ -16,13 +17,11 @@ import {
|
||||
ThumbnailsControlConfig,
|
||||
} from '../types.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 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 {
|
||||
// Whether or not to fetch thumbnails.
|
||||
@@ -88,6 +87,7 @@ export class FrigateCardSurround extends LitElement {
|
||||
this.cardWideConfig,
|
||||
this.view,
|
||||
{
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: this.view.view,
|
||||
mediaType: this.fetchMedia,
|
||||
select: 'latest',
|
||||
@@ -113,12 +113,14 @@ export class FrigateCardSurround extends LitElement {
|
||||
import('./timeline.js');
|
||||
}
|
||||
|
||||
// Only reset the timeline cameraIDs when the media materially changes (and
|
||||
// not on every view change, since the view will change frequently when the
|
||||
// user is scrubbing video).
|
||||
// Only reset the timeline cameraIDs when the media or display mode
|
||||
// materially changes (and not on every view change, since the view will
|
||||
// change frequently when the user is scrubbing video).
|
||||
const oldView = changedProperties.get('view');
|
||||
if (
|
||||
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;
|
||||
}
|
||||
@@ -138,23 +140,16 @@ export class FrigateCardSurround extends LitElement {
|
||||
return null;
|
||||
}
|
||||
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()) {
|
||||
return new Set(
|
||||
this.view.query
|
||||
?.getQueries()
|
||||
?.map((query: DataQuery) => [...query.cameraIDs])
|
||||
.flat(),
|
||||
);
|
||||
return this.view.query?.getQueryCameraIDs() ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.view) {
|
||||
return;
|
||||
@@ -183,6 +178,7 @@ export class FrigateCardSurround extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.config=${this.thumbnailConfig}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.fadeThumbnails=${this.view.isViewerView()}
|
||||
.view=${this.view}
|
||||
.selected=${this.view.queryResults?.getSelectedIndex() ?? undefined}
|
||||
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
||||
@@ -230,11 +226,8 @@ export class FrigateCardSurround extends LitElement {
|
||||
</frigate-card-surround-basic>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(surroundStyle);
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
|
||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
@@ -10,18 +8,16 @@ import {
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.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 { ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.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 { FrigateCardCarousel } from './carousel.js';
|
||||
import './thumbnail.js';
|
||||
import { View } from '../view/view.js';
|
||||
import './carousel.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import './thumbnail.js';
|
||||
|
||||
export interface ThumbnailCarouselTap {
|
||||
queryResults: MediaQueriesResults;
|
||||
@@ -38,83 +34,14 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
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 })
|
||||
public config?: ThumbnailsControlConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public selected? = 0;
|
||||
public fadeThumbnails = false;
|
||||
|
||||
protected _carouselOptions?: EmblaOptionsType = {
|
||||
containScroll: 'keepSnaps',
|
||||
dragFree: true,
|
||||
};
|
||||
protected _thumbnailSlides: TemplateResult[] = [];
|
||||
|
||||
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 {
|
||||
if (changedProps.has('config')) {
|
||||
if (this.config?.size) {
|
||||
@@ -128,95 +55,93 @@ 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(
|
||||
'--frigate-card-carousel-thumbnail-opacity',
|
||||
this.selected === undefined ? '1.0' : '0.4',
|
||||
!this.fadeThumbnails || this._getSelectedSlide() === null ? '1.0' : '0.4',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a given thumbnail.
|
||||
* @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;
|
||||
}
|
||||
|
||||
const classes = {
|
||||
embla__slide: true,
|
||||
'slide-selected': this.selected === index,
|
||||
};
|
||||
|
||||
const seekTarget = this.view?.context?.mediaViewer?.seek;
|
||||
return html` <frigate-card-thumbnail
|
||||
class="${classMap(classes)}"
|
||||
.cameraManager=${this.cameraManager}
|
||||
.hass=${this.hass}
|
||||
.media=${media}
|
||||
.view=${this.view}
|
||||
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
|
||||
?details=${!!this.config?.show_details}
|
||||
?show_favorite_control=${this.config?.show_favorite_control}
|
||||
?show_timeline_control=${this.config?.show_timeline_control}
|
||||
?show_download_control=${this.config?.show_download_control}
|
||||
@click=${(ev: Event) => {
|
||||
if (this.view && this.view.queryResults) {
|
||||
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
|
||||
this,
|
||||
'thumbnail-carousel:tap',
|
||||
{
|
||||
queryResults: this.view.queryResults.clone().selectResult(index),
|
||||
},
|
||||
);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
>
|
||||
</frigate-card-thumbnail>`;
|
||||
protected _getSelectedSlide(view?: View): number | null {
|
||||
return (view ?? this.view)?.queryResults?.getSelectedIndex() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the direction of the thumbnail carousel.
|
||||
* @returns `vertical`, `horizontal` or undefined.
|
||||
*/
|
||||
protected _getDirection(): 'horizontal' | 'vertical' | undefined {
|
||||
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 = {
|
||||
embla__slide: true,
|
||||
'slide-selected': selectedIndex === index,
|
||||
};
|
||||
|
||||
slides.push(html` <frigate-card-thumbnail
|
||||
class="${classMap(classes)}"
|
||||
.cameraManager=${this.cameraManager}
|
||||
.hass=${this.hass}
|
||||
.media=${media}
|
||||
.view=${this.view}
|
||||
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
|
||||
?details=${!!this.config?.show_details}
|
||||
?show_favorite_control=${this.config?.show_favorite_control}
|
||||
?show_timeline_control=${this.config?.show_timeline_control}
|
||||
?show_download_control=${this.config?.show_download_control}
|
||||
@click=${(ev: Event) => {
|
||||
if (this.view && this.view.queryResults) {
|
||||
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
|
||||
this,
|
||||
'thumbnail-carousel:tap',
|
||||
{
|
||||
queryResults: this.view.queryResults.clone().selectIndex(index),
|
||||
},
|
||||
);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
>
|
||||
</frigate-card-thumbnail>`);
|
||||
}
|
||||
return slides;
|
||||
}
|
||||
|
||||
protected _getDirection(): CarouselDirection | null {
|
||||
if (this.config?.mode === 'left' || this.config?.mode === 'right') {
|
||||
return 'vertical';
|
||||
} else if (this.config?.mode === 'above' || this.config?.mode === 'below') {
|
||||
return 'horizontal';
|
||||
}
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the element.
|
||||
* @returns A template to display to the user.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
const slides = this._getSlides();
|
||||
if (!slides.length || !this.config || this.config.mode === 'none') {
|
||||
const direction = this._getDirection();
|
||||
if (!this._thumbnailSlides.length || !this.config || !direction) {
|
||||
return;
|
||||
}
|
||||
|
||||
return html`<frigate-card-carousel
|
||||
${ref(this._refCarousel)}
|
||||
direction=${ifDefined(this._getDirection())}
|
||||
.selected=${this.selected ?? 0}
|
||||
.carouselOptions=${this._carouselOptions}
|
||||
.carouselPlugins=${this._carouselPlugins}
|
||||
direction=${direction}
|
||||
.selected=${this._getSelectedSlide() ?? 0}
|
||||
.dragFree=${true}
|
||||
>
|
||||
${slides}
|
||||
${this._thumbnailSlides}
|
||||
</frigate-card-carousel>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(thumbnailCarouselStyle);
|
||||
}
|
||||
|
||||
@@ -333,11 +333,18 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
||||
|
||||
@customElement('frigate-card-thumbnail')
|
||||
export class FrigateCardThumbnail extends LitElement {
|
||||
// HomeAssistant object may be required for thumbnail signing (for Frigate
|
||||
// events).
|
||||
@property({ attribute: false })
|
||||
// Performance: During timeline scrubbing, hass may be updated
|
||||
// continuously. As it is not needed for the thumbnail rendering itself, it
|
||||
// does not trigger a re-render. The HomeAssistant object may be required for
|
||||
// thumbnail signing (after initial signing the thumbnail is stored in a data
|
||||
// URL, so the signing will not expire).
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
// Performance: During timeline scrubbing, the view will be updated
|
||||
// continuously. As it is not needed for the thumbnail rendering itself, it
|
||||
// does not trigger a re-render.
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@@ -359,9 +366,6 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public seek?: Date;
|
||||
|
||||
@property({ attribute: false })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
/**
|
||||
* Render the element.
|
||||
* @returns A template to display to the user.
|
||||
@@ -381,7 +385,6 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
|
||||
const shouldShowTimelineControl =
|
||||
this.show_timeline_control &&
|
||||
this.view &&
|
||||
(!ViewMediaClassifier.isRecording(this.media) ||
|
||||
// Only show timeline control if the recording has a start & end time.
|
||||
(this.media.getStartTime() && this.media.getEndTime()));
|
||||
|
||||
+164
-142
@@ -1,9 +1,7 @@
|
||||
import add from 'date-fns/add';
|
||||
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import sub from 'date-fns/sub';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
@@ -31,6 +29,7 @@ import {
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { rangesOverlap } from '../camera-manager/range';
|
||||
import { MediaQuery } from '../camera-manager/types';
|
||||
import { convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
|
||||
import { localize } from '../localize/localize';
|
||||
import timelineCoreStyle from '../scss/timeline-core.scss';
|
||||
import {
|
||||
@@ -48,18 +47,23 @@ import {
|
||||
dispatchFrigateCardEvent,
|
||||
formatDateAndTime,
|
||||
isHoverableDevice,
|
||||
isTruthy,
|
||||
setOrRemoveAttribute,
|
||||
} from '../utils/basic';
|
||||
import {
|
||||
createQueriesForRecordingsView,
|
||||
executeMediaQueryForView,
|
||||
findBestMediaIndex,
|
||||
} from '../utils/media-to-view';
|
||||
import { executeMediaQueryForView, findBestMediaIndex } from '../utils/media-to-view';
|
||||
import { FrigateCardTimelineItem, TimelineDataSource } from '../utils/timeline-source';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||
import { EventMediaQueries, MediaQueries } from '../view/media-queries';
|
||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||
import {
|
||||
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 './date-picker.js';
|
||||
import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js';
|
||||
@@ -82,7 +86,7 @@ interface TimelineViewContext {
|
||||
}
|
||||
|
||||
type TimelineItemClickAction = 'play' | 'select';
|
||||
type TimelinePanBehavior = 'pan' | 'seek' | 'seek-in-media';
|
||||
type TimelinePanBehavior = 'pan' | 'seek' | 'seek-in-media' | 'seek-in-camera';
|
||||
|
||||
declare module 'view' {
|
||||
interface ViewContext {
|
||||
@@ -285,13 +289,17 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
? localize('timeline.pan_behavior.pan')
|
||||
: this._panBehavior === '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 =
|
||||
this._panBehavior === 'pan'
|
||||
? 'mdi:pan-horizontal'
|
||||
: this._panBehavior === 'seek'
|
||||
? 'mdi:filmstrip'
|
||||
: 'mdi:lock';
|
||||
? 'mdi:filmstrip-box-multiple'
|
||||
: this._panBehavior === 'seek-in-media'
|
||||
? 'mdi:play-box-lock'
|
||||
: 'mdi:camera-lock';
|
||||
|
||||
return html` ${capabilities?.supportsTimeline
|
||||
? html` <div
|
||||
@@ -311,6 +319,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
? 'seek'
|
||||
: this._panBehavior === 'seek'
|
||||
? 'seek-in-media'
|
||||
: this._panBehavior === 'seek-in-media'
|
||||
? 'seek-in-camera'
|
||||
: 'pan';
|
||||
}}
|
||||
aria-label="${panTitle}"
|
||||
@@ -403,15 +413,18 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
const targetBarOn =
|
||||
this._shouldSupportSeeking() &&
|
||||
(this._panBehavior === 'seek' ||
|
||||
this._panBehavior === 'seek-in-camera' ||
|
||||
(this._panBehavior === 'seek-in-media' &&
|
||||
this._timeline.getSelection().some((id) => {
|
||||
const item = this._timelineSource?.dataset?.get(id);
|
||||
return (
|
||||
this._panBehavior !== 'seek-in-camera' ||
|
||||
item?.media?.getCameraID() === this.view?.camera,
|
||||
item &&
|
||||
item.start &&
|
||||
item.end &&
|
||||
targetTime.getTime() >= item.start &&
|
||||
targetTime.getTime() <= item.end
|
||||
item.start &&
|
||||
item.end &&
|
||||
targetTime.getTime() >= item.start &&
|
||||
targetTime.getTime() <= item.end
|
||||
);
|
||||
})));
|
||||
|
||||
@@ -479,13 +492,30 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
}
|
||||
|
||||
const canSeek = this._shouldSupportSeeking();
|
||||
const newResults =
|
||||
this._panBehavior === 'seek-in-media'
|
||||
? null
|
||||
: results
|
||||
.clone()
|
||||
.resetSelectedResult()
|
||||
.selectBestResult((media) => findBestMediaIndex(media, targetTime));
|
||||
let newResults: MediaQueriesResults | null = null;
|
||||
|
||||
if (this._panBehavior === 'seek') {
|
||||
newResults = results
|
||||
.clone()
|
||||
.resetSelectedResult()
|
||||
.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
|
||||
? targetTime >= new Date()
|
||||
@@ -493,11 +523,12 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
: 'media'
|
||||
: this.view.view;
|
||||
|
||||
const selectedCamera = newResults?.getSelectedResult()?.getCameraID();
|
||||
this.view
|
||||
.evolve({
|
||||
...(selectedCamera && { camera: selectedCamera }),
|
||||
view: desiredView,
|
||||
...(newResults &&
|
||||
newResults.hasSelectedResult() && { queryResults: newResults }),
|
||||
queryResults: newResults,
|
||||
}) // Whether or not to set the timeline window.
|
||||
.mergeInContext({
|
||||
...(canSeek && { mediaViewer: { seek: targetTime } }),
|
||||
@@ -533,6 +564,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
!this.cameraManager ||
|
||||
!this.cardWideConfig ||
|
||||
!timelineCameraIDs ||
|
||||
!this._timelineSource ||
|
||||
!properties.what
|
||||
) {
|
||||
return;
|
||||
@@ -543,45 +575,10 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
|
||||
if (
|
||||
this.timelineConfig?.show_recordings &&
|
||||
['background', 'group-label'].includes(properties.what)
|
||||
properties.time &&
|
||||
['background', 'axis'].includes(properties.what)
|
||||
) {
|
||||
const cameraIDs = properties.group
|
||||
? 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),
|
||||
},
|
||||
);
|
||||
const query = this._createMediaQueries('recording');
|
||||
if (query) {
|
||||
view = await executeMediaQueryForView(
|
||||
this,
|
||||
@@ -597,10 +594,15 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
);
|
||||
}
|
||||
} 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
|
||||
?.clone()
|
||||
.resetSelectedResult()
|
||||
.selectResultIfFound((media) => media.getID() === properties.item);
|
||||
.selectResultIfFound((media) => media.getID() === properties.item, criteria);
|
||||
|
||||
if (!newResults || !newResults.hasSelectedResult()) {
|
||||
// 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
|
||||
// events on the timeline, e.g. in the snapshots viewer but
|
||||
// mini-timeline showing all media).
|
||||
const fullEventView = await this._createViewWithEventMediaQuery(
|
||||
this._createEventMediaQuerys(),
|
||||
const fullEventView = await this._createViewWithMediaQueries(
|
||||
this._createMediaQueries('event'),
|
||||
{
|
||||
selectedItem: properties.item,
|
||||
targetView: 'media',
|
||||
@@ -673,59 +675,58 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
}
|
||||
this._removeTargetBar();
|
||||
|
||||
if (!this.hass) {
|
||||
if (!this.hass || !this._timeline || !this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefetchedWindow = this._getPrefetchWindow(properties);
|
||||
await this._timelineSource?.refresh(this.hass, prefetchedWindow);
|
||||
await this._timelineSource?.refresh(this.hass, this._getPrefetchWindow(properties));
|
||||
|
||||
// Don't show event thumbnails if the user is looking at recordings,
|
||||
// as the recording "hours" are the media, not the event
|
||||
// clips/snapshots.
|
||||
if (
|
||||
this._timeline &&
|
||||
this.view &&
|
||||
!MediaQueriesClassifier.areRecordingQueries(this.view.query)
|
||||
) {
|
||||
const newView = await this._createViewWithEventMediaQuery(
|
||||
this._createEventMediaQuerys({ window: this._timeline.getWindow() }),
|
||||
);
|
||||
const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
|
||||
if (!queryType) {
|
||||
return;
|
||||
}
|
||||
const mediaQuery = this._createMediaQueries(queryType);
|
||||
const newView = await this._createViewWithMediaQueries(mediaQuery);
|
||||
|
||||
// Specifically avoid dispatching new results on range change unless there
|
||||
// is something to be gained by doing so. Example usecase: On initial view
|
||||
// load in mini timeline mode, the first 50 events are fetched -- the
|
||||
// first drag of the timeline should not dispatch new results unless
|
||||
// something is actually useful (as otherwise it creates a visible
|
||||
// 'flicker' for the user as the viewer reloads all the media).
|
||||
const newResults = newView?.queryResults;
|
||||
if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) {
|
||||
newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this);
|
||||
}
|
||||
// Specifically avoid dispatching new results on range change unless there
|
||||
// is something to be gained by doing so. Example usecase: On initial view
|
||||
// load in mini timeline mode, the first 50 events are fetched -- the
|
||||
// first drag of the timeline should not dispatch new results unless
|
||||
// something is actually useful (as otherwise it creates a visible
|
||||
// 'flicker' for the user as the viewer reloads all the media).
|
||||
const newResults = newView?.queryResults;
|
||||
if (newView && newResults && !this.view.queryResults?.isSupersetOf(newResults)) {
|
||||
newView?.mergeInContext(this._getTimelineContext())?.dispatchChangeEvent(this);
|
||||
}
|
||||
}
|
||||
|
||||
protected _createEventMediaQuerys(options?: {
|
||||
window?: TimelineWindow;
|
||||
}): EventMediaQueries | null {
|
||||
if (!this._timeline || !this._timelineSource || !this.cardWideConfig) {
|
||||
protected _createMediaQueries(
|
||||
type: MediaQueriesType,
|
||||
options?: {
|
||||
window?: TimelineWindow;
|
||||
},
|
||||
): MediaQueries | null {
|
||||
if (!this._timeline || !this._timelineSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheFriendlyWindow = this._timelineSource.getCacheFriendlyEventWindow(
|
||||
options?.window ?? this._timeline.getWindow(),
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(
|
||||
this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()),
|
||||
);
|
||||
|
||||
const eventQueries =
|
||||
this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow);
|
||||
if (!eventQueries) {
|
||||
return null;
|
||||
if (type === 'event') {
|
||||
const queries = this._timelineSource.getTimelineEventQueries(cacheFriendlyWindow);
|
||||
return queries ? new EventMediaQueries(queries) : null;
|
||||
} else if (type === 'recording') {
|
||||
const queries =
|
||||
this._timelineSource.getTimelineRecordingQueries(cacheFriendlyWindow);
|
||||
return queries ? new RecordingMediaQueries(queries) : null;
|
||||
}
|
||||
return new EventMediaQueries(eventQueries);
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async _createViewWithEventMediaQuery(
|
||||
query: EventMediaQueries | null,
|
||||
protected async _createViewWithMediaQueries(
|
||||
query: MediaQueries | null,
|
||||
options?: {
|
||||
targetView?: FrigateCardView;
|
||||
selectedItem?: IdType;
|
||||
@@ -788,11 +789,12 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
return new DataSet(groups);
|
||||
}
|
||||
|
||||
protected _getPerfectWindowFromMedia(media: ViewMedia): TimelineWindow | null {
|
||||
const startTime = media.getStartTime();
|
||||
const endTime = media.getEndTime();
|
||||
|
||||
if (ViewMediaClassifier.isEvent(media)) {
|
||||
protected _getPerfectWindowFromMediaStartAndEndTime(
|
||||
isEvent: boolean,
|
||||
startTime: Date | null,
|
||||
endTime: Date | null,
|
||||
): TimelineWindow | null {
|
||||
if (isEvent) {
|
||||
const windowSeconds = this._getConfiguredWindowSeconds();
|
||||
|
||||
if (startTime && endTime) {
|
||||
@@ -820,7 +822,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
end: add(startTime, { seconds: windowSeconds / 2 }),
|
||||
};
|
||||
}
|
||||
} else if (ViewMediaClassifier.isRecording(media) && startTime && endTime) {
|
||||
} else if (startTime && endTime) {
|
||||
return {
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
@@ -889,8 +891,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
maxItems: this.timelineConfig.clustering_threshold,
|
||||
|
||||
clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => {
|
||||
const media = this.view?.queryResults?.getSelectedResult();
|
||||
const selectedId = media?.getID();
|
||||
const selectedIDs = this._getAllSelectedMediaIDsFromView();
|
||||
const firstMedia = (<FrigateCardTimelineItem>first).media;
|
||||
const secondMedia = (<FrigateCardTimelineItem>second).media;
|
||||
|
||||
@@ -900,8 +901,8 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
return (
|
||||
first.type !== 'background' &&
|
||||
first.type === second.type &&
|
||||
first.id !== selectedId &&
|
||||
second.id !== selectedId &&
|
||||
!selectedIDs.includes(first.id) &&
|
||||
!selectedIDs.includes(second.id) &&
|
||||
!!firstMedia &&
|
||||
!!secondMedia &&
|
||||
ViewMediaClassifier.isEvent(firstMedia) &&
|
||||
@@ -956,6 +957,18 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
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.
|
||||
*/
|
||||
@@ -980,8 +993,10 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
|
||||
let desiredWindow = timelineWindow;
|
||||
const media = this.view.queryResults?.getSelectedResult();
|
||||
const mediaStartTime = media?.getStartTime();
|
||||
const mediaEndTime = media?.getEndTime();
|
||||
const mediaStartTime = media?.getStartTime() ?? null;
|
||||
const mediaEndTime = media?.getEndTime() ?? null;
|
||||
const mediaIsEvent = media ? ViewMediaClassifier.isEvent(media) : false;
|
||||
|
||||
const mediaWindow: TimelineWindow | null =
|
||||
media && mediaStartTime
|
||||
? // 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) {
|
||||
desiredWindow = context.window;
|
||||
} else if (media && mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) {
|
||||
const perfectMediaWindow = this._getPerfectWindowFromMedia(media);
|
||||
} else if (mediaWindow && !rangesOverlap(mediaWindow, timelineWindow)) {
|
||||
const perfectMediaWindow = this._getPerfectWindowFromMediaStartAndEndTime(
|
||||
mediaIsEvent,
|
||||
mediaStartTime,
|
||||
mediaEndTime,
|
||||
);
|
||||
if (perfectMediaWindow) {
|
||||
desiredWindow = perfectMediaWindow;
|
||||
}
|
||||
@@ -1013,22 +1032,27 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
await this._timelineSource?.refresh(this.hass, prefetchedWindow);
|
||||
}
|
||||
|
||||
const mediaID = media?.getID();
|
||||
if (media && mediaID && this._isClustering()) {
|
||||
// Hack: Clustering may not update unless the dataset changes, artifically
|
||||
// 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
|
||||
// interrupting the user and to make the timeline smoother.
|
||||
const currentSelection = this._timeline.getSelection();
|
||||
const mediaIDsToSelect = this._getAllSelectedMediaIDsFromView();
|
||||
|
||||
// Need to this rewrite prior to setting the selection (just below), or
|
||||
// the selection will be lost on rewrite.
|
||||
this._timelineSource?.rewriteEvent(mediaID);
|
||||
}
|
||||
const needToSelect = mediaIDsToSelect.some(
|
||||
(mediaID) => !currentSelection.includes(mediaID),
|
||||
);
|
||||
|
||||
const desiredId =
|
||||
!!media && ViewMediaClassifier.isEvent(media) ? media.getID() : null;
|
||||
if (desiredId) {
|
||||
this._timeline?.setSelection([desiredId], {
|
||||
if (needToSelect) {
|
||||
if (this._isClustering()) {
|
||||
// Hack: Clustering may not update unless the dataset changes, artifically
|
||||
// update the dataset to ensure the newly selected item cannot be included
|
||||
// in a cluster.
|
||||
|
||||
for (const mediaID of mediaIDsToSelect) {
|
||||
// Need to this rewrite prior to setting the selection (just below), or
|
||||
// the selection will be lost on rewrite.
|
||||
this._timelineSource?.rewriteEvent(mediaID);
|
||||
}
|
||||
}
|
||||
|
||||
this._timeline?.setSelection(mediaIDsToSelect, {
|
||||
focus: false,
|
||||
animation: {
|
||||
animation: false,
|
||||
@@ -1053,20 +1077,23 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
// -> New view received ... [loop]
|
||||
//
|
||||
// Also don't generate thumbnails in mini-timelines (they will already have
|
||||
// been generated), or if the view is for recordings (media thumbnails are
|
||||
// recordings, not events in this case).
|
||||
// been generated).
|
||||
|
||||
const freshMediaQuery = this._createEventMediaQuerys({
|
||||
const queryType = MediaQueriesClassifier.getQueriesType(this.view.query);
|
||||
if (!queryType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshMediaQuery = this._createMediaQueries(queryType, {
|
||||
window: desiredWindow,
|
||||
});
|
||||
|
||||
if (
|
||||
!this.mini &&
|
||||
!MediaQueriesClassifier.areRecordingQueries(this.view.query) &&
|
||||
freshMediaQuery &&
|
||||
!this._alreadyHasAcceptableMediaQuery(freshMediaQuery)
|
||||
) {
|
||||
(await this._createViewWithEventMediaQuery(freshMediaQuery))
|
||||
(await this._createViewWithMediaQueries(freshMediaQuery))
|
||||
?.mergeInContext(this._getTimelineContext(desiredWindow))
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
@@ -1194,8 +1221,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
createdTimeline = true;
|
||||
const noGroups = this.mini && groups.length === 1;
|
||||
if (noGroups) {
|
||||
// In a mini timeline, if there's only one group don't bother grouping
|
||||
// at all.
|
||||
this._timeline = new Timeline(
|
||||
this._refTimeline.value,
|
||||
this._timelineSource.dataset,
|
||||
@@ -1244,9 +1269,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(timelineCoreStyle);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
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 basicBlockStyle from '../scss/basic-block.scss';
|
||||
import { CardWideConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
|
||||
import { View } from '../view/view';
|
||||
import './surround.js';
|
||||
import './timeline-core.js';
|
||||
@@ -24,10 +24,6 @@ export class FrigateCardTimeline extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.timelineConfig) {
|
||||
return html``;
|
||||
@@ -49,11 +45,8 @@ export class FrigateCardTimeline extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(timelineStyle);
|
||||
return unsafeCSS(basicBlockStyle);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,52 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
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 { TitleControlConfig } from '../types.js';
|
||||
import { Timer } from '../utils/timer';
|
||||
import { View } from '../view/view.js';
|
||||
|
||||
type PaperToast = HTMLElement & {
|
||||
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')
|
||||
export class FrigateCardTitleControl extends LitElement {
|
||||
@property({ attribute: false })
|
||||
@@ -25,10 +63,6 @@ export class FrigateCardTitleControl extends LitElement {
|
||||
|
||||
protected _toastRef: Ref<PaperToast> = createRef();
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult {
|
||||
if (!this.text || !this.config || this.config.mode == 'none' || !this.fitInto) {
|
||||
return html``;
|
||||
@@ -50,17 +84,10 @@ export class FrigateCardTitleControl extends LitElement {
|
||||
</paper-toast>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the toast is visible.
|
||||
* @returns `true` if the toast is visible, `false` otherwise.
|
||||
*/
|
||||
public isVisible(): boolean {
|
||||
return this._toastRef.value?.opened ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the toast.
|
||||
*/
|
||||
public hide(): void {
|
||||
if (this._toastRef.value) {
|
||||
// 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 {
|
||||
if (this._toastRef.value) {
|
||||
// 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 {
|
||||
return unsafeCSS(titleStyle);
|
||||
}
|
||||
|
||||
+322
-178
@@ -1,5 +1,3 @@
|
||||
import { EmblaPluginType } from 'embla-carousel';
|
||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
@@ -8,13 +6,19 @@ import {
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} 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 { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
|
||||
import {
|
||||
dispatchMessageEvent,
|
||||
renderMessage,
|
||||
renderProgressIndicator,
|
||||
} from '../components/message.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import '../patches/ha-hls-player';
|
||||
import basicBlockStyle from '../scss/basic-block.scss';
|
||||
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
|
||||
import viewerProviderStyle from '../scss/viewer-provider.scss';
|
||||
import viewerStyle from '../scss/viewer.scss';
|
||||
@@ -29,9 +33,19 @@ import {
|
||||
} from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.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 { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
||||
import { MediaGridSelected } from '../utils/media-grid-controller.js';
|
||||
import {
|
||||
dispatchMediaLoadedEvent,
|
||||
dispatchMediaPauseEvent,
|
||||
@@ -50,21 +64,21 @@ import {
|
||||
setControlsOnVideo,
|
||||
} from '../utils/media.js';
|
||||
import { screenshotMedia } from '../utils/screenshot.js';
|
||||
import { Timer } from '../utils/timer';
|
||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
||||
import { VideoContentType, ViewMedia } from '../view/media.js';
|
||||
import { View } from '../view/view.js';
|
||||
import type { CarouselSelect } from './carousel.js';
|
||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||
import { Lazyload } from './embla-plugins/lazyload.js';
|
||||
import {
|
||||
FrigateCardMediaCarousel,
|
||||
wrapMediaLoadedEventForCarousel,
|
||||
} from './media-carousel.js';
|
||||
import type { EmblaCarouselPlugins } from './carousel.js';
|
||||
import './next-prev-control.js';
|
||||
import './surround.js';
|
||||
import './title-control.js';
|
||||
import {
|
||||
FrigateCardTitleControl,
|
||||
getDefaultTitleConfigForView,
|
||||
showTitleControlAfterDelay,
|
||||
} from './title-control.js';
|
||||
|
||||
export interface MediaViewerViewContext {
|
||||
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')
|
||||
export class FrigateCardViewer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
@@ -96,10 +120,6 @@ export class FrigateCardViewer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (
|
||||
!this.hass ||
|
||||
@@ -118,7 +138,14 @@ export class FrigateCardViewer extends LitElement {
|
||||
// timeline).
|
||||
const mediaType = this.view.getDefaultMediaType();
|
||||
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') {
|
||||
@@ -129,8 +156,8 @@ export class FrigateCardViewer extends LitElement {
|
||||
this.cardWideConfig,
|
||||
this.view,
|
||||
{
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: 'recording',
|
||||
select: 'latest',
|
||||
},
|
||||
);
|
||||
} else {
|
||||
@@ -141,31 +168,26 @@ export class FrigateCardViewer extends LitElement {
|
||||
this.cardWideConfig,
|
||||
this.view,
|
||||
{
|
||||
allCameras: this.view.isGrid(),
|
||||
targetView: 'media',
|
||||
mediaType: mediaType,
|
||||
select: 'latest',
|
||||
},
|
||||
);
|
||||
}
|
||||
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
||||
}
|
||||
|
||||
return html`
|
||||
<frigate-card-viewer-carousel
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
</frigate-card-viewer-carousel>
|
||||
`;
|
||||
return html` <frigate-card-viewer-grid
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
</frigate-card-viewer-grid>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(viewerStyle);
|
||||
}
|
||||
@@ -181,6 +203,9 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public viewFilterCameraID?: string;
|
||||
|
||||
// Resetting the viewer configuration causes a full reset so ensure the config
|
||||
// has actually changed with a full comparison (dynamic configuration
|
||||
// overrides may causes changes elsewhere in the full card configuration that
|
||||
@@ -198,7 +223,13 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
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.
|
||||
@@ -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.
|
||||
* @returns A list of EmblaOptionsTypes.
|
||||
*/
|
||||
protected _getPlugins(): EmblaPluginType[] {
|
||||
protected _getPlugins(): EmblaCarouselPlugins {
|
||||
return [
|
||||
// Only enable wheel plugin if there is more than one media item.
|
||||
...(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({
|
||||
AutoLazyLoad({
|
||||
...(this.viewerConfig?.lazy_load && {
|
||||
lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide),
|
||||
}),
|
||||
}),
|
||||
AutoMediaPlugin({
|
||||
AutoMediaLoadedInfo(),
|
||||
AutoMediaActions({
|
||||
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
|
||||
...(this.viewerConfig?.auto_play && {
|
||||
autoPlayCondition: this.viewerConfig.auto_play,
|
||||
@@ -284,6 +287,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
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
|
||||
* neighbors.
|
||||
*/
|
||||
protected _getMediaNeighbors(): [ViewMedia | null, ViewMedia | null] {
|
||||
const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
|
||||
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
|
||||
if (!this.view || !this.view.queryResults || selectedIndex === null) {
|
||||
return [null, null];
|
||||
protected _getMediaNeighbors(): MediaNeighbors | null {
|
||||
const mediaCount = this._media?.length ?? 0;
|
||||
if (!this._media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previous: ViewMedia | null =
|
||||
selectedIndex > 0 ? this.view.queryResults.getResult(selectedIndex - 1) : null;
|
||||
const next: ViewMedia | null =
|
||||
selectedIndex + 1 < resultCount
|
||||
? this.view.queryResults.getResult(selectedIndex + 1)
|
||||
: null;
|
||||
return [previous, next];
|
||||
}
|
||||
|
||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
||||
this._setViewSelectedIndex(ev.detail.index);
|
||||
const prevIndex = this._selected > 0 ? this._selected - 1 : null;
|
||||
const nextIndex = this._selected + 1 < mediaCount ? this._selected + 1 : null;
|
||||
return {
|
||||
...(prevIndex !== null && {
|
||||
previous: {
|
||||
index: prevIndex,
|
||||
media: this._media[prevIndex],
|
||||
},
|
||||
}),
|
||||
...(nextIndex !== null && {
|
||||
next: {
|
||||
index: nextIndex,
|
||||
media: this._media[nextIndex],
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
protected _setViewSelectedIndex(index: number): void {
|
||||
if (!this.view?.queryResults) {
|
||||
if (!this._media) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedIndex = this.view.queryResults.getSelectedIndex();
|
||||
if (selectedIndex === null || selectedIndex === index) {
|
||||
if (this._selected === index) {
|
||||
// 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
|
||||
// index).
|
||||
return;
|
||||
}
|
||||
|
||||
const newResults = this.view?.queryResults?.clone().selectResult(index);
|
||||
const newResults = this.view?.queryResults
|
||||
?.clone()
|
||||
.selectIndex(index, this.viewFilterCameraID);
|
||||
if (!newResults) {
|
||||
return;
|
||||
}
|
||||
const cameraID = newResults.getSelectedResult()?.getCameraID();
|
||||
const cameraID = newResults
|
||||
.getSelectedResult(this.viewFilterCameraID)
|
||||
?.getCameraID();
|
||||
|
||||
this.view
|
||||
?.evolve({
|
||||
@@ -338,6 +349,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
// Always change the camera to the owner of the selected media.
|
||||
...(cameraID && { camera: cameraID }),
|
||||
})
|
||||
.removeContextProperty('mediaViewer', 'seek')
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
|
||||
@@ -354,7 +366,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
'frigate-card-viewer-provider',
|
||||
) as FrigateCardViewerProvider | null;
|
||||
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.
|
||||
*/
|
||||
protected _getSlides(): TemplateResult[] {
|
||||
if (!this.view || !this.view.queryResults) {
|
||||
if (!this._media) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const slides: TemplateResult[] = [];
|
||||
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
|
||||
const media = this.view.queryResults.getResult(i);
|
||||
for (let i = 0; i < this._media.length; ++i) {
|
||||
const media = this._media[i];
|
||||
if (media) {
|
||||
const slide = this._renderMediaItem(media, i);
|
||||
const slide = this._renderMediaItem(media);
|
||||
if (slide) {
|
||||
slides[i] = slide;
|
||||
}
|
||||
@@ -388,11 +400,25 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
if (changedProps.has('viewerConfig')) {
|
||||
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 {
|
||||
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
|
||||
if (!resultCount) {
|
||||
const mediaCount = this._media?.length ?? 0;
|
||||
if (!this._media || !mediaCount) {
|
||||
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
|
||||
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
|
||||
// avoid rendering a blank. This situation should not occur in practice, as
|
||||
// this view should not be called without a selected media.
|
||||
const media =
|
||||
this.view?.queryResults?.getSelectedResult() ??
|
||||
this.view?.queryResults?.getResult(resultCount - 1);
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.cameraManager ||
|
||||
!media ||
|
||||
!this.view ||
|
||||
!this.view.queryResults
|
||||
) {
|
||||
const selectedMedia = this._media[this._selected] ?? this._media[mediaCount - 1];
|
||||
|
||||
if (!this.hass || !this.cameraManager || !selectedMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [prev, next] = this._getMediaNeighbors();
|
||||
|
||||
const neighbors = this._getMediaNeighbors();
|
||||
const scroll = (direction: 'previous' | 'next'): void => {
|
||||
const currentIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
|
||||
if (!this.view || !this.view?.queryResults || currentIndex === null) {
|
||||
if (!neighbors || !this._media) {
|
||||
return;
|
||||
}
|
||||
const newIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (newIndex >= 0 && newIndex < this.view.queryResults.getResultsCount()) {
|
||||
const newIndex =
|
||||
(direction === 'previous' ? neighbors.previous?.index : neighbors.next?.index) ??
|
||||
null;
|
||||
if (newIndex !== null) {
|
||||
this._setViewSelectedIndex(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const cameraMetadata = this.cameraManager.getCameraMetadata(
|
||||
this.hass,
|
||||
media.getCameraID(),
|
||||
selectedMedia.getCameraID(),
|
||||
);
|
||||
|
||||
return html` <frigate-card-media-carousel
|
||||
${ref(this._refMediaCarousel)}
|
||||
.carouselOptions=${guard([this.viewerConfig], () => ({
|
||||
draggable: this.viewerConfig?.draggable ?? true,
|
||||
}))}
|
||||
.carouselPlugins=${guard(
|
||||
[this.viewerConfig, this.view.queryResults.getResults()],
|
||||
this._getPlugins.bind(this),
|
||||
)}
|
||||
.label=${media.getTitle() ?? undefined}
|
||||
.logo=${cameraMetadata?.engineLogo}
|
||||
.titlePopupConfig=${this.viewerConfig?.controls.title}
|
||||
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||
@frigate-card:media:loaded=${this._seekHandler.bind(this)}
|
||||
>
|
||||
<frigate-card-next-previous-control
|
||||
slot="previous"
|
||||
.hass=${this.hass}
|
||||
.direction=${'previous'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${prev?.getThumbnail() ?? undefined}
|
||||
.label=${prev?.getTitle() ?? ''}
|
||||
?disabled=${!prev}
|
||||
@click=${(ev) => {
|
||||
scroll('previous');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
const titleConfig = getDefaultTitleConfigForView(
|
||||
this.view,
|
||||
this.viewerConfig?.controls.title,
|
||||
);
|
||||
|
||||
return html`
|
||||
<frigate-card-carousel
|
||||
.dragEnabled=${this.viewerConfig?.draggable ?? true}
|
||||
.plugins=${guard([this.viewerConfig, this._media], this._getPlugins.bind(this))}
|
||||
.selected=${this._selected}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@frigate-card:carousel:select=${(ev: CustomEvent<CarouselSelected>) => {
|
||||
this._setViewSelectedIndex(ev.detail.index);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
${guard(this.view?.queryResults?.getResults(), () => this._getSlides())}
|
||||
<frigate-card-next-previous-control
|
||||
slot="next"
|
||||
.hass=${this.hass}
|
||||
.direction=${'next'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${next?.getThumbnail() ?? undefined}
|
||||
.label=${next?.getTitle() ?? ''}
|
||||
?disabled=${!next}
|
||||
@click=${(ev) => {
|
||||
scroll('next');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
@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-next-previous-control>
|
||||
</frigate-card-media-carousel>`;
|
||||
@frigate-card:media:unloaded=${() => {
|
||||
this._player = null;
|
||||
}}
|
||||
>
|
||||
<frigate-card-next-previous-control
|
||||
slot="previous"
|
||||
.hass=${this.hass}
|
||||
.direction=${'previous'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${neighbors?.previous?.media.getThumbnail() ?? undefined}
|
||||
.label=${neighbors?.previous?.media.getTitle() ?? ''}
|
||||
?disabled=${!neighbors?.previous}
|
||||
@click=${(ev: Event) => {
|
||||
scroll('previous');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
${guard(this._media, () => this._getSlides())}
|
||||
<frigate-card-next-previous-control
|
||||
slot="next"
|
||||
.hass=${this.hass}
|
||||
.direction=${'next'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${neighbors?.next?.media.getThumbnail() ?? undefined}
|
||||
.label=${neighbors?.next?.media.getTitle() ?? ''}
|
||||
?disabled=${!neighbors?.next}
|
||||
@click=${(ev: Event) => {
|
||||
scroll('next');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
</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> {
|
||||
const seek = this.view?.context?.mediaViewer?.seek;
|
||||
const media = this.view?.queryResults?.getSelectedResult();
|
||||
if (!this.hass || !media || !seek) {
|
||||
if (
|
||||
!this.hass ||
|
||||
!seek ||
|
||||
!this._media ||
|
||||
!this._player
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const selectedMedia = this._media[this._selected];
|
||||
if (!selectedMedia) {
|
||||
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 =
|
||||
(await this.cameraManager?.getMediaSeekTime(this.hass, media, seek)) ?? null;
|
||||
const player = this._getPlayer();
|
||||
if (player && seekTime !== null) {
|
||||
player.seek(seekTime);
|
||||
(await this.cameraManager?.getMediaSeekTime(this.hass, selectedMedia, seek)) ??
|
||||
null;
|
||||
|
||||
if (seekTime !== null) {
|
||||
this._player.seek(seekTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
protected _renderMediaItem(media: ViewMedia): TemplateResult | null {
|
||||
if (!this.hass || !this.view || !this.viewerConfig) {
|
||||
return null;
|
||||
}
|
||||
@@ -516,11 +569,8 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.disabled=${this.viewerConfig.lazy_load}
|
||||
.load=${!this.viewerConfig.lazy_load}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
|
||||
wrapMediaLoadedEventForCarousel(index, e);
|
||||
}}
|
||||
></frigate-card-viewer-provider>
|
||||
</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)
|
||||
export class FrigateCardViewerProvider
|
||||
extends LitElement
|
||||
@@ -550,10 +692,11 @@ export class FrigateCardViewerProvider
|
||||
@property({ attribute: false })
|
||||
public resolvedMediaCache?: ResolvedMediaCache;
|
||||
|
||||
// Whether or not to disable this entity. If `true`, no contents are rendered
|
||||
// until this attribute is set to `false` (this is useful for lazy loading).
|
||||
// Whether or not to load the viewer media. If `false`, no contents are
|
||||
// rendered until this attribute is set to `true` (this is useful for lazy
|
||||
// loading).
|
||||
@property({ attribute: false })
|
||||
public disabled = false;
|
||||
public load = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
@@ -679,7 +822,7 @@ export class FrigateCardViewerProvider
|
||||
return;
|
||||
}
|
||||
|
||||
const results = new MediaQueriesResults(mediaArray);
|
||||
const results = new MediaQueriesResults({ results: mediaArray });
|
||||
results.selectResultIfFound(
|
||||
(clipMedia) => clipMedia.getID() === this.media?.getID(),
|
||||
);
|
||||
@@ -700,7 +843,7 @@ export class FrigateCardViewerProvider
|
||||
const mediaContentID = this.media ? this.media.getContentID() : null;
|
||||
|
||||
if (
|
||||
(changedProps.has('disabled') ||
|
||||
(changedProps.has('load') ||
|
||||
changedProps.has('media') ||
|
||||
changedProps.has('viewerConfig') ||
|
||||
changedProps.has('resolvedMediaCache') ||
|
||||
@@ -708,7 +851,7 @@ export class FrigateCardViewerProvider
|
||||
this.hass &&
|
||||
mediaContentID &&
|
||||
!this.resolvedMediaCache?.has(mediaContentID) &&
|
||||
(!this.viewerConfig?.lazy_load || !this.disabled)
|
||||
(!this.viewerConfig?.lazy_load || this.load)
|
||||
) {
|
||||
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => {
|
||||
this.requestUpdate();
|
||||
@@ -732,7 +875,7 @@ export class FrigateCardViewerProvider
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -830,6 +973,7 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-viewer-carousel': FrigateCardViewerCarousel;
|
||||
'frigate-card-viewer': FrigateCardViewer;
|
||||
'frigate-card-viewer-grid': FrigateCardViewerGrid;
|
||||
FRIGATE_CARD_VIEWER_PROVIDER: FrigateCardViewerProvider;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,7 +199,8 @@ export class FrigateCardViews extends LitElement {
|
||||
<frigate-card-live
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.liveConfig=${this.nonOverriddenConfig.live}
|
||||
.nonOverriddenLiveConfig=${this.nonOverriddenConfig.live}
|
||||
.overriddenLiveConfig=${this.config.live}
|
||||
.conditionControllerEpoch=${this.conditionControllerEpoch}
|
||||
.liveOverrides=${getOverridesByKey('live', this.config.overrides)}
|
||||
.cameraManager=${this.cameraManager}
|
||||
|
||||
+9
-3
@@ -7,6 +7,7 @@ import {
|
||||
frigateConditionalSchema,
|
||||
OverrideConfigurationKey,
|
||||
RawFrigateCardConfig,
|
||||
ViewDisplayMode
|
||||
} from './types';
|
||||
|
||||
interface ConditionState {
|
||||
@@ -16,6 +17,7 @@ interface ConditionState {
|
||||
camera?: string;
|
||||
state?: HassEntities;
|
||||
media_loaded?: boolean;
|
||||
displayMode?: ViewDisplayMode;
|
||||
}
|
||||
|
||||
export class ConditionEvaluateRequestEvent extends Event {
|
||||
@@ -176,10 +178,10 @@ export class ConditionController {
|
||||
}
|
||||
if (condition.fullscreen !== undefined) {
|
||||
result &&=
|
||||
state.fullscreen !== undefined && condition.fullscreen == state.fullscreen;
|
||||
state.fullscreen !== undefined && condition.fullscreen === state.fullscreen;
|
||||
}
|
||||
if (condition.expand !== undefined) {
|
||||
result &&= state.expand !== undefined && condition.expand == state.expand;
|
||||
result &&= state.expand !== undefined && condition.expand === state.expand;
|
||||
}
|
||||
if (condition.camera?.length) {
|
||||
result &&= !!state.camera && condition.camera.includes(state.camera);
|
||||
@@ -198,11 +200,15 @@ export class ConditionController {
|
||||
}
|
||||
if (condition.media_loaded !== undefined) {
|
||||
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) {
|
||||
result &&= window.matchMedia(condition.media_query).matches;
|
||||
}
|
||||
if (condition.display_mode) {
|
||||
result &&= !!state.displayMode && condition.display_mode === state.displayMode;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -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_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_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_LAZY_LOAD = `${CONF_MEDIA_VIEWER}.lazy_load` as const;
|
||||
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_DURATION_SECONDS =
|
||||
`${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_POSITION_X = `${CONF_LIVE}.layout.position.x` as const;
|
||||
export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const;
|
||||
|
||||
+79
-4
@@ -78,6 +78,10 @@ import {
|
||||
CONF_LIVE_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
||||
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_LAYOUT_FIT,
|
||||
CONF_LIVE_LAYOUT_POSITION_X,
|
||||
@@ -117,6 +121,10 @@ import {
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TIMELINE_WINDOW_SECONDS,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
||||
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_LAYOUT_FIT,
|
||||
CONF_MEDIA_VIEWER_LAYOUT_POSITION_X,
|
||||
@@ -126,8 +134,8 @@ import {
|
||||
CONF_MEDIA_VIEWER_TRANSITION_EFFECT,
|
||||
CONF_MEDIA_VIEWER_ZOOMABLE,
|
||||
CONF_MENU_ALIGNMENT,
|
||||
CONF_MENU_BUTTON_SIZE,
|
||||
CONF_MENU_BUTTONS,
|
||||
CONF_MENU_BUTTON_SIZE,
|
||||
CONF_MENU_POSITION,
|
||||
CONF_MENU_STYLE,
|
||||
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 {
|
||||
BUTTON_SIZE_MIN,
|
||||
FRIGATE_MENU_PRIORITY_MAX,
|
||||
FrigateCardConfig,
|
||||
frigateCardConfigDefaults,
|
||||
FRIGATE_MENU_PRIORITY_MAX,
|
||||
RawFrigateCardConfig,
|
||||
RawFrigateCardConfigArray,
|
||||
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_TIMELINE = 'live.controls.timeline';
|
||||
const MENU_LIVE_CONTROLS_TITLE = 'live.controls.title';
|
||||
const MENU_LIVE_DISPLAY = 'live.display';
|
||||
const MENU_LIVE_LAYOUT = 'live.layout';
|
||||
const MENU_LIVE_MICROPHONE = 'live.microphone';
|
||||
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_TIMELINE = 'media_viewer.controls.timeline';
|
||||
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_OPTIONS = 'options';
|
||||
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') },
|
||||
];
|
||||
|
||||
protected _displayModes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'single', label: localize('display_modes.single') },
|
||||
{ value: 'grid', label: localize('display_modes.grid') },
|
||||
];
|
||||
|
||||
public setConfig(config: RawFrigateCardConfig): void {
|
||||
// Note: This does not use Zod to parse the configuration, so it may be
|
||||
// partially or completely invalid. It's more useful to have a partially
|
||||
@@ -979,7 +995,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
labelPath,
|
||||
{ name: 'mdi:page-layout-body' },
|
||||
html`
|
||||
${this._renderOptionSelector(configPathFit, this._layoutFits)}
|
||||
${this._renderOptionSelector(configPathFit, this._layoutFits, {
|
||||
label: localize('config.common.layout.fit'),
|
||||
})}
|
||||
${this._renderNumberInput(configPathPositionX, {
|
||||
min: 0,
|
||||
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.
|
||||
* @param domain The submenu domain.
|
||||
@@ -1785,7 +1846,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
)}`,
|
||||
)}
|
||||
${this._renderMenuButton('play') /* */}
|
||||
${this._renderMenuButton('mute')}
|
||||
${this._renderMenuButton('mute') /* */}
|
||||
${this._renderMenuButton('screenshot')}
|
||||
</div>
|
||||
`
|
||||
@@ -1826,6 +1887,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_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(
|
||||
MENU_LIVE_CONTROLS,
|
||||
true,
|
||||
@@ -1957,6 +2025,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_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(
|
||||
MENU_MEDIA_VIEWER_CONTROLS,
|
||||
true,
|
||||
|
||||
@@ -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": {
|
||||
"fit": "Layout fit",
|
||||
"fits": {
|
||||
@@ -269,6 +276,7 @@
|
||||
"cameras": "Cameras",
|
||||
"clips": "Clips",
|
||||
"download": "Download",
|
||||
"display_mode": "Display mode",
|
||||
"enabled": "Button enabled",
|
||||
"expand": "Expand",
|
||||
"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": {
|
||||
"add_new_camera": "Add new camera",
|
||||
"button": "Button",
|
||||
@@ -454,6 +466,9 @@
|
||||
"what": "What",
|
||||
"where": "Where"
|
||||
},
|
||||
"media_viewer": {
|
||||
"unseekable": "Seek time not found in media"
|
||||
},
|
||||
"media_filter": {
|
||||
"all": "All",
|
||||
"camera": "Camera",
|
||||
@@ -501,6 +516,7 @@
|
||||
"pan_behavior": {
|
||||
"pan": "Pan",
|
||||
"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"
|
||||
},
|
||||
"select_date": "Choose date"
|
||||
|
||||
@@ -148,6 +148,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"display": {
|
||||
"editor_label": "",
|
||||
"grid_columns": "",
|
||||
"grid_max_columns": "",
|
||||
"grid_selected_width_factor": "",
|
||||
"mode": ""
|
||||
},
|
||||
"layout": {
|
||||
"fit": "Adatta al layout",
|
||||
"fits": {
|
||||
@@ -267,6 +274,7 @@
|
||||
"camera_ui": "Interfaccia utente della fotocamera",
|
||||
"cameras": "Telecamere",
|
||||
"clips": "Clip",
|
||||
"display_mode": "",
|
||||
"download": "Download",
|
||||
"enabled": "Pulsante abilitato",
|
||||
"expand": "Espandere",
|
||||
@@ -358,6 +366,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"display_modes": {
|
||||
"grid": "",
|
||||
"single": ""
|
||||
},
|
||||
"editor": {
|
||||
"add_new_camera": "Aggiungi nuova telecamera",
|
||||
"button": "Pulsante",
|
||||
@@ -473,6 +485,9 @@
|
||||
},
|
||||
"where": "Dove"
|
||||
},
|
||||
"media_viewer": {
|
||||
"unseekable": ""
|
||||
},
|
||||
"recording": {
|
||||
"camera": "Camera",
|
||||
"duration": "Durata",
|
||||
@@ -490,6 +505,7 @@
|
||||
"pan_behavior": {
|
||||
"pan": "",
|
||||
"seek": "",
|
||||
"seek-in-camera": "",
|
||||
"seek-in-media": ""
|
||||
},
|
||||
"select_date": "Scegli la data"
|
||||
|
||||
@@ -148,6 +148,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"display": {
|
||||
"editor_label": "",
|
||||
"grid_columns": "",
|
||||
"grid_max_columns": "",
|
||||
"grid_selected_width_factor": "",
|
||||
"mode": ""
|
||||
},
|
||||
"layout": {
|
||||
"fit": "Ajuste de layout",
|
||||
"fits": {
|
||||
@@ -268,6 +275,7 @@
|
||||
"camera_ui": "Interface de usuário da câmera",
|
||||
"cameras": "Selecionar câmera",
|
||||
"clips": "Clipes",
|
||||
"display_mode": "",
|
||||
"download": "Baixe a mídia do evento",
|
||||
"enabled": "Botão ativado",
|
||||
"expand": "Expandir",
|
||||
@@ -363,6 +371,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"display_modes": {
|
||||
"grid": "",
|
||||
"single": ""
|
||||
},
|
||||
"editor": {
|
||||
"add_new_camera": "Adicionar nova câmera",
|
||||
"button": "Botão",
|
||||
@@ -482,6 +494,9 @@
|
||||
},
|
||||
"where": "Onde"
|
||||
},
|
||||
"media_viewer": {
|
||||
"unseekable": ""
|
||||
},
|
||||
"recording": {
|
||||
"camera": "Câmera",
|
||||
"duration": "Duração",
|
||||
@@ -500,6 +515,7 @@
|
||||
"pan_behavior": {
|
||||
"pan": "",
|
||||
"seek": "",
|
||||
"seek-in-camera": "",
|
||||
"seek-in-media": ""
|
||||
},
|
||||
"select_date": "Escolha a data"
|
||||
|
||||
@@ -148,6 +148,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"display": {
|
||||
"editor_label": "",
|
||||
"grid_columns": "",
|
||||
"grid_max_columns": "",
|
||||
"grid_selected_width_factor": "",
|
||||
"mode": ""
|
||||
},
|
||||
"layout": {
|
||||
"fit": "Fit",
|
||||
"fits": {
|
||||
@@ -260,6 +267,7 @@
|
||||
"camera_ui": "Camera",
|
||||
"cameras": "Selecionar câmera",
|
||||
"clips": "Clipes",
|
||||
"display_mode": "",
|
||||
"download": "Descarregar mídia do evento",
|
||||
"enabled": "Botão ativado",
|
||||
"expand": "Expandir",
|
||||
@@ -351,6 +359,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"display_modes": {
|
||||
"grid": "",
|
||||
"single": ""
|
||||
},
|
||||
"editor": {
|
||||
"add_new_camera": "Adicionar nova câmera",
|
||||
"button": "Botão",
|
||||
@@ -465,6 +477,9 @@
|
||||
},
|
||||
"where": "Onde"
|
||||
},
|
||||
"media_viewer": {
|
||||
"unseekable": ""
|
||||
},
|
||||
"recording": {
|
||||
"camera": "Camera",
|
||||
"duration": "Duração",
|
||||
@@ -480,9 +495,10 @@
|
||||
},
|
||||
"timeline": {
|
||||
"pan_behavior": {
|
||||
"pan": "Pan",
|
||||
"seek": "Pan seeks across all media",
|
||||
"seek-in-media": "Pan seeks within selected media item only"
|
||||
"pan": "",
|
||||
"seek": "",
|
||||
"seek-in-camera": "",
|
||||
"seek-in-media": ""
|
||||
},
|
||||
"select_date": "Selecionar a data"
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
// Keep carousel controls relative to the media carousel itself.
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.embla {
|
||||
|
||||
@@ -13,14 +13,14 @@ div.control-surround {
|
||||
padding-top: $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 {
|
||||
// Ensure the clickable area is at least 32px wide.
|
||||
padding-right: calc(32px - $drawer-icon-size);
|
||||
}
|
||||
left: 100%;
|
||||
}
|
||||
:host([location=right]) div.control-surround {
|
||||
:host([location='right']) div.control-surround {
|
||||
@if $drawer-icon-size < 32 {
|
||||
// See note above.
|
||||
padding-left: calc(32px - $drawer-icon-size);
|
||||
@@ -54,12 +54,12 @@ ha-icon.control {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
:host([location=left]) ha-icon.control {
|
||||
:host([location='left']) ha-icon.control {
|
||||
border-top-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-bottom-left-radius: $drawer-icon-size;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
height: 100%;
|
||||
flex: 0 0 100%;
|
||||
|
||||
@@ -2,6 +2,4 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
|
||||
--video-max-height: none;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
@@ -84,7 +84,6 @@ div.timeline {
|
||||
cursor: pointer;
|
||||
}
|
||||
:host([recordings]) .vis-item.vis-background,
|
||||
:host([recordings]) .vis-labelset,
|
||||
:host([recordings]) .vis-time-axis {
|
||||
cursor: crosshair;
|
||||
}
|
||||
@@ -119,8 +118,7 @@ div.timeline {
|
||||
.vis-label {
|
||||
transition: background-color 0.5s ease-out;
|
||||
}
|
||||
:host([recordings]) .vis-text.vis-minor:hover,
|
||||
:host([recordings]) .vis-label:hover {
|
||||
:host([recordings]) .vis-text.vis-minor:hover {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
:host {
|
||||
--paper-toast-background-color: rgba(0,0,0,0.6);
|
||||
--paper-toast-color: white;
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
paper-toast {
|
||||
|
||||
@@ -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 {
|
||||
height: 100%;
|
||||
flex: 0 0 100%;
|
||||
|
||||
+33
-29
@@ -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)
|
||||
*/
|
||||
@@ -231,6 +244,7 @@ const FRIGATE_CARD_ACTIONS = [
|
||||
'camera_select',
|
||||
'live_substream_select',
|
||||
'media_player',
|
||||
'display_mode_select',
|
||||
] as const;
|
||||
export type FrigateCardAction = (typeof FRIGATE_CARD_ACTIONS)[number];
|
||||
|
||||
@@ -256,6 +270,12 @@ const frigateCardMediaPlayerActionSchema = frigateCardCustomActionsBaseSchema.ex
|
||||
media_player: z.string(),
|
||||
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([
|
||||
frigateCardViewActionSchema,
|
||||
@@ -263,6 +283,7 @@ export const frigateCardCustomActionSchema = z.union([
|
||||
frigateCardCameraSelectActionSchema,
|
||||
frigateCardLiveDependencySelectActionSchema,
|
||||
frigateCardMediaPlayerActionSchema,
|
||||
frigateCardViewDisplayModeActionSchema,
|
||||
]);
|
||||
export type FrigateCardCustomAction = z.infer<typeof frigateCardCustomActionSchema>;
|
||||
|
||||
@@ -647,6 +668,7 @@ export const frigateCardConditionSchema = z.object({
|
||||
media_loaded: z.boolean().optional(),
|
||||
state: stateConditions.optional(),
|
||||
media_query: z.string().optional(),
|
||||
display_mode: viewDisplayModeSchema.optional(),
|
||||
});
|
||||
export type FrigateCardCondition = z.infer<typeof frigateCardConditionSchema>;
|
||||
|
||||
@@ -946,6 +968,7 @@ const liveConfigDefault = {
|
||||
zoomable: true,
|
||||
transition_effect: 'slide' as const,
|
||||
show_image_during_load: true,
|
||||
mode: 'single' as const,
|
||||
controls: {
|
||||
builtin: true,
|
||||
next_previous: {
|
||||
@@ -954,10 +977,6 @@ const liveConfigDefault = {
|
||||
},
|
||||
thumbnails: liveThumbnailControlsDefaults,
|
||||
timeline: miniTimelineConfigDefault,
|
||||
title: {
|
||||
mode: 'popup-bottom-right' as const,
|
||||
duration_seconds: 2,
|
||||
},
|
||||
},
|
||||
microphone: {
|
||||
...microphoneConfigDefault,
|
||||
@@ -990,16 +1009,7 @@ const liveOverridableConfigSchema = z
|
||||
liveConfigDefault.controls.thumbnails,
|
||||
),
|
||||
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
|
||||
title: titleControlConfigSchema
|
||||
.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),
|
||||
title: titleControlConfigSchema.optional(),
|
||||
})
|
||||
.default(liveConfigDefault.controls),
|
||||
show_image_during_load: z
|
||||
@@ -1008,6 +1018,7 @@ const liveOverridableConfigSchema = z
|
||||
layout: mediaLayoutConfigSchema.optional(),
|
||||
microphone: microphoneConfigSchema.default(liveConfigDefault.microphone),
|
||||
zoomable: z.boolean().default(liveConfigDefault.zoomable),
|
||||
display: viewDisplaySchema,
|
||||
})
|
||||
.merge(actionsSchema);
|
||||
|
||||
@@ -1078,6 +1089,7 @@ const menuConfigDefault = {
|
||||
play: hiddenButtonDefault,
|
||||
recordings: hiddenButtonDefault,
|
||||
screenshot: hiddenButtonDefault,
|
||||
display_mode: visibleButtonDefault,
|
||||
},
|
||||
button_size: 40,
|
||||
};
|
||||
@@ -1124,6 +1136,9 @@ const menuConfigSchema = z
|
||||
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||
display_mode: visibleButtonSchema.default(
|
||||
menuConfigDefault.buttons.display_mode,
|
||||
),
|
||||
})
|
||||
.default(menuConfigDefault.buttons),
|
||||
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
||||
@@ -1144,6 +1159,7 @@ const viewerConfigDefault = {
|
||||
zoomable: true,
|
||||
transition_effect: 'slide' as const,
|
||||
snapshot_click_plays_clip: true,
|
||||
display_mode: 'single' as const,
|
||||
controls: {
|
||||
builtin: true,
|
||||
next_previous: {
|
||||
@@ -1152,10 +1168,6 @@ const viewerConfigDefault = {
|
||||
},
|
||||
thumbnails: thumbnailControlsDefaults,
|
||||
timeline: miniTimelineConfigDefault,
|
||||
title: {
|
||||
mode: 'popup-bottom-right' as const,
|
||||
duration_seconds: 2,
|
||||
},
|
||||
},
|
||||
};
|
||||
const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({
|
||||
@@ -1190,6 +1202,7 @@ const viewerConfigSchema = z
|
||||
snapshot_click_plays_clip: z
|
||||
.boolean()
|
||||
.default(viewerConfigDefault.snapshot_click_plays_clip),
|
||||
display: viewDisplaySchema,
|
||||
controls: z
|
||||
.object({
|
||||
builtin: z.boolean().default(viewerConfigDefault.controls.builtin),
|
||||
@@ -1202,16 +1215,7 @@ const viewerConfigSchema = z
|
||||
timeline: miniTimelineConfigSchema.default(
|
||||
viewerConfigDefault.controls.timeline,
|
||||
),
|
||||
title: titleControlConfigSchema
|
||||
.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),
|
||||
title: titleControlConfigSchema.optional(),
|
||||
})
|
||||
.default(viewerConfigDefault.controls),
|
||||
layout: mediaLayoutConfigSchema.optional(),
|
||||
@@ -1373,7 +1377,7 @@ const performanceConfigDefault = {
|
||||
},
|
||||
};
|
||||
|
||||
const performanceConfigSchema = z
|
||||
export const performanceConfigSchema = z
|
||||
.object({
|
||||
profile: z.enum(['low', 'high']).default(performanceConfigDefault.profile),
|
||||
features: z
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FrigateCardCustomAction,
|
||||
frigateCardCustomActionSchema,
|
||||
FrigateCardViewAction,
|
||||
ViewDisplayMode,
|
||||
} from '../types.js';
|
||||
|
||||
/**
|
||||
@@ -42,6 +43,7 @@ export function createFrigateCardCustomAction(
|
||||
camera?: string;
|
||||
media_player?: string;
|
||||
media_player_action?: 'play' | 'stop';
|
||||
display_mode?: ViewDisplayMode;
|
||||
},
|
||||
): FrigateCardCustomAction | null {
|
||||
if (action === 'camera_select' || action === 'live_substream_select') {
|
||||
@@ -67,6 +69,17 @@ export function createFrigateCardCustomAction(
|
||||
...(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 {
|
||||
action: 'fire-dom-event',
|
||||
frigate_card_action: action,
|
||||
|
||||
@@ -221,3 +221,22 @@ export const setOrRemoveAttribute = (
|
||||
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);
|
||||
};
|
||||
|
||||
+1
-3
@@ -65,8 +65,6 @@ export function getAllDependentCameras(
|
||||
}
|
||||
}
|
||||
};
|
||||
if (cameraID) {
|
||||
getDependentCameras(cameraID);
|
||||
}
|
||||
getDependentCameras(cameraID);
|
||||
return cameraIDs;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
-67
@@ -1,17 +1,26 @@
|
||||
import { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
|
||||
import { CreateOptionsType } from 'embla-carousel/components/Options';
|
||||
import { CreatePluginType } from 'embla-carousel/components/Plugins';
|
||||
import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
|
||||
import { LazyUnloadCondition } from '../../types';
|
||||
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler';
|
||||
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
|
||||
import { LazyUnloadCondition } from '../../../../types';
|
||||
|
||||
declare module 'embla-carousel/components/Plugins' {
|
||||
interface EmblaPluginsType {
|
||||
lazyload?: AutoLazyLoadType;
|
||||
}
|
||||
}
|
||||
|
||||
type OptionsType = CreateOptionsType<{
|
||||
// Number of slides to lazyload left/right of selected (0 == only selected
|
||||
// slide).
|
||||
lazyLoadCount?: number;
|
||||
lazyLoadCount: number;
|
||||
lazyUnloadCondition?: LazyUnloadCondition;
|
||||
|
||||
lazyLoadCallback?: (index: number, slide: HTMLElement) => void;
|
||||
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
|
||||
}>;
|
||||
type AutoLazyLoadOptionsType = Partial<OptionsType>;
|
||||
type AutoLazyLoadType = CreatePluginType<LoosePluginType, AutoLazyLoadOptionsType>;
|
||||
|
||||
const defaultOptions: OptionsType = {
|
||||
active: true,
|
||||
@@ -19,74 +28,54 @@ const defaultOptions: OptionsType = {
|
||||
lazyLoadCount: 0,
|
||||
};
|
||||
|
||||
type LazyloadOptionsType = Partial<OptionsType>;
|
||||
|
||||
type LazyloadType = CreatePluginType<
|
||||
{
|
||||
hasLazyloaded(index: number): boolean;
|
||||
},
|
||||
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;
|
||||
export function AutoLazyLoad(
|
||||
userOptions: AutoLazyLoadOptionsType = {},
|
||||
): AutoLazyLoadType {
|
||||
let options: OptionsType;
|
||||
let emblaApi: EmblaCarouselType;
|
||||
let slides: HTMLElement[];
|
||||
const lazyLoadedSlides: Set<number> = new Set();
|
||||
|
||||
const loadEvents: EmblaEventType[] = ['init', 'select', 'resize'];
|
||||
const loadEvents: EmblaEventType[] = ['init', 'select'];
|
||||
const unloadEvents: EmblaEventType[] = ['select'];
|
||||
|
||||
/**
|
||||
* Initialize the plugin.
|
||||
*/
|
||||
function init(embla: EmblaCarouselType): void {
|
||||
carousel = embla;
|
||||
options = optionsHandler.atMedia(self.options);
|
||||
slides = carousel.slideNodes();
|
||||
function init(
|
||||
emblaApiInstance: EmblaCarouselType,
|
||||
optionsHandler: OptionsHandlerType,
|
||||
): void {
|
||||
const { mergeOptions, optionsAtMedia } = optionsHandler;
|
||||
const allOptions = mergeOptions(defaultOptions, userOptions);
|
||||
options = optionsAtMedia(allOptions);
|
||||
|
||||
emblaApi = emblaApiInstance;
|
||||
slides = emblaApi.slideNodes();
|
||||
|
||||
if (options.lazyLoadCallback) {
|
||||
loadEvents.forEach((evt) => carousel.on(evt, lazyLoadHandler));
|
||||
loadEvents.forEach((evt) => emblaApi.on(evt, lazyLoadHandler));
|
||||
}
|
||||
if (
|
||||
options.lazyUnloadCallback &&
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the plugin.
|
||||
*/
|
||||
function destroy(): void {
|
||||
if (options.lazyLoadCallback) {
|
||||
loadEvents.forEach((evt) => carousel.off(evt, lazyLoadHandler));
|
||||
loadEvents.forEach((evt) => emblaApi.off(evt, lazyLoadHandler));
|
||||
}
|
||||
if (options.lazyUnloadCallback) {
|
||||
unloadEvents.forEach((evt) => carousel.off(evt, lazyUnloadPreviousHandler));
|
||||
unloadEvents.forEach((evt) => emblaApi.off(evt, lazyUnloadPreviousHandler));
|
||||
}
|
||||
document.removeEventListener('visibilitychange', visibilityHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle document visibility changes.
|
||||
*/
|
||||
function visibilityHandler(): void {
|
||||
if (
|
||||
document.visibilityState === 'hidden' &&
|
||||
options.lazyUnloadCallback &&
|
||||
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 {
|
||||
return lazyLoadedSlides.has(index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazily load media in the carousel.
|
||||
*/
|
||||
function lazyLoadHandler(): void {
|
||||
const lazyLoadCount = options.lazyLoadCount ?? 0;
|
||||
const currentIndex = carousel.selectedScrollSnap();
|
||||
const lazyLoadCount = options.lazyLoadCount;
|
||||
const currentIndex = emblaApi.selectedScrollSnap();
|
||||
const slidesToLoad = new Set<number>();
|
||||
|
||||
// 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 {
|
||||
lazyLoadedSlides.forEach((index) => {
|
||||
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 {
|
||||
const index = carousel.previousScrollSnap();
|
||||
const index = emblaApi.previousScrollSnap();
|
||||
|
||||
if (hasLazyloaded(index) && options.lazyUnloadCallback) {
|
||||
options.lazyUnloadCallback(index, slides[index]);
|
||||
@@ -154,14 +129,11 @@ export function Lazyload(userOptions?: LazyloadOptionsType): LazyloadType {
|
||||
}
|
||||
}
|
||||
|
||||
const self: LazyloadType = {
|
||||
name: 'lazyload',
|
||||
options: optionsHandler.merge(optionsBase, userOptions),
|
||||
const self: AutoLazyLoadType = {
|
||||
name: 'autoLazyLoad',
|
||||
options: userOptions,
|
||||
init,
|
||||
destroy,
|
||||
hasLazyloaded,
|
||||
};
|
||||
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;
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,3 +111,43 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
+48
-39
@@ -1,20 +1,20 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
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 { View } from '../view/view';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import {
|
||||
EventMediaQueries,
|
||||
MediaQueries,
|
||||
RecordingMediaQueries,
|
||||
} 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 { View } from '../view/view';
|
||||
import { errorToConsole } from './basic';
|
||||
import { MediaQuery } from '../camera-manager/types';
|
||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const';
|
||||
import { getAllDependentCameras } from './camera.js';
|
||||
|
||||
type ResultSelectType = 'latest' | 'time' | 'none';
|
||||
|
||||
@@ -25,13 +25,16 @@ export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||
cardWideConfig: CardWideConfig,
|
||||
view: View,
|
||||
options?: {
|
||||
allCameras?: boolean;
|
||||
mediaType?: ClipsOrSnapshotsOrAll;
|
||||
targetView?: FrigateCardView;
|
||||
select?: ResultSelectType;
|
||||
},
|
||||
): Promise<void> => {
|
||||
const cameraIDs = getAllDependentCameras(cameraManager, view.camera);
|
||||
if (!cameraIDs) {
|
||||
const cameraIDs = options?.allCameras
|
||||
? cameraManager.getStore().getVisibleCameraIDs()
|
||||
: getAllDependentCameras(cameraManager, view.camera);
|
||||
if (!cameraIDs.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -84,12 +87,15 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
cardWideConfig: CardWideConfig,
|
||||
view: View,
|
||||
options?: {
|
||||
allCameras?: boolean;
|
||||
targetView?: 'recording' | 'recordings';
|
||||
select?: ResultSelectType;
|
||||
},
|
||||
): Promise<void> => {
|
||||
const cameraIDs = getAllDependentCameras(cameraManager, view.camera);
|
||||
if (!cameraIDs) {
|
||||
const cameraIDs = options?.allCameras
|
||||
? cameraManager.getStore().getVisibleCameraIDs()
|
||||
: getAllDependentCameras(cameraManager, view.camera);
|
||||
if (!cameraIDs.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,21 +116,15 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
)?.dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
export const createQueriesForRecordingsView = (
|
||||
const createQueriesForRecordingsView = (
|
||||
cameraManager: CameraManager,
|
||||
cardWideConfig: CardWideConfig,
|
||||
cameraIDs: Set<string>,
|
||||
options?: {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
},
|
||||
): RecordingMediaQueries | null => {
|
||||
const limit =
|
||||
cardWideConfig.performance?.features.media_chunk_size ?? MEDIA_CHUNK_SIZE_DEFAULT;
|
||||
const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
|
||||
limit: limit,
|
||||
...(options?.start && { start: options.start }),
|
||||
...(options?.end && { end: options.end }),
|
||||
});
|
||||
return recordingQueries ? new RecordingMediaQueries(recordingQueries) : null;
|
||||
};
|
||||
@@ -161,17 +161,13 @@ export const executeMediaQueryForView = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryResults = new MediaQueriesResults(
|
||||
mediaArray,
|
||||
options?.select === 'latest' && mediaArray.length
|
||||
? mediaArray.length - 1
|
||||
: undefined,
|
||||
);
|
||||
const queryResults = new MediaQueriesResults({ results: mediaArray });
|
||||
let viewerContext: ViewContext | undefined = {};
|
||||
const cameraID = options?.targetCameraID ?? view.camera;
|
||||
|
||||
if (options?.select === 'time' && options?.targetTime) {
|
||||
queryResults.selectBestResult((media) =>
|
||||
findBestMediaIndex(media, options.targetTime as Date),
|
||||
findBestMediaIndex(media, options.targetTime as Date, cameraID),
|
||||
);
|
||||
viewerContext = {
|
||||
mediaViewer: {
|
||||
@@ -180,16 +176,14 @@ export const executeMediaQueryForView = async (
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
view
|
||||
?.evolve({
|
||||
query: query,
|
||||
queryResults: queryResults,
|
||||
view: options?.targetView,
|
||||
camera: options?.targetCameraID,
|
||||
})
|
||||
.mergeInContext(viewerContext) ?? null
|
||||
);
|
||||
return view
|
||||
.evolve({
|
||||
query: query,
|
||||
queryResults: queryResults,
|
||||
view: options?.targetView,
|
||||
camera: cameraID,
|
||||
})
|
||||
.mergeInContext(viewerContext);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -201,12 +195,14 @@ export const executeMediaQueryForView = async (
|
||||
*/
|
||||
export const findBestMediaIndex = (
|
||||
mediaArray: ViewMedia[],
|
||||
targetTime: Date
|
||||
targetTime: Date,
|
||||
favorCameraID?: string,
|
||||
): number | null => {
|
||||
let bestMatch:
|
||||
| {
|
||||
index: number;
|
||||
duration: number;
|
||||
cameraID: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
@@ -216,8 +212,21 @@ export const findBestMediaIndex = (
|
||||
|
||||
if (media.includesTime(targetTime) && start && end) {
|
||||
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
@@ -9,6 +9,7 @@ const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1;
|
||||
|
||||
export type FrigateCardHTMLVideoElement = HTMLVideoElement & {
|
||||
_controlsHideTimer?: Timer;
|
||||
_controlsOriginalValue?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ export const setControlsOnVideo = (
|
||||
if (video._controlsHideTimer) {
|
||||
video._controlsHideTimer.stop();
|
||||
delete video._controlsHideTimer;
|
||||
delete video._controlsOriginalValue;
|
||||
}
|
||||
video.controls = value;
|
||||
};
|
||||
@@ -38,9 +40,10 @@ export const hideMediaControlsTemporarily = (
|
||||
video: FrigateCardHTMLVideoElement,
|
||||
seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS,
|
||||
): void => {
|
||||
const oldValue = video.controls;
|
||||
const oldValue = video._controlsOriginalValue ?? video.controls;
|
||||
setControlsOnVideo(video, false);
|
||||
video._controlsHideTimer ??= new Timer();
|
||||
video._controlsOriginalValue = oldValue;
|
||||
video._controlsHideTimer.start(seconds, () => {
|
||||
setControlsOnVideo(video, oldValue);
|
||||
});
|
||||
|
||||
@@ -5,11 +5,11 @@ import { CameraManager } from '../camera-manager/manager';
|
||||
import { FRIGATE_BUTTON_MENU_ICON } from '../const';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import {
|
||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||
FrigateCardConfig,
|
||||
FrigateCardCustomAction,
|
||||
MediaLoadedInfo,
|
||||
MenuButton,
|
||||
FrigateCardConfig,
|
||||
FrigateCardCustomAction,
|
||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||
MediaLoadedInfo,
|
||||
MenuButton,
|
||||
} from '../types';
|
||||
import { View } from '../view/view';
|
||||
import { createFrigateCardCustomAction } from './action';
|
||||
@@ -82,7 +82,7 @@ export class MenuButtonController {
|
||||
) as FrigateCardCustomAction,
|
||||
});
|
||||
|
||||
if (visibleCameras) {
|
||||
if (visibleCameras.size) {
|
||||
const menuItems = Array.from(visibleCameras, ([cameraID, config]) => {
|
||||
const action = createFrigateCardCustomAction('camera_select', {
|
||||
camera: cameraID,
|
||||
@@ -388,10 +388,31 @@ export class MenuButtonController {
|
||||
...config.menu.buttons.screenshot,
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
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) => ({
|
||||
style: this._getStyleFromActions(config, view, button),
|
||||
...button,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DataSet } from 'vis-data';
|
||||
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
|
||||
import { ClipsOrSnapshotsOrAll } from '../types';
|
||||
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 { ViewMedia } from '../view/media';
|
||||
import {
|
||||
@@ -98,12 +98,6 @@ export class TimelineDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
public getCacheFriendlyEventWindow(window: TimelineWindow): TimelineWindow {
|
||||
return convertRangeToCacheFriendlyTimes(window, {
|
||||
endCap: true,
|
||||
});
|
||||
}
|
||||
|
||||
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null {
|
||||
return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, {
|
||||
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(
|
||||
hass: HomeAssistant,
|
||||
window: TimelineWindow,
|
||||
@@ -127,8 +128,7 @@ export class TimelineDataSource {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window);
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
|
||||
const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow);
|
||||
if (!eventQueries) {
|
||||
return;
|
||||
@@ -137,7 +137,6 @@ export class TimelineDataSource {
|
||||
const mediaArray = await this._cameraManager.executeMediaQueries(hass, eventQueries);
|
||||
const data: FrigateCardTimelineItem[] = [];
|
||||
for (const media of mediaArray ?? []) {
|
||||
const endTime = media.getEndTime();
|
||||
const startTime = media.getStartTime();
|
||||
const id = media.getID();
|
||||
if (id && startTime) {
|
||||
@@ -148,7 +147,7 @@ export class TimelineDataSource {
|
||||
media: media,
|
||||
start: startTime.getTime(),
|
||||
type: 'range',
|
||||
end: endTime?.getTime() ?? startTime.getTime(),
|
||||
end: media.getUsableEndTime()?.getTime() ?? startTime.getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -217,10 +216,7 @@ export class TimelineDataSource {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window, {
|
||||
endCap: true,
|
||||
});
|
||||
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
|
||||
const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries(
|
||||
this._cameraIDs,
|
||||
{
|
||||
|
||||
+8
-9
@@ -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
|
||||
* type name entirely. */
|
||||
const contenders = new Set<string>();
|
||||
if (error && error.issues) {
|
||||
for (let i = 0; i < error.issues.length; i++) {
|
||||
const issue = error.issues[i];
|
||||
if (issue.code == 'invalid_union') {
|
||||
if (error.issues.length) {
|
||||
for (const issue of error.issues) {
|
||||
if (issue.code === 'invalid_union') {
|
||||
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
|
||||
for (let j = 0; j < unionErrors.length; j++) {
|
||||
const nestedErrors = getParseErrorPaths(unionErrors[j]);
|
||||
for (const unionError of unionErrors) {
|
||||
const nestedErrors = getParseErrorPaths(unionError);
|
||||
if (nestedErrors && nestedErrors.size) {
|
||||
nestedErrors.forEach(contenders.add, contenders);
|
||||
}
|
||||
}
|
||||
} else if (issue.code == 'invalid_type') {
|
||||
if (issue.path[issue.path.length - 1] == 'type') {
|
||||
} else if (issue.code === 'invalid_type') {
|
||||
if (issue.path[issue.path.length - 1] === 'type') {
|
||||
return null;
|
||||
}
|
||||
contenders.add(getParseErrorPathString(issue.path));
|
||||
} else if (issue.code != 'custom') {
|
||||
} else {
|
||||
contenders.add(getParseErrorPathString(issue.path));
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -141,15 +141,20 @@ export class Zoom {
|
||||
}
|
||||
|
||||
public deactivate(): void {
|
||||
const unregisterListener = (events: string[], func: (ev: Event) => void) => {
|
||||
const unregisterListener = (
|
||||
events: string[],
|
||||
func: (ev: Event) => void,
|
||||
options?: EventListenerOptions,
|
||||
) => {
|
||||
events.forEach((eventName) => {
|
||||
this._element.removeEventListener(eventName, func);
|
||||
this._element.removeEventListener(eventName, func, options);
|
||||
});
|
||||
};
|
||||
|
||||
unregisterListener(this._events['down'], this._downHandler);
|
||||
unregisterListener(this._events['move'], this._moveHandler);
|
||||
unregisterListener(this._events['up'], this._upHandler);
|
||||
unregisterListener(this._events['down'], this._downHandler, { capture: true });
|
||||
unregisterListener(this._events['move'], this._moveHandler, { capture: true });
|
||||
unregisterListener(this._events['up'], this._upHandler, { capture: true });
|
||||
unregisterListener(['wheel'], this._wheelHandler);
|
||||
unregisterListener(['click'], this._clickHandler, { capture: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventMediaQueries, MediaQueries, RecordingMediaQueries } from './media-queries';
|
||||
|
||||
export type MediaQueriesType = 'event' | 'recording';
|
||||
export class MediaQueriesClassifier {
|
||||
public static areEventQueries(
|
||||
queries?: MediaQueries | null,
|
||||
@@ -12,4 +13,12 @@ export class MediaQueriesClassifier {
|
||||
): queries is RecordingMediaQueries {
|
||||
return queries instanceof RecordingMediaQueries;
|
||||
}
|
||||
|
||||
public static getQueriesType(queries?: MediaQueries | null): MediaQueriesType | null {
|
||||
return this.areEventQueries(queries)
|
||||
? 'event'
|
||||
: this.areRecordingQueries(queries)
|
||||
? 'recording'
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,151 @@
|
||||
import clone from 'lodash-es/clone.js';
|
||||
import { isSuperset } from '../utils/basic.js';
|
||||
import { ViewMedia } from './media.js';
|
||||
|
||||
export class MediaQueriesResults {
|
||||
protected _results: ViewMedia[] | null = null;
|
||||
protected _resultsTimestamp: Date | null = null;
|
||||
protected _selectedIndex: number | null = null;
|
||||
type CameraResultSlices = Map<string, ResultSlice>;
|
||||
type SelectApproach = 'first' | 'last';
|
||||
|
||||
constructor(results?: ViewMedia[], selectedIndex?: number | null) {
|
||||
if (results) {
|
||||
this.setResults(results);
|
||||
interface ResultSliceOptions {
|
||||
results?: ViewMedia[];
|
||||
selectedIndex?: number | null;
|
||||
selectApproach?: SelectApproach;
|
||||
}
|
||||
|
||||
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 (selectedIndex !== undefined) {
|
||||
this.selectResult(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 {
|
||||
// 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
|
||||
// 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).
|
||||
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 {
|
||||
if (!this._results || !that._results) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const thisMediaIDs = new Set(this._results.map((media) => media.getID()));
|
||||
const thatMediaIDs = new Set(that._results.map((media) => media.getID()));
|
||||
const thisMediaIDs = new Set(this._main.getResults()?.map((media) => media.getID()));
|
||||
const thatMediaIDs = new Set(that._main.getResults()?.map((media) => media.getID()));
|
||||
|
||||
if (
|
||||
!thisMediaIDs ||
|
||||
!thatMediaIDs ||
|
||||
!thisMediaIDs.size ||
|
||||
!thatMediaIDs.size ||
|
||||
// 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
|
||||
// reduce to the same null identifier above.
|
||||
@@ -46,68 +157,124 @@ export class MediaQueriesResults {
|
||||
return isSuperset(thisMediaIDs, thatMediaIDs);
|
||||
}
|
||||
|
||||
public getResults(): ViewMedia[] | null {
|
||||
return this._results;
|
||||
public getCameraIDs(): Set<string> {
|
||||
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[]) {
|
||||
this._results = results;
|
||||
this._resultsTimestamp = new Date();
|
||||
public getResultsCount(cameraID?: string): number {
|
||||
return this.getSlice(cameraID)?.getResultsCount() ?? 0;
|
||||
}
|
||||
public getResult(index?: number): ViewMedia | null {
|
||||
if (!this._results || index === undefined) {
|
||||
return null;
|
||||
public hasResults(cameraID?: string): boolean {
|
||||
return this.getSlice(cameraID)?.getResultsCount() !== 0;
|
||||
}
|
||||
public getResult(index?: number, cameraID?: string): ViewMedia | null {
|
||||
return this.getSlice(cameraID)?.getResult(index) ?? null;
|
||||
}
|
||||
public getSelectedIndex(cameraID?: string): number | null {
|
||||
return this.getSlice(cameraID)?.getSelectedIndex() ?? null;
|
||||
}
|
||||
public getSelectedResult(cameraID?: string): ViewMedia | null {
|
||||
return this.getSlice(cameraID)?.getSelectedResult() ?? null;
|
||||
}
|
||||
public getMultipleSelectedResults(
|
||||
criteria?: ResultSliceSelectionCriteria,
|
||||
): ViewMedia[] {
|
||||
const results: ViewMedia[] = [];
|
||||
if (!criteria || criteria.main) {
|
||||
const mainResult = this.getSelectedResult();
|
||||
if (mainResult) {
|
||||
results.push(mainResult);
|
||||
}
|
||||
}
|
||||
return this._results[index];
|
||||
const cameraIDs = this._getCameraIDsFromCriteria(criteria);
|
||||
for (const cameraID of cameraIDs ?? []) {
|
||||
const result = this.getSelectedResult(cameraID);
|
||||
if (result) {
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
public getSelectedResult(): ViewMedia | null {
|
||||
return this._selectedIndex === null ? null : this.getResult(this._selectedIndex);
|
||||
public hasSelectedResult(cameraID?: string): boolean {
|
||||
return this.getSlice(cameraID)?.hasSelectedResult() ?? false;
|
||||
}
|
||||
public getSelectedIndex(): number | null {
|
||||
return this._selectedIndex;
|
||||
}
|
||||
public hasSelectedResult(): boolean {
|
||||
return this.getSelectedResult() !== null;
|
||||
}
|
||||
public resetSelectedResult(): MediaQueriesResults {
|
||||
this._selectedIndex = null;
|
||||
public resetSelectedResult(cameraID?: string): MediaQueriesResults {
|
||||
this.getSlice(cameraID)?.resetSelectedResult();
|
||||
return this;
|
||||
}
|
||||
public getResultsTimestamp(): Date | null {
|
||||
return this._resultsTimestamp;
|
||||
}
|
||||
|
||||
public selectResult(index: number | null): MediaQueriesResults {
|
||||
if (
|
||||
index === null ||
|
||||
(this._results && index >= 0 && index < this._results.length)
|
||||
) {
|
||||
this._selectedIndex = index;
|
||||
public selectIndex(index: number, cameraID?: string): MediaQueriesResults {
|
||||
this.getSlice(cameraID)?.selectIndex(index);
|
||||
if (!cameraID) {
|
||||
// If the main selection is changed, it must also change the matching
|
||||
// camera selection.
|
||||
this.demoteMainSelectionToCameraSelection();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
public selectResultIfFound(func: (media: ViewMedia) => boolean): MediaQueriesResults {
|
||||
for (const [index, result] of this._results?.entries() ?? []) {
|
||||
if (func(result)) {
|
||||
this._selectedIndex = index;
|
||||
break;
|
||||
}
|
||||
|
||||
public demoteMainSelectionToCameraSelection(): MediaQueriesResults {
|
||||
const selected = this.getSelectedResult();
|
||||
if (selected) {
|
||||
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;
|
||||
}
|
||||
public selectBestResult(
|
||||
func: (media: ViewMedia[]) => number | null,
|
||||
criteria?: ResultSliceSelectionCriteria,
|
||||
): MediaQueriesResults {
|
||||
if (this._results) {
|
||||
const resultIndex = func(this._results);
|
||||
if (resultIndex !== null) {
|
||||
this._selectedIndex = resultIndex;
|
||||
}
|
||||
if (!criteria || criteria.main) {
|
||||
this._main.selectBestResult(func);
|
||||
this.demoteMainSelectionToCameraSelection();
|
||||
}
|
||||
const cameraIDs = this._getCameraIDsFromCriteria(criteria);
|
||||
for (const cameraID of cameraIDs ?? []) {
|
||||
this.getSlice(cameraID)?.selectBestResult(func);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 { setify } from '../utils/basic.js';
|
||||
|
||||
export type MediaQueries = EventMediaQueries | RecordingMediaQueries;
|
||||
|
||||
@@ -12,7 +15,7 @@ class MediaQueriesBase<T extends MediaQuery> {
|
||||
}
|
||||
}
|
||||
|
||||
public clone(): MediaQueriesBase<T> {
|
||||
public clone(): this {
|
||||
return cloneDeep(this);
|
||||
}
|
||||
|
||||
@@ -20,21 +23,51 @@ class MediaQueriesBase<T extends MediaQuery> {
|
||||
return this._queries;
|
||||
}
|
||||
|
||||
public setQueries(queries: T[]): void {
|
||||
public setQueries(queries: T[]): this {
|
||||
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> {
|
||||
public convertToClipsQueries(): void {
|
||||
public convertToClipsQueries(): this {
|
||||
for (const query of this._queries ?? []) {
|
||||
delete query.hasSnapshot;
|
||||
query.hasClip = true;
|
||||
}
|
||||
}
|
||||
|
||||
public clone(): EventMediaQueries {
|
||||
return cloneDeep(this);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,6 @@ export class ViewMedia {
|
||||
this._mediaType = mediaType;
|
||||
this._cameraID = cameraID;
|
||||
}
|
||||
public getContentType(): 'image' | 'video' {
|
||||
return this._mediaType === 'snapshot' ? 'image' : 'video';
|
||||
}
|
||||
public getCameraID(): string {
|
||||
return this._cameraID;
|
||||
}
|
||||
|
||||
+35
-9
@@ -1,5 +1,5 @@
|
||||
import { ViewContext } from 'view';
|
||||
import { ClipsOrSnapshots, FrigateCardView } from '../types.js';
|
||||
import { ClipsOrSnapshots, FrigateCardView, ViewDisplayMode } from '../types.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { MediaQueries } from './media-queries';
|
||||
import { MediaQueriesClassifier } from './media-queries-classifier.js';
|
||||
@@ -11,6 +11,7 @@ interface ViewEvolveParameters {
|
||||
query?: MediaQueries | null;
|
||||
queryResults?: MediaQueriesResults | null;
|
||||
context?: ViewContext | null;
|
||||
displayMode?: ViewDisplayMode | null;
|
||||
}
|
||||
|
||||
export interface ViewParameters extends ViewEvolveParameters {
|
||||
@@ -24,6 +25,7 @@ export class View {
|
||||
public query: MediaQueries | null;
|
||||
public queryResults: MediaQueriesResults | null;
|
||||
public context: ViewContext | null;
|
||||
public displayMode: ViewDisplayMode | null;
|
||||
|
||||
constructor(params: ViewParameters) {
|
||||
this.view = params.view;
|
||||
@@ -31,6 +33,7 @@ export class View {
|
||||
this.query = params.query ?? null;
|
||||
this.queryResults = params.queryResults ?? null;
|
||||
this.context = params.context ?? null;
|
||||
this.displayMode = params.displayMode ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,7 +91,12 @@ export class View {
|
||||
let currentQueriesView: ClipsOrSnapshots | 'recordings' | null = null;
|
||||
if (MediaQueriesClassifier.areEventQueries(curr.query)) {
|
||||
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';
|
||||
} else if (queries?.every((query) => query.hasSnapshot)) {
|
||||
currentQueriesView = 'snapshots';
|
||||
@@ -103,13 +111,9 @@ export class View {
|
||||
const switchingToMediaFromMedia = curr?.is('media') && next.is('media');
|
||||
|
||||
if (hasNoQueryOrResults) {
|
||||
if (switchingToGalleryFromViewer) {
|
||||
if (curr.query) {
|
||||
next.query = curr.query;
|
||||
}
|
||||
if (curr.queryResults) {
|
||||
next.queryResults = curr.queryResults;
|
||||
}
|
||||
if (switchingToGalleryFromViewer && curr.query && curr.queryResults) {
|
||||
next.query = curr.query;
|
||||
next.queryResults = curr.queryResults;
|
||||
} else if (switchingToMediaFromMedia && currentQueriesView) {
|
||||
next.view =
|
||||
currentQueriesView === 'clips'
|
||||
@@ -142,6 +146,7 @@ export class View {
|
||||
query: this.query?.clone() ?? null,
|
||||
queryResults: this.queryResults?.clone() ?? null,
|
||||
context: this.context,
|
||||
displayMode: this.displayMode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,6 +165,8 @@ export class View {
|
||||
? params.queryResults
|
||||
: this.queryResults?.clone() ?? null,
|
||||
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;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -214,6 +232,10 @@ export class 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.
|
||||
* @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown
|
||||
@@ -232,6 +254,10 @@ export class View {
|
||||
return null;
|
||||
}
|
||||
|
||||
public isGrid(): boolean {
|
||||
return this.displayMode === 'grid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event to request a view change.
|
||||
* @param target The target dispatching the event.
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('RecordingSegmentsCache', () => {
|
||||
start: now,
|
||||
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 => {
|
||||
return {
|
||||
start_time: date.getTime() / 1000,
|
||||
@@ -89,7 +89,7 @@ describe('RecordingSegmentsCache', () => {
|
||||
});
|
||||
it('should not get for other range', () => {
|
||||
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', () => {
|
||||
@@ -102,7 +102,7 @@ describe('RecordingSegmentsCache', () => {
|
||||
});
|
||||
it('should not have coverage for other range', () => {
|
||||
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', () => {
|
||||
@@ -114,11 +114,11 @@ describe('RecordingSegmentsCache', () => {
|
||||
|
||||
it('should get size', () => {
|
||||
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', () => {
|
||||
cache.add('camera-1', range, segments);
|
||||
expect(cache.getSize("camera-2")).toBeNull();
|
||||
expect(cache.getSize('camera-2')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return cameraIDs', () => {
|
||||
@@ -127,6 +127,24 @@ describe('RecordingSegmentsCache', () => {
|
||||
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', () => {
|
||||
cache.add('camera-1', range, segments);
|
||||
cache.expireMatches('camera-1', (segment) => segment === segments[0]);
|
||||
|
||||
@@ -100,6 +100,11 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
|
||||
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 () => {
|
||||
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
|
||||
const entityRegistryManager = new EntityRegistryManager(new EntityCache());
|
||||
|
||||
@@ -135,6 +135,7 @@ describe('compressRanges', () => {
|
||||
compressRanges([
|
||||
{ start: now, end: nowPlusOne },
|
||||
{ start: nowPlusOne, end: nowPlusTwo },
|
||||
{ start: now, end: nowPlusOne },
|
||||
]),
|
||||
).toEqual([{ start: now, end: nowPlusTwo }]);
|
||||
});
|
||||
@@ -162,4 +163,8 @@ describe('compressRanges', () => {
|
||||
];
|
||||
expect(compressRanges(input)).toEqual([{ start: 1, end: 3 }]);
|
||||
});
|
||||
|
||||
it('should return nothing with no input', () => {
|
||||
expect(compressRanges([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,30 +76,26 @@ describe('capEndDate', () => {
|
||||
});
|
||||
|
||||
describe('sortMedia', () => {
|
||||
const media_1 = new TestViewMedia(
|
||||
'id-1',
|
||||
new Date('2023-04-29T14:25'),
|
||||
'clip',
|
||||
'camera-1',
|
||||
);
|
||||
const media_2 = new TestViewMedia(
|
||||
'id-2',
|
||||
new Date('2023-04-29T14:26'),
|
||||
'clip',
|
||||
'camera-1',
|
||||
);
|
||||
const media_3_dup_id = new TestViewMedia(
|
||||
'id-2',
|
||||
new Date('2023-04-29T14:26'),
|
||||
'clip',
|
||||
'camera-1',
|
||||
);
|
||||
const media_4_no_id = new TestViewMedia(
|
||||
null,
|
||||
new Date('2023-04-29T14:27'),
|
||||
'clip',
|
||||
'camera-1',
|
||||
);
|
||||
const media_1 = new TestViewMedia({
|
||||
id: 'id-1',
|
||||
startTime: new Date('2023-04-29T14:25'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
const media_2 = new TestViewMedia({
|
||||
id: 'id-2',
|
||||
startTime: new Date('2023-04-29T14:26'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
const media_3_dup_id = new TestViewMedia({
|
||||
id: 'id-2',
|
||||
startTime: new Date('2023-04-29T14:26'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
const media_4_no_id = new TestViewMedia({
|
||||
id: null,
|
||||
startTime: new Date('2023-04-29T14:27'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
|
||||
it('should sort sorted media', () => {
|
||||
const media = [media_1, media_2];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ConditionController,
|
||||
ConditionEvaluateRequestEvent,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getOverriddenConfig,
|
||||
getOverridesByKey,
|
||||
} from '../src/conditions';
|
||||
import { FrigateCardCondition } from '../src/types';
|
||||
import { createCondition, createConfig, createStateEntity } from './test-utils';
|
||||
|
||||
// @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', () => {
|
||||
@@ -347,4 +355,14 @@ describe('ConditionController', () => {
|
||||
controller.destroy();
|
||||
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
@@ -9,6 +9,7 @@ import {
|
||||
CameraConfigs,
|
||||
CameraManagerCameraCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
QueryType,
|
||||
} from '../src/camera-manager/types';
|
||||
import {
|
||||
CameraConfig,
|
||||
@@ -16,10 +17,12 @@ import {
|
||||
FrigateCardCondition,
|
||||
FrigateCardConfig,
|
||||
MediaLoadedInfo,
|
||||
PerformanceConfig,
|
||||
RawFrigateCardConfig,
|
||||
cameraConfigSchema,
|
||||
frigateCardConditionSchema,
|
||||
frigateCardConfigSchema,
|
||||
performanceConfigSchema,
|
||||
} from '../src/types';
|
||||
import { Entity } from '../src/utils/ha/entity-registry/types';
|
||||
import { ViewMedia, ViewMediaType } from '../src/view/media';
|
||||
@@ -126,11 +129,25 @@ export const createCameraManager = (options?: {
|
||||
const configs = options?.configs ?? new Map([['camera', createCameraConfig()]]);
|
||||
vi.mocked(store.getCameras).mockReturnValue(configs);
|
||||
vi.mocked(store.getVisibleCameras).mockReturnValue(configs);
|
||||
vi.mocked(store.getVisibleCameraIDs).mockReturnValue(new Set(configs.keys()));
|
||||
vi.mocked(store.getCameraConfig).mockImplementation((cameraID): CameraConfig => {
|
||||
return configs.get(cameraID) ?? createCameraConfig();
|
||||
});
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
// to an engine.
|
||||
export class TestViewMedia extends ViewMedia {
|
||||
protected _id: string | null;
|
||||
protected _startTime: Date;
|
||||
protected _startTime: Date | null;
|
||||
protected _endTime: Date | null;
|
||||
protected _inProgress: boolean | null;
|
||||
|
||||
constructor(
|
||||
id: string | null,
|
||||
startTime: Date,
|
||||
mediaType: ViewMediaType,
|
||||
cameraID: string,
|
||||
) {
|
||||
super(mediaType, cameraID);
|
||||
this._id = id;
|
||||
this._startTime = startTime;
|
||||
constructor(options?: {
|
||||
id?: string | null;
|
||||
startTime?: Date;
|
||||
mediaType?: ViewMediaType;
|
||||
cameraID?: string;
|
||||
endTime?: Date;
|
||||
inProgress?: boolean;
|
||||
}) {
|
||||
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 {
|
||||
return this._id;
|
||||
@@ -191,4 +231,61 @@ export class TestViewMedia extends ViewMedia {
|
||||
public getStartTime(): Date | null {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -103,6 +103,24 @@ describe('createFrigateCardCustomAction', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -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 {
|
||||
allPromises,
|
||||
arrayify,
|
||||
arrayMove,
|
||||
arrayify,
|
||||
contentsChanged,
|
||||
dayToDate,
|
||||
dispatchFrigateCardEvent,
|
||||
errorToConsole,
|
||||
formatDate,
|
||||
formatDateAndTime,
|
||||
getChildrenFromElement,
|
||||
getDurationString,
|
||||
isHTMLElement,
|
||||
isHoverableDevice,
|
||||
isSuperset,
|
||||
isTruthy,
|
||||
isValidDate,
|
||||
prettifyTitle,
|
||||
runWhenIdleIfSupported,
|
||||
setify,
|
||||
setOrRemoveAttribute,
|
||||
setify,
|
||||
sleep,
|
||||
} from '../../src/utils/basic';
|
||||
import { createSlot, createSlotHost } from '../test-utils';
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('dispatchFrigateCardEvent', () => {
|
||||
@@ -181,6 +185,11 @@ describe('getDurationString', () => {
|
||||
const end = new Date(2023, 3, 14, 15, 37, 20);
|
||||
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', () => {
|
||||
@@ -227,7 +236,13 @@ describe('isValidDate', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
setOrRemoveAttribute(element, true, 'key', 'value');
|
||||
expect(element.getAttribute('key')).toBe('value');
|
||||
@@ -240,3 +255,39 @@ describe('setOrRemoveAttribute', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,13 +12,66 @@ vi.mock('../../src/utils/ha');
|
||||
const media = new ViewMedia('clip', 'camera-1');
|
||||
|
||||
// @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', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
global.window.location = mock<Location>();
|
||||
});
|
||||
|
||||
it('should throw error when no media', async () => {
|
||||
it('should throw error when no media', () => {
|
||||
const cameraManager = createCameraManager();
|
||||
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue(null);
|
||||
|
||||
@@ -55,44 +108,16 @@ describe('downloadMedia', () => {
|
||||
await downloadMedia(createHASS(), cameraManager, media);
|
||||
expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadURL', () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
global.window.location = mock<Location>();
|
||||
});
|
||||
|
||||
it('should download same origin via link', async () => {
|
||||
const location: Location & { origin: string } = mock<Location>();
|
||||
location.origin = 'http://foo';
|
||||
global.window.location = location;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.click = vi.fn();
|
||||
link.setAttribute = vi.fn();
|
||||
vi.spyOn(document, 'createElement').mockReturnValue(link);
|
||||
|
||||
downloadURL('http://foo/url.mp4');
|
||||
|
||||
expect(link.href).toBe('http://foo/url.mp4');
|
||||
expect(link.setAttribute).toBeCalledWith('download', 'download');
|
||||
expect(link.click).toBeCalled();
|
||||
});
|
||||
|
||||
it('should download in apps via window.open', async () => {
|
||||
// Set the origin to the same.
|
||||
const location: Location & { origin: string } = mock<Location>();
|
||||
location.origin = 'http://foo';
|
||||
global.window.location = location;
|
||||
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)',
|
||||
it('should download media without signing', async () => {
|
||||
const cameraManager = createCameraManager();
|
||||
mock<CameraManager>(cameraManager).getMediaDownloadPath.mockResolvedValue({
|
||||
sign: false,
|
||||
endpoint: 'https://foo/',
|
||||
});
|
||||
|
||||
const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
|
||||
downloadURL('http://foo/url.mp4');
|
||||
expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank');
|
||||
await downloadMedia(createHASS(), cameraManager, media);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { mock } from 'vitest-mock-extended';
|
||||
import { FrigateCardMediaPlayer } from '../../src/types.js';
|
||||
import {
|
||||
FrigateCardHTMLVideoElement,
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
hideMediaControlsTemporarily,
|
||||
playMediaMutingIfNecessary,
|
||||
setControlsOnVideo,
|
||||
@@ -107,4 +108,20 @@ describe('playMediaMutingIfNecessary', () => {
|
||||
expect(player.isMuted).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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import screenfull from 'screenfull';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
FrigateCardMediaPlayer,
|
||||
MediaLoadedInfo,
|
||||
MenuButton,
|
||||
ViewDisplayMode,
|
||||
} from '../../src/types';
|
||||
import { createFrigateCardCustomAction } from '../../src/utils/action';
|
||||
import { MenuButtonController } from '../../src/utils/menu-controller';
|
||||
@@ -67,6 +69,7 @@ const calculateButtons = (
|
||||
);
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MenuButtonController', () => {
|
||||
let controller: MenuButtonController;
|
||||
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', () => {
|
||||
const cameraManager = createCameraManager({
|
||||
configs: new Map([
|
||||
@@ -612,7 +635,10 @@ describe('MenuButtonController', () => {
|
||||
|
||||
const cameraManager = createCameraManager();
|
||||
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(
|
||||
createMediaCapabilities({ canDownload: true }),
|
||||
@@ -640,7 +666,10 @@ describe('MenuButtonController', () => {
|
||||
|
||||
const cameraManager = createCameraManager();
|
||||
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(
|
||||
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', () => {
|
||||
const button: MenuButton = {
|
||||
...dynamicButton,
|
||||
style: {},
|
||||
};
|
||||
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);
|
||||
expect(calculateButtons(controller)).not.toContainEqual(button);
|
||||
|
||||
@@ -62,32 +62,30 @@ describe('generateScreenshotTitle', () => {
|
||||
});
|
||||
|
||||
it('should get title for media viewer view with id', () => {
|
||||
const media = new TestViewMedia(
|
||||
'id1',
|
||||
new Date('2023-06-16T18:52'),
|
||||
'clip',
|
||||
'camera-1',
|
||||
);
|
||||
const media = new TestViewMedia({
|
||||
id: 'id1',
|
||||
startTime: new Date('2023-06-16T18:52'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
const view = createView({
|
||||
view: 'media',
|
||||
camera: 'camera-1',
|
||||
queryResults: new MediaQueriesResults([media], 0),
|
||||
queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }),
|
||||
});
|
||||
|
||||
expect(generateScreenshotTitle(view)).toBe('media-camera-1-id1.jpg');
|
||||
});
|
||||
|
||||
it('should get title for media viewer view without id', () => {
|
||||
const media = new TestViewMedia(
|
||||
null,
|
||||
new Date('2023-06-16T18:52'),
|
||||
'clip',
|
||||
'camera-1',
|
||||
);
|
||||
const media = new TestViewMedia({
|
||||
id: null,
|
||||
startTime: new Date('2023-06-16T18:52'),
|
||||
cameraID: 'camera-1',
|
||||
});
|
||||
const view = createView({
|
||||
view: 'media',
|
||||
camera: 'camera-1',
|
||||
queryResults: new MediaQueriesResults([media], 0),
|
||||
queryResults: new MediaQueriesResults({ results: [media], selectedIndex: 0 }),
|
||||
});
|
||||
|
||||
expect(generateScreenshotTitle(view)).toBe('media-camera-1.jpg');
|
||||
|
||||
@@ -52,6 +52,20 @@ describe('createViewWithoutSubstream', () => {
|
||||
const newView = createViewWithoutSubstream(view);
|
||||
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', () => {
|
||||
@@ -95,7 +109,7 @@ describe('createViewWithNextStream', () => {
|
||||
camera: 'camera',
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera']));
|
||||
const cameraManager = createCameraManager()
|
||||
const cameraManager = createCameraManager();
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.camera).toBe(view.camera);
|
||||
expect(newView.view).toBe(view.view);
|
||||
@@ -107,7 +121,7 @@ describe('createViewWithNextStream', () => {
|
||||
camera: 'camera',
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||
const cameraManager = createCameraManager()
|
||||
const cameraManager = createCameraManager();
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera2']]));
|
||||
});
|
||||
@@ -122,7 +136,7 @@ describe('createViewWithNextStream', () => {
|
||||
},
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||
const cameraManager = createCameraManager()
|
||||
const cameraManager = createCameraManager();
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
||||
});
|
||||
@@ -137,7 +151,7 @@ describe('createViewWithNextStream', () => {
|
||||
},
|
||||
});
|
||||
vi.mocked(getAllDependentCameras).mockReturnValue(new Set(['camera', 'camera2']));
|
||||
const cameraManager = createCameraManager()
|
||||
const cameraManager = createCameraManager();
|
||||
const newView = createViewWithNextStream(cameraManager, view);
|
||||
expect(newView.context?.live?.overrides).toEqual(new Map([['camera', 'camera']]));
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { z } from 'zod';
|
||||
import { z, ZodError } from 'zod';
|
||||
import {
|
||||
deepRemoveDefaults,
|
||||
getParseErrorKeys,
|
||||
getParseErrorPaths,
|
||||
deepRemoveDefaults,
|
||||
getParseErrorKeys,
|
||||
getParseErrorPaths,
|
||||
} from '../../src/utils/zod';
|
||||
|
||||
describe('deepRemoveDefaults', () => {
|
||||
@@ -88,4 +88,7 @@ describe('getParseErrorPaths', () => {
|
||||
new Set(['array[0] -> type', 'array[0] -> data']),
|
||||
);
|
||||
});
|
||||
it('should get no paths for empty error', () => {
|
||||
expect(getParseErrorPaths(new ZodError([]))).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,6 +92,27 @@ describe('Zoom', () => {
|
||||
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', () => {
|
||||
mediaMediSpy.mockReturnValue(<MediaQueryList>{ matches: false });
|
||||
|
||||
@@ -252,4 +273,50 @@ describe('Zoom', () => {
|
||||
element.dispatchEvent(ev_2);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 { ViewMedia } from '../../src/view/media';
|
||||
import { EventMediaQueries, RecordingMediaQueries } from '../../src/view/media-queries';
|
||||
@@ -52,6 +52,7 @@ describe('View Basics', () => {
|
||||
query: new EventMediaQueries(),
|
||||
queryResults: new MediaQueriesResults(),
|
||||
context: {},
|
||||
displayMode: 'single',
|
||||
});
|
||||
|
||||
const evolved = view.evolve({
|
||||
@@ -60,12 +61,14 @@ describe('View Basics', () => {
|
||||
query: new EventMediaQueries(),
|
||||
queryResults: new MediaQueriesResults(),
|
||||
context: {},
|
||||
displayMode: 'grid',
|
||||
});
|
||||
expect(evolved.view).not.toBe(view.view);
|
||||
expect(evolved.camera).not.toBe(view.camera);
|
||||
expect(evolved.query).not.toBe(view.query);
|
||||
expect(evolved.queryResults).not.toBe(view.queryResults);
|
||||
expect(evolved.context).not.toBe(view.context);
|
||||
expect(evolved.displayMode).not.toBe(view.displayMode);
|
||||
});
|
||||
|
||||
it('should evolve with nothing set', () => {
|
||||
@@ -119,6 +122,28 @@ describe('View Basics', () => {
|
||||
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', () => {
|
||||
expect(createView({ view: 'clips' }).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', () => {
|
||||
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
|
||||
const queryResults_1 = new MediaQueriesResults(media, 0);
|
||||
const queryResults_2 = new MediaQueriesResults(media, 1);
|
||||
const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
|
||||
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
|
||||
expect(
|
||||
View.isMajorMediaChange(
|
||||
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', () => {
|
||||
const media = [new ViewMedia('clip', 'camera-1'), new ViewMedia('clip', 'camera-2')];
|
||||
const queryResults_1 = new MediaQueriesResults(media, 0);
|
||||
const queryResults_2 = new MediaQueriesResults(media, 1);
|
||||
const queryResults_1 = new MediaQueriesResults({ results: media, selectedIndex: 0 });
|
||||
const queryResults_2 = new MediaQueriesResults({ results: media, selectedIndex: 1 });
|
||||
expect(
|
||||
View.isMajorMediaChange(
|
||||
createView({ queryResults: queryResults_1 }),
|
||||
@@ -282,7 +307,29 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
||||
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([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
||||
@@ -301,7 +348,7 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
||||
]),
|
||||
'recording',
|
||||
],
|
||||
])('should adopt for media case', (mediaQueries, expectedView) => {
|
||||
])('should adopt in media case', (mediaQueries, expectedView) => {
|
||||
const current = createView({
|
||||
view: 'media',
|
||||
query: mediaQueries,
|
||||
@@ -314,6 +361,54 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
||||
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', () => {
|
||||
const query = new EventMediaQueries([
|
||||
{ type: QueryType.Event, cameraIDs: new Set(['camera']), hasClip: true },
|
||||
@@ -409,6 +504,30 @@ describe('View.adoptFromViewIfAppropriate', () => {
|
||||
View.adoptFromViewIfAppropriate(next, current);
|
||||
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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user