Merge pull request #508 from dermotduffy/thumbnails-on-the-side
Significantly flesh out timeline support
This commit is contained in:
@@ -257,8 +257,10 @@ live:
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `mode` | `none` | :white_check_mark: | Whether to show the thumbnail carousel `below` the media, `above` the media or to hide it entirely (`none`).|
|
||||
| `mode` | `none` | :white_check_mark: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).|
|
||||
| `size` | `100px` | :white_check_mark: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).|
|
||||
| `show_details` | `false` | :white_check_mark: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.|
|
||||
| `show_controls` | `true` | :white_check_mark: | Whether to show event controls (e.g. timeline icon, favorite icon) alongside the thumbnail.|
|
||||
| `media` | `clips` | :white_check_mark: | Whether to show `clips` or `snapshots` in the thumbnail carousel in the `live` view.|
|
||||
|
||||
#### Live Controls: Next / Previous
|
||||
@@ -310,7 +312,7 @@ event_viewer:
|
||||
| `lazy_load` | `true` | :heavy_multiplication_x: | Whether or not to lazily load media in the event viewer carousel. Setting this will false will fetch all media immediately which may make the carousel experience smoother at a cost of (potentially) a substantial number of simultaneous media fetches on load. |
|
||||
| `draggable` | `true` | :heavy_multiplication_x: | Whether or not the event viewer carousel can be dragged left or right, via touch/swipe and mouse dragging. |
|
||||
| `transition_effect` | `slide` | :heavy_multiplication_x: | Effect to apply as a transition between event media. Accepted values: `slide` or `none`. |
|
||||
| `controls` | | :heavy_multiplication_x: | Configuration for the event viewer. See below. |
|
||||
| `controls` | | :heavy_multiplication_x: | Configuration for the event viewer controls. See below. |
|
||||
| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_viewer` (e.g. `clip`, `snapshot`). See [actions](#actions) below.|
|
||||
|
||||
#### Event Viewer Controls: Next / Previous
|
||||
@@ -340,8 +342,10 @@ event_viewer:
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media or to hide it entirely (`none`).|
|
||||
| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).|
|
||||
| `size` | `100px` | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).|
|
||||
| `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.|
|
||||
| `show_controls` | `true` | :heavy_multiplication_x: | Whether to show event controls (e.g. timeline icon, favorite icon) alongside the thumbnail.|
|
||||
|
||||
#### Event Viewer Controls: Title
|
||||
|
||||
@@ -388,6 +392,41 @@ image:
|
||||
| `refresh_seconds` | 0 | :white_check_mark: | The image will be refreshed at least every `refresh_seconds` (it may refresh more frequently, e.g. whenever Home Assistant updates its camera security token). `0` implies no refreshing. |
|
||||
| `actions` | | :white_check_mark: | Actions to use for the `image` view. See [actions](#actions) below.|
|
||||
|
||||
### Timeline Options
|
||||
|
||||
The `timeline` is used to show the timing sequence of events across cameras.
|
||||
|
||||
All configuration is under:
|
||||
|
||||
```yaml
|
||||
timeline:
|
||||
```
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `window_seconds` | `3600` | :heavy_multiplication_x: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. |
|
||||
| `clustering_threshold` | `3` | :heavy_multiplication_x: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.|
|
||||
| `media` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.|
|
||||
| `controls` | | :heavy_multiplication_x: | Configuration for the timeline controls. See below.|
|
||||
| `actions` | | :heavy_multiplication_x: | Actions to use for the `timeline` views. See [actions](#actions) below.|
|
||||
|
||||
#### Timeline Controls: Thumbnails
|
||||
|
||||
All configuration is under:
|
||||
|
||||
```yaml
|
||||
timeline:
|
||||
controls:
|
||||
thumbnails:
|
||||
```
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` the media, in a drawer to the `left` or `right` of the media or to hide it entirely (`none`).|
|
||||
| `size` | `100px` | :heavy_multiplication_x: | The size of the thumbnails in the thumbnail carousel [in CSS Units](https://www.w3schools.com/cssref/css_units.asp).|
|
||||
| `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.|
|
||||
| `show_controls` | `true` | :heavy_multiplication_x: | Whether to show event controls (e.g. timeline icon, favorite icon) alongside the thumbnail.|
|
||||
|
||||
### Dimension Options
|
||||
|
||||
All configuration is under:
|
||||
|
||||
+6
-3
@@ -24,16 +24,19 @@
|
||||
"@types/bluebird": "^3.5.36",
|
||||
"component-emitter": "^1.3.0",
|
||||
"crypto": "^1.0.1",
|
||||
"custom-card-helpers": "^1.8.0",
|
||||
"embla-carousel": "^6.1.1",
|
||||
"custom-card-helpers": "^1.9.0",
|
||||
"date-fns": "^2.28.0",
|
||||
"embla-carousel": "^6.2.0",
|
||||
"embla-carousel-wheel-gestures": "^2.1.1",
|
||||
"home-assistant-js-websocket": "^6.1.1",
|
||||
"lit": "^2.2.1",
|
||||
"keycharm": "^0.4.0",
|
||||
"lit": "^2.2.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"moment": "^2.29.1",
|
||||
"propagating-hammerjs": "^2.0.1",
|
||||
"quick-lru": "^6.1.0",
|
||||
"screenfull": "^6.0.1",
|
||||
"side-drawer": "^3.0.0",
|
||||
"ts-toolbelt": "^9.6.0",
|
||||
"uuid": "^8.3.2",
|
||||
"vis-data": "^7.1.3",
|
||||
|
||||
@@ -153,6 +153,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
|
||||
element.addEventListener('touchcancel', end);
|
||||
|
||||
element.addEventListener('mousedown', start, { passive: true });
|
||||
element.addEventListener('pointerdown', start, { passive: true });
|
||||
element.addEventListener('click', end);
|
||||
|
||||
element.addEventListener('keyup', handleEnter);
|
||||
|
||||
+33
-21
@@ -1,6 +1,7 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
|
||||
import type {
|
||||
BrowseMediaQueryParametersBase,
|
||||
BrowseMediaQueryParameters,
|
||||
FrigateBrowseMediaSource,
|
||||
CameraConfig,
|
||||
@@ -39,8 +40,8 @@ export class BrowseMediaUtil {
|
||||
* @param media The media object.
|
||||
* @returns `true` if it's truly a media item, `false` otherwise.
|
||||
*/
|
||||
static isTrueMedia(media: FrigateBrowseMediaSource): boolean {
|
||||
return !media.can_expand;
|
||||
static isTrueMedia(media?: FrigateBrowseMediaSource): boolean {
|
||||
return !!media && !media.can_expand;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,15 +114,13 @@ export class BrowseMediaUtil {
|
||||
* Get the parameters to search for media.
|
||||
* @returns A BrowseMediaQueryParameters object.
|
||||
*/
|
||||
static getBrowseMediaQueryParameters(
|
||||
mediaType: 'clips' | 'snapshots',
|
||||
static getBrowseMediaQueryParametersBase(
|
||||
cameraConfig?: CameraConfig,
|
||||
): BrowseMediaQueryParameters | undefined {
|
||||
): BrowseMediaQueryParametersBase | null {
|
||||
if (!cameraConfig || !cameraConfig.camera_name) {
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
mediaType: mediaType,
|
||||
clientId: cameraConfig.client_id,
|
||||
cameraName: cameraConfig.camera_name,
|
||||
label: cameraConfig.label,
|
||||
@@ -129,33 +128,47 @@ export class BrowseMediaUtil {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the mediaType parameter from the current view.
|
||||
* @param browseMediaQueryParametersBase The base media query parameters object.
|
||||
* @param view The current view.
|
||||
* @returns A fully populated BrowseMediaQueryParameters or null.
|
||||
*/
|
||||
static setMediaTypeFromView(
|
||||
browseMediaQueryParametersBase: BrowseMediaQueryParametersBase | null,
|
||||
view: View,
|
||||
): BrowseMediaQueryParameters | null {
|
||||
if (
|
||||
!browseMediaQueryParametersBase ||
|
||||
!(view.isClipRelatedView() || view.isSnapshotRelatedView())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...browseMediaQueryParametersBase,
|
||||
mediaType: view.isClipRelatedView() ? 'clips' : 'snapshots',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parameters to search for media related to the current view.
|
||||
* @returns A BrowseMediaQueryParameters object.
|
||||
*/
|
||||
static getBrowseMediaQueryParametersOrDispatchError(
|
||||
static getBrowseMediaQueryParametersBaseOrDispatchError(
|
||||
node: HTMLElement,
|
||||
view: View,
|
||||
cameraConfig: CameraConfig,
|
||||
): BrowseMediaQueryParameters | undefined {
|
||||
if (!view.isClipRelatedView() && !view.isSnapshotRelatedView()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Verify there is a camera name, otherwise getBrowseMediaQueryParameters()
|
||||
): BrowseMediaQueryParametersBase | null {
|
||||
// Verify there is a camera name, otherwise getBrowseMediaQueryParametersBase()
|
||||
// will return undefined.
|
||||
if (!cameraConfig.camera_name) {
|
||||
dispatchErrorMessageEvent(
|
||||
node,
|
||||
localize('error.no_camera_name') + `: ${JSON.stringify(cameraConfig)}`,
|
||||
);
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
return BrowseMediaUtil.getBrowseMediaQueryParameters(
|
||||
view.isClipRelatedView() ? 'clips' : 'snapshots',
|
||||
cameraConfig,
|
||||
);
|
||||
return BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,7 +239,6 @@ export class BrowseMediaUtil {
|
||||
view
|
||||
.evolve({
|
||||
target: parent,
|
||||
previous: view,
|
||||
})
|
||||
.dispatchChangeEvent(node);
|
||||
}
|
||||
|
||||
+28
-7
@@ -6,12 +6,13 @@ import {
|
||||
html,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { HomeAssistant, LovelaceCardEditor, getLovelace } from 'custom-card-helpers';
|
||||
import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||
import { until } from 'lit/directives/until.js';
|
||||
import { HomeAssistant, LovelaceCardEditor, getLovelace } from 'custom-card-helpers';
|
||||
import screenfull from 'screenfull';
|
||||
import { throttle } from 'lodash-es';
|
||||
import { until } from 'lit/directives/until.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
@@ -180,6 +181,10 @@ export class FrigateCard extends LitElement {
|
||||
// A cache of resolved media URLs/mimetypes for use in the whole card.
|
||||
protected _resolvedMediaCache = new ResolvedMediaCache();
|
||||
|
||||
// The mouse handler may be called continually, throttle it to at most once
|
||||
// per second for performance reasons.
|
||||
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
|
||||
|
||||
/**
|
||||
* Set the Home Assistant object.
|
||||
*/
|
||||
@@ -381,7 +386,10 @@ export class FrigateCard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (this._getConfig().menu.buttons.download && this._view?.isViewerView()) {
|
||||
if (
|
||||
this._getConfig().menu.buttons.download &&
|
||||
(this._view?.isViewerView() || (this._view?.is('timeline') && !!this._view?.media))
|
||||
) {
|
||||
buttons.push({
|
||||
type: 'custom:frigate-card-menu-icon',
|
||||
title: localize('config.menu.buttons.download'),
|
||||
@@ -743,7 +751,7 @@ export class FrigateCard extends LitElement {
|
||||
* Download media being displayed in the viewer.
|
||||
*/
|
||||
protected async _downloadViewerMedia(): Promise<void> {
|
||||
if (!this._hass || !this._view?.isViewerView()) {
|
||||
if (!this._hass || !(this._view?.isViewerView() || this._view?.is('timeline'))) {
|
||||
// Should not occur.
|
||||
return;
|
||||
}
|
||||
@@ -931,6 +939,13 @@ export class FrigateCard extends LitElement {
|
||||
this._startInteractionTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse movements.
|
||||
*/
|
||||
protected _mouseHandler(): void {
|
||||
this._startInteractionTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the user interaction ('screensaver') timer.
|
||||
*/
|
||||
@@ -1067,6 +1082,7 @@ export class FrigateCard extends LitElement {
|
||||
if (screenfull.isEnabled) {
|
||||
screenfull.on('change', this._fullscreenHandler.bind(this));
|
||||
}
|
||||
this.addEventListener('mousemove', this._boundMouseHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1076,6 +1092,7 @@ export class FrigateCard extends LitElement {
|
||||
if (screenfull.isEnabled) {
|
||||
screenfull.off('change', this._fullscreenHandler.bind(this));
|
||||
}
|
||||
this.removeEventListener('mousemove', this._boundMouseHandler);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -1089,13 +1106,15 @@ export class FrigateCard extends LitElement {
|
||||
// Do not artifically constrain aspect ratio if:
|
||||
// - It's fullscreen.
|
||||
// - Aspect ratio enforcement is disabled.
|
||||
// - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the gallery).
|
||||
// - Aspect ratio enforcement is dynamic and it's a media view (i.e. not the
|
||||
// gallery) or timeline.
|
||||
// - There is a message to display to the user.
|
||||
|
||||
return !(
|
||||
(screenfull.isEnabled && screenfull.isFullscreen) ||
|
||||
aspectRatioMode == 'unconstrained' ||
|
||||
(aspectRatioMode == 'dynamic' && this._view?.isMediaView()) ||
|
||||
(aspectRatioMode == 'dynamic' &&
|
||||
(this._view?.isMediaView() || this._view?.is('timeline'))) ||
|
||||
this._message != null
|
||||
);
|
||||
}
|
||||
@@ -1138,6 +1157,8 @@ export class FrigateCard extends LitElement {
|
||||
specificActions = this._getConfig().event_viewer.actions;
|
||||
} else if (this._view?.is('image')) {
|
||||
specificActions = this._getConfig().image?.actions;
|
||||
} else if (this._view?.is('timeline')) {
|
||||
specificActions = this._getConfig().timeline?.actions;
|
||||
}
|
||||
return { ...this._getConfig().view.actions, ...specificActions };
|
||||
}
|
||||
|
||||
+37
-3
@@ -8,8 +8,13 @@ import {
|
||||
} from 'custom-card-helpers';
|
||||
import { StyleInfo } from 'lit/directives/style-map.js';
|
||||
import { ZodSchema, z } from 'zod';
|
||||
import {
|
||||
differenceInSeconds,
|
||||
differenceInMinutes,
|
||||
differenceInHours,
|
||||
fromUnixTime,
|
||||
} from 'date-fns';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { localize } from './localize/localize.js';
|
||||
import {
|
||||
Actions,
|
||||
@@ -20,6 +25,7 @@ import {
|
||||
FrigateCardAction,
|
||||
FrigateCardCustomAction,
|
||||
frigateCardCustomActionSchema,
|
||||
FrigateEvent,
|
||||
MediaShowInfo,
|
||||
Message,
|
||||
SignedPath,
|
||||
@@ -109,11 +115,11 @@ export async function homeAssistantSignPath(
|
||||
* @param detail An optional detail object to attach.
|
||||
*/
|
||||
export function dispatchFrigateCardEvent<T>(
|
||||
element: HTMLElement,
|
||||
target: EventTarget,
|
||||
name: string,
|
||||
detail?: T,
|
||||
): void {
|
||||
element.dispatchEvent(
|
||||
target.dispatchEvent(
|
||||
new CustomEvent<T>(`frigate-card:${name}`, {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
@@ -589,3 +595,31 @@ export const frigateCardHasAction = (
|
||||
export const stopEventFromActivatingCardWideActions = (ev: Event): void => {
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience function to convert a timestamp to hours, minutes and seconds
|
||||
* string. Heavily inspired by, and returning the same format as, the Frigate
|
||||
* UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97
|
||||
* @param event The Frigate event.
|
||||
* @returns A duration string.
|
||||
*/
|
||||
export function getEventDurationString(event: FrigateEvent): string {
|
||||
if (!event.end_time) {
|
||||
return localize('event.in_progress');
|
||||
}
|
||||
const start = fromUnixTime(event.start_time);
|
||||
const end = fromUnixTime(event.end_time);
|
||||
const hours = differenceInHours(end, start);
|
||||
const minutes = differenceInMinutes(end, start) - hours * 60;
|
||||
const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60;
|
||||
let duration = '';
|
||||
|
||||
if (hours) {
|
||||
duration += `${hours}h `;
|
||||
}
|
||||
if (minutes) {
|
||||
duration += `${minutes}m `;
|
||||
}
|
||||
duration += `${seconds}s`;
|
||||
return duration;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { CSSResultGroup, LitElement, unsafeCSS, PropertyValues } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
|
||||
import EmblaCarousel, {
|
||||
EmblaCarouselType,
|
||||
EmblaOptionsType,
|
||||
EmblaPluginType,
|
||||
} from 'embla-carousel';
|
||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||
|
||||
import { TransitionEffect } from '../types';
|
||||
import { dispatchFrigateCardEvent } from '../common';
|
||||
@@ -15,6 +18,9 @@ export interface CarouselSelect {
|
||||
}
|
||||
|
||||
export class FrigateCardCarousel extends LitElement {
|
||||
@property({ attribute: true, reflect: true })
|
||||
public direction: 'vertical' | 'horizontal' = 'horizontal';
|
||||
|
||||
protected _carousel?: EmblaCarouselType;
|
||||
protected _plugins: Record<string, EmblaPluginType> = {};
|
||||
|
||||
@@ -71,8 +77,14 @@ export class FrigateCardCarousel extends LitElement {
|
||||
* Get the Embla plugins to use.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
*/
|
||||
protected _getPlugins(): EmblaPluginType[] | undefined {
|
||||
return undefined;
|
||||
protected _getPlugins(): EmblaPluginType[] {
|
||||
return [
|
||||
WheelGesturesPlugin({
|
||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||
// gestures as scrolling for the carousel.
|
||||
forceWheelAxis: 'y',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected _destroyCarousel(): void {
|
||||
@@ -98,7 +110,14 @@ export class FrigateCardCarousel extends LitElement {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
this._carousel = EmblaCarousel(carouselNode, this._getOptions(), plugins);
|
||||
this._carousel = EmblaCarousel(
|
||||
carouselNode,
|
||||
{
|
||||
axis: this.direction == 'horizontal' ? 'x' : 'y',
|
||||
...this._getOptions(),
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init'));
|
||||
this._carousel.on('select', () => {
|
||||
const selected = this.carouselSelected();
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
CSSResultGroup,
|
||||
LitElement,
|
||||
TemplateResult,
|
||||
html,
|
||||
unsafeCSS,
|
||||
PropertyValues,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import 'side-drawer';
|
||||
|
||||
import drawerStyle from '../scss/drawer.scss';
|
||||
import drawerInjectStyle from '../scss/drawer-inject.scss';
|
||||
|
||||
@customElement('frigate-card-drawer')
|
||||
export class FrigateCardDrawer extends LitElement {
|
||||
@property({ attribute: true, reflect: true })
|
||||
public location: 'left' | 'right' = 'left';
|
||||
|
||||
@property({ attribute: true, reflect: true, type: Boolean })
|
||||
public control = true;
|
||||
|
||||
@property({ type: Boolean, reflect: true, attribute: true })
|
||||
public open = false;
|
||||
|
||||
protected _refDrawer: Ref<HTMLElement & { open: boolean }> = createRef();
|
||||
protected _refSlot: Ref<HTMLSlotElement> = createRef();
|
||||
|
||||
/**
|
||||
* Called on the first update.
|
||||
* @param changedProps The changed properties.
|
||||
*/
|
||||
protected firstUpdated(changedProps: PropertyValues): void {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
// The `side-drawer` component (and the material drawer for that matter)
|
||||
// only do fixed drawers (i.e. a drawer for the whole viewport). Hackily
|
||||
// override the style to customize the drawer to be absolute within the div.
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = drawerInjectStyle;
|
||||
this._refDrawer.value?.shadowRoot?.appendChild(style);
|
||||
}
|
||||
|
||||
protected _slotChanged(): void {
|
||||
const elements = this._refSlot.value?.assignedElements({ flatten: true });
|
||||
if (elements && elements.length && this._refDrawer.value) {
|
||||
// Hide the drawer unless there is content.
|
||||
this._refDrawer.value.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<side-drawer
|
||||
${ref(this._refDrawer)}
|
||||
?hidden=${true}
|
||||
location="${this.location}"
|
||||
?open=${this.open}
|
||||
>
|
||||
${this.control
|
||||
? html`
|
||||
<div
|
||||
class="control-surround"
|
||||
@click=${() => {
|
||||
this.open = !this.open;
|
||||
}}
|
||||
>
|
||||
<ha-icon
|
||||
class="control"
|
||||
icon="${this.open ? 'mdi:menu-open' : 'mdi:menu'}"
|
||||
>
|
||||
</ha-icon>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged}></slot>
|
||||
</side-drawer>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(drawerStyle);
|
||||
}
|
||||
}
|
||||
+42
-26
@@ -13,6 +13,7 @@ import {
|
||||
} from '../types.js';
|
||||
import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||
import { View } from '../view.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { renderProgressIndicator } from './message.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../common.js';
|
||||
|
||||
@@ -37,17 +38,18 @@ export class FrigateCardGallery extends LitElement {
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.view || !this.cameraConfig) {
|
||||
if (!this.hass || !this.view || !this.cameraConfig || !this.view.isGalleryView()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.view.target) {
|
||||
const browseMediaQueryParameters =
|
||||
BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError(
|
||||
const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView(
|
||||
BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError(
|
||||
this,
|
||||
this.view,
|
||||
this.cameraConfig,
|
||||
);
|
||||
),
|
||||
this.view,
|
||||
);
|
||||
if (!browseMediaQueryParameters) {
|
||||
return;
|
||||
}
|
||||
@@ -127,6 +129,18 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the back arrow should be displayed.
|
||||
* @returns `true` if the back arrow should be displayed, `false` otherwise.
|
||||
*/
|
||||
protected _showBackArrow(): boolean {
|
||||
return (
|
||||
!!this.view?.previous &&
|
||||
!!this.view.previous.target &&
|
||||
this.view.previous.view === this.view.view
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
@@ -156,7 +170,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
};
|
||||
|
||||
return html` <ul class="mdc-image-list frigate-card-gallery">
|
||||
${this.view && this.view.previous
|
||||
${this._showBackArrow()
|
||||
? html`<li class="mdc-image-list__item" style="${styleMap(itemStyle)}">
|
||||
<div class="mdc-image-list__image-aspect-container">
|
||||
<div class="mdc-image-list__image">
|
||||
@@ -202,26 +216,28 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
</div>`
|
||||
: child.thumbnail
|
||||
? html`<img
|
||||
aria-label="${child.title}"
|
||||
class="mdc-image-list__image"
|
||||
src="${child.thumbnail}"
|
||||
title="${child.title}"
|
||||
@click=${(ev) => {
|
||||
if (this.view) {
|
||||
this.view
|
||||
.evolve({
|
||||
view: this.view.is('clips') ? 'clip' : 'snapshot',
|
||||
childIndex: index,
|
||||
previous: this.view,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
/>${child.frigate?.event?.retain_indefinitely ? html`<ha-icon
|
||||
class="favorite"
|
||||
icon="mdi:star"
|
||||
/>` : ``}`
|
||||
aria-label="${child.title}"
|
||||
class="mdc-image-list__image"
|
||||
src="${child.thumbnail}"
|
||||
title="${child.title}"
|
||||
@click=${(ev: Event) => {
|
||||
if (this.view) {
|
||||
this.view
|
||||
.evolve({
|
||||
view: this.view.is('clips') ? 'clip' : 'snapshot',
|
||||
childIndex: index,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
/>${child.frigate?.event?.retain_indefinitely
|
||||
? html`<ha-icon
|
||||
class="favorite"
|
||||
icon="mdi:star"
|
||||
title=${localize('thumbnail.retain_indefinitely')}
|
||||
/>`
|
||||
: ``}`
|
||||
: ``}
|
||||
</div>
|
||||
</li>`,
|
||||
|
||||
+27
-84
@@ -7,7 +7,6 @@ import {
|
||||
PropertyValues,
|
||||
} from 'lit';
|
||||
import {
|
||||
FrigateBrowseMediaSource,
|
||||
ExtendedHomeAssistant,
|
||||
CameraConfig,
|
||||
JSMPEGConfig,
|
||||
@@ -20,25 +19,21 @@ import {
|
||||
LiveProvider,
|
||||
TransitionEffect,
|
||||
frigateCardConfigDefaults,
|
||||
BrowseMediaQueryParameters,
|
||||
} from '../types.js';
|
||||
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||
import { Ref, createRef, ref } from 'lit/directives/ref.js';
|
||||
import { Task } from '@lit-labs/task';
|
||||
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { until } from 'lit/directives/until.js';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import { AutoMediaPlugin, AutoMediaPluginType } from './embla-plugins/automedia.js';
|
||||
import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||
import { ConditionState, getOverriddenConfig } from '../card-condition.js';
|
||||
import { FrigateCardMediaCarousel } from './media-carousel.js';
|
||||
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
|
||||
import {
|
||||
FrigateCardThumbnailCarousel,
|
||||
ThumbnailCarouselTap,
|
||||
} from './thumbnail-carousel.js';
|
||||
import { Lazyload } from './embla-plugins/lazyload.js';
|
||||
import { View } from '../view.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
@@ -104,9 +99,6 @@ export class FrigateCardLive extends LitElement {
|
||||
// pre-loading it may be propagated upwards later.
|
||||
protected _savedMediaShowInfo?: MediaShowInfo;
|
||||
|
||||
@query('frigate-card-thumbnail-carousel')
|
||||
protected _thumbnailCarousel?: FrigateCardThumbnailCarousel;
|
||||
|
||||
/**
|
||||
* Handler for media show events that special cases preloaded live views.
|
||||
* @param e The media show event.
|
||||
@@ -120,79 +112,12 @@ export class FrigateCardLive extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render thumbnails carousel.
|
||||
* @returns A rendered template or void.
|
||||
*/
|
||||
protected renderThumbnails(config: LiveConfig): TemplateResult | void {
|
||||
if (!this.liveConfig || !this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchThumbnailsThenRender = async (): Promise<TemplateResult | void> => {
|
||||
if (!this.hass || !this.cameras || !this.view) {
|
||||
return;
|
||||
}
|
||||
const browseMediaParams = BrowseMediaUtil.getBrowseMediaQueryParameters(
|
||||
config.controls.thumbnails.media,
|
||||
this.cameras.get(this.view.camera),
|
||||
);
|
||||
if (!browseMediaParams) {
|
||||
return;
|
||||
}
|
||||
let parent: FrigateBrowseMediaSource | null;
|
||||
try {
|
||||
parent = await BrowseMediaUtil.browseMediaQuery(this.hass, browseMediaParams);
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(this, (e as Error).message);
|
||||
}
|
||||
|
||||
if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) {
|
||||
return html`<frigate-card-thumbnail-carousel
|
||||
.target=${parent}
|
||||
.view=${this.view}
|
||||
.config=${config.controls.thumbnails}
|
||||
.highlightSelected=${false}
|
||||
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
|
||||
const mediaType = browseMediaParams.mediaType;
|
||||
if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) {
|
||||
new View({
|
||||
view: mediaType === 'clips' ? 'clip' : 'snapshot',
|
||||
camera: this.view.camera,
|
||||
target: ev.detail.target,
|
||||
childIndex: ev.detail.childIndex,
|
||||
}).dispatchChangeEvent(this);
|
||||
}
|
||||
}}
|
||||
>
|
||||
</frigate-card-thumbnail-carousel>`;
|
||||
}
|
||||
};
|
||||
|
||||
const fillerStyle = {
|
||||
height: config.controls.thumbnails.size,
|
||||
};
|
||||
|
||||
// As the live carousel moves, thumbnails are re-fetched. This is an async
|
||||
// request, so it can jarring to the user to have the main camera view nudge
|
||||
// up/down as the thumbnails disappear and re-appear. Instead, if there was
|
||||
// previously a thumbnail carousel rendered, use a filler that is the same
|
||||
// size until it is replaced with a real carousel (or empty, if no carousel
|
||||
// is rendered for the next camera).
|
||||
return html`${until(
|
||||
fetchThumbnailsThenRender(),
|
||||
this._thumbnailCarousel
|
||||
? html` <div style="${styleMap(fillerStyle)}"></div>`
|
||||
: html``,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.liveConfig || !this.cameras) {
|
||||
if (!this.hass || !this.liveConfig || !this.cameras || !this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -202,11 +127,26 @@ export class FrigateCardLive extends LitElement {
|
||||
this.conditionState,
|
||||
) as LiveConfig;
|
||||
|
||||
const browseMediaParamsBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase(
|
||||
this.cameras.get(this.view.camera),
|
||||
);
|
||||
if (!browseMediaParamsBase) {
|
||||
return;
|
||||
}
|
||||
const browseMediaParams: BrowseMediaQueryParameters = {
|
||||
...browseMediaParamsBase,
|
||||
mediaType: config.controls.thumbnails.media,
|
||||
}
|
||||
|
||||
// Note use of liveConfig and not config below -- the carousel will
|
||||
// independently override the liveconfig to reflect the camera in the
|
||||
// carousel (not necessarily the selected camera).
|
||||
return html`
|
||||
${config.controls.thumbnails.mode === 'above' ? this.renderThumbnails(config) : ''}
|
||||
return html` <frigate-card-surround-thumbnails
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.config=${config.controls.thumbnails}
|
||||
.browseMediaParams=${browseMediaParams}
|
||||
>
|
||||
<frigate-card-live-carousel
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
@@ -228,8 +168,7 @@ export class FrigateCardLive extends LitElement {
|
||||
}}
|
||||
>
|
||||
</frigate-card-live-carousel>
|
||||
${config.controls.thumbnails.mode === 'below' ? this.renderThumbnails(config) : ''}
|
||||
`;
|
||||
</frigate-card-surround-thumbnails>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,8 +283,9 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
|
||||
* Get the Embla plugins to use.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
*/
|
||||
protected _getPlugins(): EmblaPluginType[] | undefined {
|
||||
protected _getPlugins(): EmblaPluginType[] {
|
||||
return [
|
||||
...super._getPlugins(),
|
||||
Lazyload({
|
||||
lazyloadCallback: this.liveConfig?.lazy_load
|
||||
? (...args) => this._lazyloadOrUnloadSlide('load', ...args)
|
||||
@@ -417,7 +357,10 @@ export class FrigateCardLiveCarousel extends FrigateCardMediaCarousel {
|
||||
this.view
|
||||
.evolve({
|
||||
camera: Array.from(this.cameras.keys())[selectedSnap],
|
||||
previous: this.view,
|
||||
|
||||
// Reset the target so thumbnails will be re-fetched.
|
||||
target: null,
|
||||
childIndex: null,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { Task } from '@lit-labs/task';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||
import {
|
||||
BrowseMediaQueryParameters,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateBrowseMediaSource,
|
||||
FrigateCardView,
|
||||
ThumbnailsControlConfig,
|
||||
} from '../types.js';
|
||||
|
||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||
import { View } from '../view.js';
|
||||
import { dispatchErrorMessageEvent, dispatchFrigateCardEvent } from '../common.js';
|
||||
|
||||
import './surround.js';
|
||||
|
||||
import surroundThumbnailsStyle from '../scss/surround.scss';
|
||||
|
||||
@customElement('frigate-card-surround-thumbnails')
|
||||
export class FrigateCardSurround extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected config?: ThumbnailsControlConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected targetView?: FrigateCardView;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected browseMediaParams?: BrowseMediaQueryParameters;
|
||||
|
||||
// A task to await the load of the WebRTC component.
|
||||
protected _browseTask = new Task(this, this._fetchMedia.bind(this), () => [
|
||||
this.hass,
|
||||
this.view,
|
||||
this.browseMediaParams,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Fetch thumbnail media when a target is not specified in the view (e.g. for
|
||||
* the live view).
|
||||
* @param param Task parameters.
|
||||
* @returns
|
||||
*/
|
||||
protected async _fetchMedia([hass, view, browseMediaParams]: (
|
||||
| (HomeAssistant & ExtendedHomeAssistant)
|
||||
| Readonly<View>
|
||||
| BrowseMediaQueryParameters
|
||||
| undefined
|
||||
)[]): Promise<void> {
|
||||
hass = hass as HomeAssistant & ExtendedHomeAssistant;
|
||||
view = view as Readonly<View>;
|
||||
browseMediaParams = browseMediaParams as BrowseMediaQueryParameters;
|
||||
|
||||
if (!hass || !view || view.target || !browseMediaParams) {
|
||||
return;
|
||||
}
|
||||
let parent: FrigateBrowseMediaSource | null;
|
||||
try {
|
||||
parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaParams);
|
||||
} catch (e) {
|
||||
return dispatchErrorMessageEvent(this, (e as Error).message);
|
||||
}
|
||||
if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) {
|
||||
this.view
|
||||
?.evolve({
|
||||
...(this.targetView && { view: this.targetView }),
|
||||
target: parent,
|
||||
childIndex: null,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a drawer is being used.
|
||||
* @returns `true` if a drawer is used, `false` otherwise.
|
||||
*/
|
||||
protected _hasDrawer(): boolean {
|
||||
return !!this.config && ['left', 'right'].includes(this.config.mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.view || !this.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changeDrawer = (ev: CustomEvent, action: 'open' | 'close') => {
|
||||
// The event catch/re-dispatch below protect encapsulation: Catches the
|
||||
// request to view thumbnails and re-dispatches a request to open the drawer
|
||||
// (if the thumbnails are in a drawer). The new event needs to be dispatched
|
||||
// from the origin of the inbound event, so it can be handled by
|
||||
// <frigate-card-surround> .
|
||||
if (this.config && this._hasDrawer()) {
|
||||
dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, {
|
||||
drawer: this.config.mode,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return html` <frigate-card-surround
|
||||
@frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')}
|
||||
@frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
||||
>
|
||||
${this.config?.mode !== 'none'
|
||||
? html` <frigate-card-thumbnail-carousel
|
||||
slot=${this.config.mode}
|
||||
.config=${this.config}
|
||||
.view=${this.view}
|
||||
.target=${this.view.target}
|
||||
.selected=${this.view.childIndex}
|
||||
@frigate-card:change-view=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
||||
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
|
||||
this.view
|
||||
?.evolve({
|
||||
view: this.targetView || 'event',
|
||||
target: ev.detail.target,
|
||||
childIndex: ev.detail.childIndex,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}}
|
||||
>
|
||||
</frigate-card-thumbnail-carousel>`
|
||||
: ''}
|
||||
<slot></slot>
|
||||
</frigate-card-surround>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(surroundThumbnailsStyle);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
|
||||
import { FrigateCardDrawer } from './drawer.js';
|
||||
|
||||
import './drawer.js';
|
||||
|
||||
import surroundStyle from '../scss/surround.scss';
|
||||
|
||||
interface FrigateCardDrawerOpen {
|
||||
drawer: 'left' | 'right';
|
||||
}
|
||||
|
||||
@customElement('frigate-card-surround')
|
||||
export class FrigateCardSurround extends LitElement {
|
||||
protected _refDrawerLeft: Ref<FrigateCardDrawer> = createRef();
|
||||
protected _refDrawerRight: Ref<FrigateCardDrawer> = createRef();
|
||||
protected _boundDrawerHandler = this._drawerHandler.bind(this);
|
||||
|
||||
/**
|
||||
* Component connected callback.
|
||||
*/
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
|
||||
this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler);
|
||||
this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler);
|
||||
}
|
||||
|
||||
protected _drawerHandler(ev: Event) {
|
||||
const drawer = (ev as CustomEvent<FrigateCardDrawerOpen>).detail.drawer;
|
||||
const open = ev.type.endsWith(':open');
|
||||
if (drawer === 'left' && this._refDrawerLeft.value) {
|
||||
this._refDrawerLeft.value.open = open;
|
||||
} else if (drawer === 'right' && this._refDrawerRight.value) {
|
||||
this._refDrawerRight.value.open = open;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
return html` <slot name="above"></slot>
|
||||
<slot></slot>
|
||||
<frigate-card-drawer ${ref(this._refDrawerLeft)} location="left">
|
||||
<slot name="left"></slot>
|
||||
</frigate-card-drawer>
|
||||
<frigate-card-drawer ${ref(this._refDrawerRight)} location="right">
|
||||
<slot name="right"></slot>
|
||||
</frigate-card-drawer>
|
||||
<slot name="below"></slot>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return compiled CSS styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(surroundStyle);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||
import { CSSResultGroup, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { CSSResultGroup, TemplateResult, html, unsafeCSS, PropertyValues } from 'lit';
|
||||
import { EmblaOptionsType } from 'embla-carousel';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js';
|
||||
import type {
|
||||
FrigateBrowseMediaSource,
|
||||
ThumbnailsControlConfig,
|
||||
} from '../types.js';
|
||||
import { FrigateCardCarousel } from './carousel.js';
|
||||
import { dispatchFrigateCardEvent, stopEventFromActivatingCardWideActions } from '../common.js';
|
||||
import { View } from '../view.js';
|
||||
import {
|
||||
contentsChanged,
|
||||
dispatchFrigateCardEvent,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../common.js';
|
||||
|
||||
import './thumbnail.js';
|
||||
|
||||
import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
||||
|
||||
@@ -18,27 +31,72 @@ export interface ThumbnailCarouselTap {
|
||||
@customElement('frigate-card-thumbnail-carousel')
|
||||
export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
||||
@property({ attribute: false })
|
||||
protected target?: FrigateBrowseMediaSource;
|
||||
protected view?: Readonly<View>;
|
||||
|
||||
protected _tapSelected?;
|
||||
// Use contentsChanged here to avoid the carousel rebuilding and resetting in
|
||||
// front of the user, unless the contents have actually changed.
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public target?: FrigateBrowseMediaSource | null;
|
||||
|
||||
// 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;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
set config(config: ThumbnailsControlConfig | undefined) {
|
||||
if (config) {
|
||||
if (config && config.size !== undefined && config.size !== null) {
|
||||
this.style.setProperty('--frigate-card-carousel-thumbnail-size', config.size);
|
||||
}
|
||||
this._config = config;
|
||||
set selected(selected: number | null) {
|
||||
this._selected = selected;
|
||||
if (selected !== null) {
|
||||
// If there is a selection, 'dim' all the other slides.
|
||||
this.style.setProperty('--frigate-card-carousel-thumbnail-opacity', '0.4');
|
||||
}
|
||||
}
|
||||
protected _config?: ThumbnailsControlConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
set highlightSelected(value: boolean) {
|
||||
this.style.setProperty(
|
||||
'--frigate-card-carousel-thumbnail-opacity',
|
||||
value ? '0.6' : '1.0',
|
||||
);
|
||||
set config(config: ThumbnailsControlConfig) {
|
||||
this.direction = ['left', 'right'].includes(config.mode) ? 'vertical' : 'horizontal';
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
@state()
|
||||
protected _config?: ThumbnailsControlConfig;
|
||||
|
||||
@state()
|
||||
protected _selected?: number | null;
|
||||
|
||||
/**
|
||||
* Handle gallery resize.
|
||||
*/
|
||||
protected _resizeHandler(): void {
|
||||
if (this._carousel) {
|
||||
this._carousel.reInit();
|
||||
// Reinit will cause the scroll position to reset, so re-scroll to the
|
||||
// correct location.
|
||||
if (this._selected !== undefined && this._selected !== null) {
|
||||
this.carouselScrollTo(this._selected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Component connected callback.
|
||||
*/
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._resizeObserver.observe(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Component disconnected callback.
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
this._resizeObserver.disconnect();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,28 +107,10 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
||||
return {
|
||||
containScroll: 'keepSnaps',
|
||||
dragFree: true,
|
||||
startIndex: this._selected ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a particular slide.
|
||||
* @param index Slide number.
|
||||
*/
|
||||
carouselScrollTo(index: number): void {
|
||||
if (!this._carousel) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._tapSelected !== undefined) {
|
||||
this._carousel.slideNodes()[this._tapSelected].classList.remove('slide-selected');
|
||||
}
|
||||
|
||||
super.carouselScrollTo(index);
|
||||
|
||||
this._carousel.slideNodes()[index].classList.add('slide-selected');
|
||||
this._tapSelected = index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slides to include in the render.
|
||||
* @returns The slides to include in the render.
|
||||
@@ -90,6 +130,27 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
||||
return slides;
|
||||
}
|
||||
|
||||
/**
|
||||
* The updated lifecycle callback for this element.
|
||||
* @param changedProperties The properties that were changed in this render.
|
||||
*/
|
||||
updated(changedProperties: PropertyValues): void {
|
||||
if (changedProperties.has('target')) {
|
||||
this._destroyCarousel();
|
||||
}
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('_selected')) {
|
||||
this.updateComplete.then(() => {
|
||||
if (this._carousel) {
|
||||
if (this._selected !== undefined && this._selected !== null) {
|
||||
this.carouselScrollTo(this._selected);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a given thumbnail.
|
||||
* @param mediaToRender The media item to render.
|
||||
@@ -100,17 +161,27 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
||||
childIndex: number,
|
||||
slideIndex: number,
|
||||
): TemplateResult | void {
|
||||
if (!parent.children || !parent.children.length) {
|
||||
if (
|
||||
!parent.children ||
|
||||
!parent.children.length ||
|
||||
!BrowseMediaUtil.isTrueMedia(parent.children[childIndex])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaToRender = parent.children[childIndex];
|
||||
if (!BrowseMediaUtil.isTrueMedia(mediaToRender) || !mediaToRender.thumbnail) {
|
||||
return;
|
||||
}
|
||||
const classes = {
|
||||
embla__slide: true,
|
||||
'slide-selected': this._selected === childIndex,
|
||||
};
|
||||
|
||||
return html`<div
|
||||
class="embla__slide"
|
||||
return html` <frigate-card-thumbnail
|
||||
.view=${this.view}
|
||||
.target=${parent}
|
||||
.childIndex=${childIndex}
|
||||
?details=${this._config?.show_details}
|
||||
?controls=${this._config?.show_controls}
|
||||
thumbnail_size=${ifDefined(this._config?.size)}
|
||||
class="${classMap(classes)}"
|
||||
@click=${(ev) => {
|
||||
if (this._carousel && this._carousel.clickAllowed()) {
|
||||
dispatchFrigateCardEvent<ThumbnailCarouselTap>(this, 'carousel:tap', {
|
||||
@@ -122,17 +193,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
aria-label="${mediaToRender.title}"
|
||||
src="${mediaToRender.thumbnail}"
|
||||
title="${mediaToRender.title}"
|
||||
/>
|
||||
${mediaToRender?.frigate?.event?.retain_indefinitely ? html`
|
||||
<ha-icon
|
||||
class="favorite"
|
||||
icon="mdi:star"
|
||||
/>` : ``}
|
||||
</div>`;
|
||||
</frigate-card-thumbnail>`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { CSSResult, TemplateResult, html, unsafeCSS, LitElement } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { format, fromUnixTime } from 'date-fns';
|
||||
|
||||
import type { FrigateBrowseMediaSource, FrigateEvent } from '../types.js';
|
||||
import { View } from '../view.js';
|
||||
import {
|
||||
getEventDurationString,
|
||||
prettifyFrigateName,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../common.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
|
||||
import thumbnailStyle from '../scss/thumbnail.scss';
|
||||
|
||||
@customElement('frigate-card-thumbnail')
|
||||
export class FrigateCardThumbnail extends LitElement {
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public details = false;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public controls = false;
|
||||
|
||||
@property({ attribute: true })
|
||||
set thumbnail_size(size: string) {
|
||||
this.style.setProperty('--frigate-card-thumbnail-size', String(size));
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Data-binding based interface
|
||||
// ============================
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public target?: FrigateBrowseMediaSource;
|
||||
|
||||
@property({ attribute: false })
|
||||
public childIndex?: number;
|
||||
|
||||
// =============================================================
|
||||
// Overrides that can be used if data bindings are not available
|
||||
// =============================================================
|
||||
@property({ attribute: true })
|
||||
public thumbnail?: string;
|
||||
|
||||
@property({ attribute: true })
|
||||
public label?: string;
|
||||
|
||||
@property({ attribute: true })
|
||||
public event?: string;
|
||||
|
||||
/**
|
||||
* Render the element.
|
||||
* @returns A template to display to the user.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
let event: FrigateEvent | null = null;
|
||||
let thumbnail: string | null = null;
|
||||
let label: string | null = null;
|
||||
|
||||
// Take the event / thumbnail / label from the data-bound media (if specified).
|
||||
if (this.target && this.target.children && this.childIndex !== undefined) {
|
||||
const media = this.target.children[this.childIndex];
|
||||
event = media.frigate?.event ?? null;
|
||||
thumbnail = media.thumbnail;
|
||||
label = media.title;
|
||||
}
|
||||
|
||||
// Always give the overrides preference (if specified).
|
||||
if (this.event) {
|
||||
event = JSON.parse(this.event);
|
||||
}
|
||||
thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
|
||||
label = this.label ? this.label : label;
|
||||
|
||||
if (!thumbnail) {
|
||||
return;
|
||||
}
|
||||
|
||||
return html` <img
|
||||
aria-label="${label ?? ''}"
|
||||
src="${thumbnail}"
|
||||
title="${label ?? ''}"
|
||||
/>
|
||||
${this.controls && event?.retain_indefinitely
|
||||
? html` <ha-icon
|
||||
class="favorite"
|
||||
icon="mdi:star"
|
||||
title=${localize('thumbnail.retain_indefinitely')}
|
||||
/>`
|
||||
: ``}
|
||||
${this.details && event
|
||||
? html` <div class="details">
|
||||
<div class="left">
|
||||
<div class="larger">${prettifyFrigateName(event.label)}</div>
|
||||
<div>
|
||||
<span>
|
||||
<span class="heading">${localize('event.start')}:</span>
|
||||
${format(fromUnixTime(event.start_time), 'HH:mm:ss')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
<span class="heading">${localize('event.duration')}:</span>
|
||||
${getEventDurationString(event)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<div class="larger">${(event.top_score * 100).toFixed(2) + '%'}</div>
|
||||
</div>
|
||||
</div>`
|
||||
: html``}
|
||||
${this.controls
|
||||
? html`<ha-icon
|
||||
class="timeline"
|
||||
icon="mdi:target"
|
||||
title=${localize('thumbnail.timeline')}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
this.view
|
||||
?.evolve({
|
||||
view: 'timeline',
|
||||
target: this.target,
|
||||
childIndex: this.childIndex ?? null,
|
||||
context: {},
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}}
|
||||
></ha-icon>`
|
||||
: ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResult {
|
||||
return unsafeCSS(thumbnailStyle);
|
||||
}
|
||||
}
|
||||
+672
-267
File diff suppressed because it is too large
Load Diff
+57
-129
@@ -10,14 +10,14 @@ import { BrowseMediaUtil } from '../browse-media-util.js';
|
||||
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { Task } from '@lit-labs/task';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { ref } from 'lit/directives/ref.js';
|
||||
|
||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||
import type {
|
||||
BrowseMediaNeighbors,
|
||||
BrowseMediaQueryParameters,
|
||||
BrowseMediaQueryParametersBase,
|
||||
FrigateBrowseMediaSource,
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
@@ -27,10 +27,6 @@ import type {
|
||||
} from '../types.js';
|
||||
import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js';
|
||||
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
|
||||
import {
|
||||
FrigateCardThumbnailCarousel,
|
||||
ThumbnailCarouselTap,
|
||||
} from './thumbnail-carousel.js';
|
||||
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
|
||||
import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js';
|
||||
import { View } from '../view.js';
|
||||
@@ -46,7 +42,6 @@ import './next-prev-control.js';
|
||||
import './title-control.js';
|
||||
|
||||
import viewerStyle from '../scss/viewer.scss';
|
||||
import viewerCoreStyle from '../scss/viewer-core.scss';
|
||||
|
||||
@customElement('frigate-card-viewer')
|
||||
export class FrigateCardViewer extends LitElement {
|
||||
@@ -70,21 +65,25 @@ export class FrigateCardViewer extends LitElement {
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.view || !this.cameraConfig) {
|
||||
if (!this.hass || !this.view || !this.cameraConfig || !this.viewerConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const browseMediaQueryParameters =
|
||||
BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError(
|
||||
const browseMediaQueryParametersBase =
|
||||
BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError(
|
||||
this,
|
||||
this.view,
|
||||
this.cameraConfig,
|
||||
);
|
||||
if (!browseMediaQueryParameters) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.view.target) {
|
||||
const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView(
|
||||
browseMediaQueryParametersBase,
|
||||
this.view,
|
||||
);
|
||||
if (!browseMediaQueryParameters) {
|
||||
return;
|
||||
}
|
||||
|
||||
BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange(
|
||||
this,
|
||||
this.hass,
|
||||
@@ -94,14 +93,20 @@ export class FrigateCardViewer extends LitElement {
|
||||
return renderProgressIndicator();
|
||||
}
|
||||
|
||||
return html` <frigate-card-viewer-core
|
||||
.view=${this.view}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
return html` <frigate-card-surround-thumbnails
|
||||
.hass=${this.hass}
|
||||
.browseMediaQueryParameters=${browseMediaQueryParameters}
|
||||
.view=${this.view}
|
||||
.config=${this.viewerConfig.controls.thumbnails}
|
||||
>
|
||||
</frigate-card-viewer-core>`;
|
||||
<frigate-card-viewer-carousel
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.browseMediaQueryParametersBase=${browseMediaQueryParametersBase}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
>
|
||||
</frigate-card-viewer-carousel>
|
||||
</frigate-card-surround-thumbnails>`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,82 +117,6 @@ export class FrigateCardViewer extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-viewer-core')
|
||||
export class FrigateCardViewerCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
protected hass?: HomeAssistant & ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected view?: Readonly<View>;
|
||||
|
||||
// See note on viewerConfig in <frigate-card-viewer-carousel>.
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
protected viewerConfig?: ViewerConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected resolvedMediaCache?: ResolvedMediaCache;
|
||||
|
||||
protected _viewerCarouselRef: Ref<FrigateCardViewerCarousel> = createRef();
|
||||
protected _thumbnailCarouselRef: Ref<FrigateCardThumbnailCarousel> = createRef();
|
||||
|
||||
protected _syncThumbnailCarousel(): void {
|
||||
const mediaSelected = this._viewerCarouselRef.value?.carouselSelected();
|
||||
if (mediaSelected !== undefined) {
|
||||
this._thumbnailCarouselRef.value?.carouselScrollTo(mediaSelected);
|
||||
}
|
||||
}
|
||||
|
||||
protected _renderThumbnails(): TemplateResult {
|
||||
if (!this.view || !this.viewerConfig) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return html` <frigate-card-thumbnail-carousel
|
||||
${ref(this._thumbnailCarouselRef)}
|
||||
.target=${this.view.target}
|
||||
.config=${this.viewerConfig.controls.thumbnails}
|
||||
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
|
||||
this._viewerCarouselRef.value?.carouselScrollTo(ev.detail.slideIndex);
|
||||
}}
|
||||
@frigate-card:carousel:init=${this._syncThumbnailCarousel.bind(this)}
|
||||
>
|
||||
</frigate-card-thumbnail-carousel>`;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.view || !this.viewerConfig) {
|
||||
return html``;
|
||||
}
|
||||
return html` ${this.viewerConfig &&
|
||||
this.viewerConfig.controls.thumbnails.mode === 'above'
|
||||
? this._renderThumbnails()
|
||||
: ''}
|
||||
<frigate-card-viewer-carousel
|
||||
${ref(this._viewerCarouselRef)}
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.browseMediaQueryParameters=${this.browseMediaQueryParameters}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
@frigate-card:carousel:select=${this._syncThumbnailCarousel.bind(this)}
|
||||
>
|
||||
</frigate-card-viewer-carousel>
|
||||
${this.viewerConfig && this.viewerConfig.controls.thumbnails.mode === 'below'
|
||||
? this._renderThumbnails()
|
||||
: ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element styles.
|
||||
*/
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(viewerCoreStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-viewer-carousel')
|
||||
export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
@property({ attribute: false })
|
||||
@@ -205,7 +134,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
protected viewerConfig?: ViewerConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected browseMediaQueryParameters?: BrowseMediaQueryParameters;
|
||||
protected browseMediaQueryParametersBase?: BrowseMediaQueryParametersBase;
|
||||
|
||||
@property({ attribute: false })
|
||||
protected resolvedMediaCache?: ResolvedMediaCache;
|
||||
@@ -216,11 +145,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
|
||||
// A task to resolve target media if lazy loading is disabled.
|
||||
protected _mediaResolutionTask = new Task<
|
||||
[FrigateBrowseMediaSource | undefined],
|
||||
[FrigateBrowseMediaSource | null | undefined],
|
||||
void
|
||||
>(
|
||||
this,
|
||||
async ([target]: (FrigateBrowseMediaSource | undefined)[]): Promise<void> => {
|
||||
async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise<void> => {
|
||||
for (
|
||||
let i = 0;
|
||||
!this.viewerConfig?.lazy_load &&
|
||||
@@ -254,12 +183,12 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
if (this._carousel && changedProperties.has('view')) {
|
||||
const oldView = changedProperties.get('view') as View | undefined;
|
||||
if (oldView) {
|
||||
if (oldView.target != this.view?.target) {
|
||||
if (oldView.target !== this.view?.target) {
|
||||
// If the media target is different entirely, reset the carousel.
|
||||
this._destroyCarousel();
|
||||
} else if (this.view?.childIndex != oldView.childIndex) {
|
||||
const slide = this._getSlideForChild(this.view?.childIndex);
|
||||
if (slide !== undefined && slide !== this.carouselSelected()) {
|
||||
} else if (this.view.childIndex != oldView.childIndex) {
|
||||
const slide = this._getSlideForChild(this.view.childIndex);
|
||||
if (slide !== null && slide !== this.carouselSelected()) {
|
||||
// If the media target is the same as already loaded, but isn't of
|
||||
// the selected slide, scroll to that slide.
|
||||
this.carouselScrollTo(slide);
|
||||
@@ -297,14 +226,19 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
// need to be destroyed here.
|
||||
}
|
||||
|
||||
protected _getSlideForChild(childIndex: number | undefined): number | undefined {
|
||||
if (childIndex === undefined) {
|
||||
return undefined;
|
||||
/**
|
||||
* Get the slide number given a media child number.
|
||||
* @param childIndex The child index (relative to `view.target`)
|
||||
* @returns A number or null if the child is not found.
|
||||
*/
|
||||
protected _getSlideForChild(childIndex: number | null | undefined): number | null {
|
||||
if (childIndex === undefined || childIndex === null) {
|
||||
return null;
|
||||
}
|
||||
const slideIndex = Object.keys(this._slideToChild).find(
|
||||
(key) => this._slideToChild[key] === childIndex,
|
||||
);
|
||||
return slideIndex !== undefined ? Number(slideIndex) : undefined;
|
||||
return slideIndex !== undefined ? Number(slideIndex) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,7 +256,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
protected _getOptions(): EmblaOptionsType {
|
||||
return {
|
||||
// Start the carousel on the selected child number.
|
||||
startIndex: this._getSlideForChild(this.view?.childIndex),
|
||||
startIndex: this._getSlideForChild(this.view?.childIndex) ?? 0,
|
||||
draggable: this.viewerConfig?.draggable,
|
||||
};
|
||||
}
|
||||
@@ -331,23 +265,19 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
* Get the Embla plugins to use.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
*/
|
||||
protected _getPlugins(): EmblaPluginType[] | undefined {
|
||||
protected _getPlugins(): EmblaPluginType[] {
|
||||
return [
|
||||
...super._getPlugins(),
|
||||
Lazyload({
|
||||
lazyloadCallback: this.viewerConfig?.lazy_load
|
||||
? this._lazyloadSlide.bind(this)
|
||||
: undefined,
|
||||
}),
|
||||
// Don't need autoplay/pause for snapshots.
|
||||
...(this.view?.is('clip')
|
||||
? [
|
||||
AutoMediaPlugin({
|
||||
playerSelector: 'frigate-card-ha-hls-player',
|
||||
autoPlayWhenVisible: !!this.viewerConfig?.auto_play,
|
||||
autoUnmuteWhenVisible: !!this.viewerConfig?.auto_unmute,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
AutoMediaPlugin({
|
||||
playerSelector: 'frigate-card-ha-hls-player',
|
||||
autoPlayWhenVisible: !!this.viewerConfig?.auto_play,
|
||||
autoUnmuteWhenVisible: !!this.viewerConfig?.auto_unmute,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -361,7 +291,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
this.view.childIndex === undefined
|
||||
this.view.childIndex === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -409,7 +339,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
!this.view.target.children.length ||
|
||||
!this.browseMediaQueryParameters
|
||||
!this.browseMediaQueryParametersBase
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -452,7 +382,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
|
||||
try {
|
||||
clips = await BrowseMediaUtil.browseMediaQuery(this.hass, {
|
||||
...this.browseMediaQueryParameters,
|
||||
...this.browseMediaQueryParametersBase,
|
||||
mediaType: 'clips',
|
||||
before: latest,
|
||||
after: earliest,
|
||||
@@ -473,12 +403,10 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
}
|
||||
const clipStartTime = BrowseMediaUtil.getEventStartTime(child);
|
||||
if (clipStartTime && clipStartTime === snapshotStartTime) {
|
||||
return new View({
|
||||
return this.view.evolve({
|
||||
view: 'clip',
|
||||
camera: this.view.camera,
|
||||
target: clips,
|
||||
childIndex: i,
|
||||
previous: this.view,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -501,7 +429,6 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
this.view
|
||||
.evolve({
|
||||
childIndex: childIndex,
|
||||
previous: this.view,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
@@ -518,7 +445,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
const childIndex: number | undefined = this._slideToChild[index];
|
||||
|
||||
if (
|
||||
childIndex == undefined ||
|
||||
childIndex === undefined ||
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
@@ -699,7 +626,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
if (
|
||||
!this.view ||
|
||||
!this.viewerConfig ||
|
||||
!BrowseMediaUtil.isTrueMedia(mediaToRender)
|
||||
!BrowseMediaUtil.isTrueMedia(mediaToRender) ||
|
||||
!['video', 'image'].includes(mediaToRender.media_content_type)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -712,7 +640,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
|
||||
|
||||
return html`
|
||||
<div class="embla__slide">
|
||||
${this.view.isClipRelatedView()
|
||||
${mediaToRender.media_content_type === 'video'
|
||||
? html`<frigate-card-ha-hls-player
|
||||
allow-exoplayer
|
||||
aria-label="${mediaToRender.title}"
|
||||
|
||||
@@ -45,6 +45,10 @@ export const CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE =
|
||||
`${CONF_EVENT_VIEWER}.controls.next_previous.size` as const;
|
||||
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE =
|
||||
`${CONF_EVENT_VIEWER}.controls.thumbnails.mode` as const;
|
||||
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS =
|
||||
`${CONF_EVENT_VIEWER}.controls.thumbnails.show_details` as const;
|
||||
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS =
|
||||
`${CONF_EVENT_VIEWER}.controls.thumbnails.show_controls` as const;
|
||||
export const CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE =
|
||||
`${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const;
|
||||
export const CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE =
|
||||
@@ -64,6 +68,10 @@ export const CONF_LIVE_CONTROLS_THUMBNAILS_MODE =
|
||||
`${CONF_LIVE}.controls.thumbnails.mode` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SIZE =
|
||||
`${CONF_LIVE}.controls.thumbnails.size` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS =
|
||||
`${CONF_LIVE}.controls.thumbnails.show_details` as const;
|
||||
export const CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_CONTROLS =
|
||||
`${CONF_LIVE}.controls.thumbnails.show_controls` as const;
|
||||
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;
|
||||
@@ -80,6 +88,16 @@ export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
|
||||
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
|
||||
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
|
||||
|
||||
export const CONF_TIMELINE = 'timeline' as const;
|
||||
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
||||
export const CONF_TIMELINE_CLUSTERING_THRESHOLD = `${CONF_TIMELINE}.clustering_threshold` as const;
|
||||
export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE = `${CONF_TIMELINE}.controls.thumbnails.mode` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = `${CONF_TIMELINE}.controls.thumbnails.size` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS = `${CONF_TIMELINE}.controls.thumbnails.show_details` as const;
|
||||
export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_CONTROLS =
|
||||
`${CONF_TIMELINE}.controls.thumbnails.show_controls` as const;
|
||||
|
||||
export const CONF_MENU = 'menu' as const;
|
||||
export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const;
|
||||
export const CONF_MENU_BUTTONS_FRIGATE_UI = `${CONF_MENU}.buttons.frigate_ui` as const;
|
||||
|
||||
@@ -32,6 +32,8 @@ import {
|
||||
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE,
|
||||
CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_MODE,
|
||||
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
|
||||
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS,
|
||||
CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
|
||||
@@ -46,6 +48,8 @@ import {
|
||||
CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
||||
CONF_LIVE_CONTROLS_TITLE_MODE,
|
||||
@@ -64,6 +68,13 @@ import {
|
||||
CONF_MENU_BUTTONS_SNAPSHOTS,
|
||||
CONF_MENU_BUTTON_SIZE,
|
||||
CONF_MENU_MODE,
|
||||
CONF_TIMELINE_CLUSTERING_THRESHOLD,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE,
|
||||
CONF_TIMELINE_MEDIA,
|
||||
CONF_TIMELINE_WINDOW_SECONDS,
|
||||
CONF_VIEW_CAMERA_SELECT,
|
||||
CONF_VIEW_DEFAULT,
|
||||
CONF_VIEW_TIMEOUT_SECONDS,
|
||||
@@ -145,6 +156,12 @@ const options: EditorOptions = {
|
||||
secondary: localize('editor.image_secondary'),
|
||||
show: false,
|
||||
},
|
||||
timeline: {
|
||||
icon: 'chart-gantt',
|
||||
name: localize('editor.timeline'),
|
||||
secondary: localize('editor.timeline_secondary'),
|
||||
show: false,
|
||||
},
|
||||
dimensions: {
|
||||
icon: 'aspect-ratio',
|
||||
name: localize('editor.dimensions'),
|
||||
@@ -178,6 +195,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
{ value: 'clip', label: localize('config.view.views.clip') },
|
||||
{ value: 'snapshot', label: localize('config.view.views.snapshot') },
|
||||
{ value: 'image', label: localize('config.view.views.image') },
|
||||
{ value: 'timeline', label: localize('config.view.views.timeline') },
|
||||
];
|
||||
|
||||
protected _cameraSelectViewModes = [
|
||||
@@ -260,6 +278,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
value: 'below',
|
||||
label: localize('config.event_viewer.controls.thumbnails.modes.below'),
|
||||
},
|
||||
{
|
||||
value: 'left',
|
||||
label: localize('config.event_viewer.controls.thumbnails.modes.left'),
|
||||
},
|
||||
{
|
||||
value: 'right',
|
||||
label: localize('config.event_viewer.controls.thumbnails.modes.right'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _thumbnailMedias = [
|
||||
@@ -305,6 +331,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
{ value: 'url', label: localize('config.image.modes.url') },
|
||||
];
|
||||
|
||||
protected _timelineMediaTypes = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'all', label: localize('config.timeline.medias.all') },
|
||||
{ value: 'clips', label: localize('config.timeline.medias.clips') },
|
||||
{ value: 'snapshots', label: localize('config.timeline.medias.snapshots') },
|
||||
];
|
||||
|
||||
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
|
||||
@@ -881,6 +914,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_LIVE_CONTROLS_TITLE_MODE,
|
||||
this._titleModes,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
defaults.live.controls.thumbnails.show_details,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
|
||||
defaults.live.controls.thumbnails.show_controls,
|
||||
)}
|
||||
${this._renderNumberInput(
|
||||
CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS,
|
||||
0,
|
||||
@@ -928,6 +969,14 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
this._thumbnailModes,
|
||||
)}
|
||||
${this._renderStringInput(CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE)}
|
||||
${this._renderSwitch(
|
||||
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
defaults.event_viewer.controls.thumbnails.show_details,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
|
||||
defaults.event_viewer.controls.thumbnails.show_controls,
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE,
|
||||
this._titleModes,
|
||||
@@ -951,6 +1000,27 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
${this._renderNumberInput(CONF_IMAGE_REFRESH_SECONDS)}
|
||||
</div>`
|
||||
: ''}
|
||||
${this._renderOptionSetHeader('timeline')}
|
||||
${options.timeline.show
|
||||
? html` <div class="values">
|
||||
${this._renderNumberInput(CONF_TIMELINE_WINDOW_SECONDS)}
|
||||
${this._renderNumberInput(CONF_TIMELINE_CLUSTERING_THRESHOLD)}
|
||||
${this._renderOptionSelector(CONF_TIMELINE_MEDIA, this._timelineMediaTypes)}
|
||||
${this._renderOptionSelector(
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
||||
this._thumbnailModes,
|
||||
)}
|
||||
${this._renderStringInput(CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE)}
|
||||
${this._renderSwitch(
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
defaults.timeline.controls.thumbnails.show_details,
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_CONTROLS,
|
||||
defaults.timeline.controls.thumbnails.show_controls,
|
||||
)}
|
||||
</div>`
|
||||
: ''}
|
||||
${this._renderOptionSetHeader('dimensions')}
|
||||
${options.dimensions.show
|
||||
? html` <div class="values">
|
||||
|
||||
@@ -76,9 +76,13 @@
|
||||
"thumbnails": {
|
||||
"mode": "Event Viewer thumbnails mode",
|
||||
"size": "Event Viewer thumbnails size (e.g. '100px')",
|
||||
"show_details": "Show event details with thumbnails",
|
||||
"show_controls": "Show event controls with thumbnails",
|
||||
"modes": {
|
||||
"below": "Thumbnails below the media",
|
||||
"above": "Thumbnails above the media",
|
||||
"left": "Thumbnails in a drawer left of the media",
|
||||
"right": "Thumbnails in a drawer right of the media",
|
||||
"none": "No thumbnails"
|
||||
}
|
||||
},
|
||||
@@ -115,6 +119,8 @@
|
||||
"thumbnails": {
|
||||
"mode": "Live thumbnails mode",
|
||||
"size": "Live thumbnails size (e.g. '100px')",
|
||||
"show_details": "Show event details with thumbnails",
|
||||
"show_controls": "Show event controls with thumbnails",
|
||||
"media": "Whether to show thumbnails of clips or snapshots",
|
||||
"medias": {
|
||||
"clips": "Clip thumbnails",
|
||||
@@ -165,6 +171,24 @@
|
||||
},
|
||||
"button_size": "Menu button size (e.g. '40px')"
|
||||
},
|
||||
"timeline": {
|
||||
"window_seconds": "The default length of the timeline view in seconds",
|
||||
"clustering_threshold": "The count of events at which they are clustered (0=no clustering)",
|
||||
"media": "The media the timeline displays",
|
||||
"medias": {
|
||||
"all": "All media types",
|
||||
"clips": "Clips",
|
||||
"snapshots": "Snapshots"
|
||||
},
|
||||
"controls": {
|
||||
"thumbnails": {
|
||||
"mode": "Timeline thumbnails mode",
|
||||
"size": "Timeline thumbnails size (e.g. '100px')",
|
||||
"show_details": "Show event details with thumbnails",
|
||||
"show_controls": "Show event controls with thumbnails"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dimensions": {
|
||||
"aspect_ratio": "Default aspect ratio (e.g. '16:9')",
|
||||
"aspect_ratio_mode": "Aspect ratio mode",
|
||||
@@ -204,7 +228,18 @@
|
||||
"add_new_camera": "Add new camera",
|
||||
"camera": "Camera",
|
||||
"overrides": "Overrides are active",
|
||||
"overrides_secondary": "Dynamic configuration overrides detected"
|
||||
"overrides_secondary": "Dynamic configuration overrides detected",
|
||||
"timeline": "Timeline",
|
||||
"timeline_secondary": "Event timeline options"
|
||||
},
|
||||
"event": {
|
||||
"start": "Start",
|
||||
"duration": "Duration",
|
||||
"in_progress": "In Progress"
|
||||
},
|
||||
"thumbnail": {
|
||||
"retain_indefinitely": "Event will be indefinitely retained",
|
||||
"timeline": "See event in timeline"
|
||||
},
|
||||
"error": {
|
||||
"empty_response": "Received empty response from Home Assistant for request",
|
||||
|
||||
+12
-1
@@ -28,6 +28,12 @@ img,video {
|
||||
-khtml-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
:host([direction=vertical]) .embla__container {
|
||||
flex-direction: column;
|
||||
}
|
||||
:host([direction=horizontal]) .embla__container {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.embla__viewport {
|
||||
width: 100%;
|
||||
@@ -50,9 +56,14 @@ img,video {
|
||||
.embla__slide {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
margin-right: 5px;
|
||||
overflow: visible;
|
||||
}
|
||||
:host([direction=vertical]) .embla__slide {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
:host([direction=horizontal]) .embla__slide {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.embla__slide img,video {
|
||||
// Letterbox media. <frigate-card-ha-hls-player> has similar added directly in
|
||||
// its element.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
:host {
|
||||
// Drawer width sizes to contents.
|
||||
width: unset;
|
||||
}
|
||||
|
||||
#fs {
|
||||
// Hide the freespace screen.
|
||||
display: none;
|
||||
width: 100%;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
#d,
|
||||
#fs {
|
||||
// Override width/height to be 100% instead of 100v[wh].
|
||||
height: 100%;
|
||||
|
||||
// Position absolutely.
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#d {
|
||||
// Need to allow drawer controls to be visible.
|
||||
overflow: visible;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
:host([location=right]) #d {
|
||||
// Position to the right.
|
||||
left: unset;
|
||||
right: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
:host([location=right][open]) #d {
|
||||
transform: none;
|
||||
box-shadow: 0px 0px 25px 0px black;
|
||||
}
|
||||
|
||||
#ifs {
|
||||
// Override width/height to be 100% instead of 100v[wh].
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
$drawer-icon-size: 20px;
|
||||
$drawer-padding-extend: 20px;
|
||||
|
||||
side-drawer {
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
div.control-surround {
|
||||
position: absolute;
|
||||
bottom: 50%;
|
||||
transform: translateY(50%);
|
||||
z-index: 0;
|
||||
padding-top: $drawer-padding-extend;
|
||||
padding-bottom: $drawer-padding-extend;
|
||||
}
|
||||
: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 {
|
||||
@if $drawer-icon-size < 32 {
|
||||
// See note above.
|
||||
padding-left: calc(32px - $drawer-icon-size);
|
||||
}
|
||||
right: 100%;
|
||||
}
|
||||
|
||||
ha-icon.control {
|
||||
color: var(--secondary-color, white);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
opacity: 0.7;
|
||||
pointer-events: all;
|
||||
|
||||
--mdc-icon-size: #{$drawer-icon-size};
|
||||
padding-top: $drawer-padding-extend;
|
||||
padding-bottom: $drawer-padding-extend;
|
||||
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
:host([open]) ha-icon.control, ha-icon.control:hover {
|
||||
// When the drawer is open or hovered make the button to close it more
|
||||
// prominent.
|
||||
opacity: 1;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
: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 {
|
||||
border-top-left-radius: $drawer-icon-size;
|
||||
border-bottom-left-radius: $drawer-icon-size;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
ha-icon.favorite {
|
||||
position: absolute;
|
||||
color: gold;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
ha-icon.timeline {
|
||||
position: absolute;
|
||||
color: var(--primary-color);
|
||||
right: 0px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 50%;
|
||||
}
|
||||
+13
-13
@@ -1,16 +1,21 @@
|
||||
@use "@material/image-list/mdc-image-list";
|
||||
@use "@material/image-list";
|
||||
@use '@material/image-list/mdc-image-list';
|
||||
@use '@material/image-list';
|
||||
@use './favorite.scss';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
-ms-overflow-style: none; /* Hide scrollbar: IE and Edge */
|
||||
scrollbar-width: none; /* Hide scrollbar: Firefox */
|
||||
|
||||
// Hide scrollbar: IE and Edge
|
||||
-ms-overflow-style: none;
|
||||
|
||||
// Hide scrollbar: Firefox
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
// Hide scrollbar for Chrome, Safari and Opera
|
||||
:host::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
@@ -44,12 +49,7 @@ ha-card.frigate-card-gallery-folder {
|
||||
height: 100%;
|
||||
line-height: 1;
|
||||
}
|
||||
.favorite {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
top: 10%;
|
||||
left: 90%;
|
||||
color: yellow;
|
||||
|
||||
ha-icon.favorite {
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -10,7 +10,3 @@ frigate-card-live-carousel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
frigate-card-thumbnail-carousel {
|
||||
flex: 0 0 var(--frigate-card-carousel-thumbnail-size);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
// Share the screen space with thumbnails that may be above/below.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Set the drawer relative to this host.
|
||||
position: relative;
|
||||
|
||||
// Hide any content outside the main pane (e.g. side drawers) to ensure the
|
||||
// user cannot scroll across to the drawers without opening them.
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
::slotted:not([name]) {
|
||||
// Expand the main body to fill available content not otherwise used by the
|
||||
// surround.
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1,37 +1,21 @@
|
||||
:host {
|
||||
--frigate-card-carousel-thumbnail-size: 100px;
|
||||
--frigate-card-carousel-thumbnail-opacity: 0.6;
|
||||
--frigate-card-carousel-thumbnail-opacity: 1.0;
|
||||
}
|
||||
|
||||
:host([direction=vertical]) {
|
||||
height: 100%;
|
||||
}
|
||||
:host([direction=horizontal]) {
|
||||
// In fullscreen mode, without explicitly setting the height to auto Chrome
|
||||
// will construct a stylesheet with 100% height.
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.embla__slide {
|
||||
flex: 0 0 var(--frigate-card-carousel-thumbnail-size);
|
||||
flex: 0 0 auto;
|
||||
opacity: var(--frigate-card-carousel-thumbnail-opacity);
|
||||
transition: opacity 0.6s ease, transform 0.2s linear;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
.embla__slide.slide-selected {
|
||||
opacity: 1.0;
|
||||
}
|
||||
.embla__slide:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.embla__slide img {
|
||||
border-radius: 5px;
|
||||
|
||||
// Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
|
||||
// should be clipped to fill the thumbnail div whilst maintaining
|
||||
// aspect-ratio.
|
||||
object-fit: cover;
|
||||
|
||||
// Restrict images to a maximum of thumbnail size.
|
||||
max-width: var(--frigate-card-carousel-thumbnail-size);
|
||||
max-height: var(--frigate-card-carousel-thumbnail-size);
|
||||
}
|
||||
.favorite {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
top: 12%;
|
||||
left: 90%;
|
||||
color: yellow;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
@use './favorite.scss';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
--frigate-card-thumbnail-size: 100px;
|
||||
|
||||
// Do not let the contain expand to fill more height than the height of the
|
||||
// thumbnail. Without this the thumbnail carousel will allow the thumbnail to
|
||||
// expand to the full height of the viewport on a vertical carousel.
|
||||
max-height: var(--frigate-card-thumbnail-size);
|
||||
}
|
||||
|
||||
:host([details]) {
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
padding: 2px;
|
||||
|
||||
// When details are enabled, use a background color so that the details have
|
||||
// contrast with the background.
|
||||
background-color: var(--primary-background-color, black);
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: var(--ha-card-border-radius, 4px);
|
||||
|
||||
// Not 'contain' as some thumbnails may vary in aspect-ratio slightly and
|
||||
// should be clipped to fill the thumbnail div whilst maintaining
|
||||
// aspect-ratio.
|
||||
object-fit: cover;
|
||||
|
||||
// Restrict images to a maximum of thumbnail size.
|
||||
max-width: var(--frigate-card-thumbnail-size);
|
||||
max-height: var(--frigate-card-thumbnail-size);
|
||||
min-width: var(--frigate-card-thumbnail-size);
|
||||
min-height: var(--frigate-card-thumbnail-size);
|
||||
|
||||
transition: transform 0.2s linear;
|
||||
}
|
||||
img:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
div.details {
|
||||
display: flex;
|
||||
|
||||
width: 200px;
|
||||
margin-left: 5px;
|
||||
padding: 8px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
div.details div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
div.details div.left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
div.larger {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
span.heading {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
@use 'vis-timeline/dist/vis-timeline-graph2d.css';
|
||||
@use 'drawer';
|
||||
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--card-background-color);
|
||||
padding-bottom: 5px;
|
||||
|
||||
// Share the screen space with thumbnails that may be above/below.
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// So that absolute sidedrawer is relative to this host.
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div.timeline {
|
||||
flex: 1;
|
||||
}
|
||||
div.timeline.left-margin {
|
||||
// Clearance for the drawer button.
|
||||
margin-left: calc(drawer.$drawer-icon-size + 1px);
|
||||
}
|
||||
div.timeline.right-margin {
|
||||
// Clearance for the drawer button.
|
||||
margin-right: calc(drawer.$drawer-icon-size + 1px);
|
||||
}
|
||||
|
||||
.vis-text {
|
||||
color: var(--primary-text-color) !important;
|
||||
}
|
||||
|
||||
.vis-timeline {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vis-labelset .vis-label {
|
||||
// Group labels.
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.vis-item {
|
||||
border-color: var(--primary-color);
|
||||
background: none;
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.vis-item:hover {
|
||||
// Float icons upwards when the user hovers over them.
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.vis-item.vis-box {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vis-item .vis-item-content {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.vis-item.vis-cluster {
|
||||
border-style: dotted;
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--primary-background-color);
|
||||
box-shadow: 0px 0px 5px 1px var(--primary-color);
|
||||
}
|
||||
|
||||
.vis-time-axis .vis-grid.vis-minor {
|
||||
border-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.vis-time-axis .vis-grid.vis-major {
|
||||
border-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.vis-label {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
div.vis-tooltip {
|
||||
padding: 0px;
|
||||
background-color: unset;
|
||||
border: none;
|
||||
|
||||
// Use browser default font-family for tooltips.
|
||||
font-family: unset;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
--frigate-card-timeline-thumbnail-size: 75px;
|
||||
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
img {
|
||||
width: var(--frigate-card-timeline-thumbnail-size);
|
||||
height: var(--frigate-card-timeline-thumbnail-size);
|
||||
display: block;
|
||||
}
|
||||
@@ -1,73 +1,5 @@
|
||||
@use 'vis-timeline/dist/vis-timeline-graph2d.css';
|
||||
|
||||
:host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
background-color: var(--card-background-color);
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
div.timeline {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vis-text {
|
||||
color: var(--primary-text-color) !important;
|
||||
}
|
||||
|
||||
.vis-timeline {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vis-labelset .vis-label {
|
||||
// Group labels.
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.vis-item {
|
||||
border-color: var(--primary-color);
|
||||
background: none;
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.vis-item:hover {
|
||||
// Float icons upwards when the user hovers over them.
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.vis-item.vis-box {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.vis-item .vis-item-content {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.vis-item.vis-cluster {
|
||||
border-style: dotted;
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--primary-background-color);
|
||||
box-shadow: 0px 0px 5px 1px var(--primary-color);
|
||||
}
|
||||
|
||||
.vis-time-axis .vis-grid.vis-minor {
|
||||
border-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.vis-time-axis .vis-grid.vis-major {
|
||||
border-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.vis-label {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
div.vis-tooltip {
|
||||
padding: 0px;
|
||||
background-color: unset;
|
||||
border: none;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
frigate-card-viewer-carousel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
frigate-card-thumbnail-carousel {
|
||||
flex: 0 0 var(--frigate-card-carousel-thumbnail-size);
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: block;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
frigate-card-viewer-carousel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
+89
-41
@@ -35,7 +35,14 @@ const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
|
||||
'timeline',
|
||||
] as const;
|
||||
|
||||
export type FrigateCardView = typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number];
|
||||
const FRIGATE_CARD_VIEWS = [
|
||||
...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||
|
||||
// Event: A clip or snapshot (timeline may produce mixed media lists).
|
||||
'event',
|
||||
] as const;
|
||||
|
||||
export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number];
|
||||
|
||||
const FRIGATE_MENU_MODES = [
|
||||
'none',
|
||||
@@ -335,8 +342,8 @@ export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
|
||||
const menuSubmenuItemSchema = elementsBaseSchema.extend({
|
||||
entity: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
state_color: z.boolean().default(true),
|
||||
selected: z.boolean().default(false),
|
||||
state_color: z.boolean().default(true).optional(),
|
||||
selected: z.boolean().default(false).optional(),
|
||||
});
|
||||
export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>;
|
||||
|
||||
@@ -434,8 +441,10 @@ export type ImageViewConfig = z.infer<typeof imageConfigSchema>;
|
||||
*/
|
||||
|
||||
const thumbnailsControlSchema = z.object({
|
||||
mode: z.enum(['none', 'above', 'below']),
|
||||
mode: z.enum(['none', 'above', 'below', 'left', 'right']),
|
||||
size: z.string().optional(),
|
||||
show_details: z.boolean().optional(),
|
||||
show_controls: z.boolean().optional(),
|
||||
});
|
||||
export type ThumbnailsControlConfig = z.infer<typeof thumbnailsControlSchema>;
|
||||
|
||||
@@ -488,6 +497,8 @@ const liveConfigDefault = {
|
||||
thumbnails: {
|
||||
media: 'clips' as const,
|
||||
size: '100px',
|
||||
show_details: false,
|
||||
show_controls: true,
|
||||
mode: 'none' as const,
|
||||
},
|
||||
title: {
|
||||
@@ -548,6 +559,12 @@ const liveOverridableConfigSchema = z
|
||||
size: thumbnailsControlSchema.shape.size.default(
|
||||
liveConfigDefault.controls.thumbnails.size,
|
||||
),
|
||||
show_details: thumbnailsControlSchema.shape.show_details.default(
|
||||
liveConfigDefault.controls.thumbnails.show_details,
|
||||
),
|
||||
show_controls: thumbnailsControlSchema.shape.show_controls.default(
|
||||
liveConfigDefault.controls.thumbnails.show_controls,
|
||||
),
|
||||
media: z
|
||||
.enum(['clips', 'snapshots'])
|
||||
.default(liveConfigDefault.controls.thumbnails.media),
|
||||
@@ -642,6 +659,8 @@ const viewerConfigDefault = {
|
||||
thumbnails: {
|
||||
size: '100px',
|
||||
mode: 'none' as const,
|
||||
show_details: false,
|
||||
show_controls: true,
|
||||
},
|
||||
title: {
|
||||
mode: 'popup-bottom-right' as const,
|
||||
@@ -681,6 +700,12 @@ const viewerConfigSchema = z
|
||||
size: thumbnailsControlSchema.shape.size.default(
|
||||
viewerConfigDefault.controls.thumbnails.size,
|
||||
),
|
||||
show_details: thumbnailsControlSchema.shape.show_details.default(
|
||||
viewerConfigDefault.controls.thumbnails.show_details,
|
||||
),
|
||||
show_controls: thumbnailsControlSchema.shape.show_controls.default(
|
||||
viewerConfigDefault.controls.thumbnails.show_controls,
|
||||
),
|
||||
})
|
||||
.default(viewerConfigDefault.controls.thumbnails),
|
||||
title: titleControlConfigSchema
|
||||
@@ -745,38 +770,56 @@ const dimensionsConfigSchema = z
|
||||
* Timeline configuration section.
|
||||
*/
|
||||
const timelineConfigDefault = {
|
||||
clustering_threshold: 3,
|
||||
media: 'all' as const,
|
||||
window_seconds: 60 * 60,
|
||||
controls: {
|
||||
thumbnails: {
|
||||
size_pixels: 75,
|
||||
overlap_pixels: 25,
|
||||
clustering_threshold: 3,
|
||||
mode: 'left' as const,
|
||||
size: '100px' as const,
|
||||
show_details: true,
|
||||
show_controls: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const timelineConfigSchema = z
|
||||
.object({
|
||||
clustering_threshold: z
|
||||
.number()
|
||||
.optional()
|
||||
.default(timelineConfigDefault.clustering_threshold),
|
||||
media: z
|
||||
.enum(['all', 'clips', 'snapshots'])
|
||||
.optional()
|
||||
.default(timelineConfigDefault.media),
|
||||
window_seconds: z
|
||||
.number()
|
||||
.min(1 * 60)
|
||||
.max(24 * 60 * 60)
|
||||
.optional()
|
||||
.default(timelineConfigDefault.window_seconds),
|
||||
controls: z
|
||||
.object({
|
||||
thumbnails: z
|
||||
.object({
|
||||
size_pixels: z
|
||||
.number()
|
||||
.min(50)
|
||||
.max(THUMBNAIL_WIDTH_MAX)
|
||||
.default(timelineConfigDefault.controls.thumbnails.size_pixels),
|
||||
overlap_pixels: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(THUMBNAIL_WIDTH_MAX)
|
||||
.default(timelineConfigDefault.controls.thumbnails.overlap_pixels),
|
||||
clustering_threshold: z
|
||||
.number()
|
||||
.default(timelineConfigDefault.controls.thumbnails.clustering_threshold),
|
||||
thumbnails: thumbnailsControlSchema
|
||||
.extend({
|
||||
mode: thumbnailsControlSchema.shape.mode.default(
|
||||
timelineConfigDefault.controls.thumbnails.mode,
|
||||
),
|
||||
size: thumbnailsControlSchema.shape.size.default(
|
||||
timelineConfigDefault.controls.thumbnails.size,
|
||||
),
|
||||
show_details: thumbnailsControlSchema.shape.show_details.default(
|
||||
timelineConfigDefault.controls.thumbnails.show_details,
|
||||
),
|
||||
show_controls: thumbnailsControlSchema.shape.show_controls.default(
|
||||
timelineConfigDefault.controls.thumbnails.show_controls,
|
||||
),
|
||||
})
|
||||
.default(timelineConfigDefault.controls.thumbnails),
|
||||
})
|
||||
.default(timelineConfigDefault.controls),
|
||||
})
|
||||
.merge(actionsSchema)
|
||||
.default(timelineConfigDefault);
|
||||
export type TimelineConfig = z.infer<typeof timelineConfigSchema>;
|
||||
|
||||
@@ -860,8 +903,8 @@ export interface ExtendedHomeAssistant {
|
||||
hassUrl(path?): string;
|
||||
}
|
||||
|
||||
export interface BrowseMediaQueryParameters {
|
||||
mediaType: 'clips' | 'snapshots';
|
||||
export interface BrowseMediaQueryParametersBase {
|
||||
mediaType?: 'clips' | 'snapshots';
|
||||
clientId: string;
|
||||
cameraName: string;
|
||||
label?: string;
|
||||
@@ -871,6 +914,10 @@ export interface BrowseMediaQueryParameters {
|
||||
unlimited?: boolean;
|
||||
}
|
||||
|
||||
export interface BrowseMediaQueryParameters extends BrowseMediaQueryParametersBase {
|
||||
mediaType: 'clips' | 'snapshots';
|
||||
}
|
||||
|
||||
export interface BrowseMediaNeighbors {
|
||||
previous: FrigateBrowseMediaSource | null;
|
||||
previousIndex: number | null;
|
||||
@@ -912,9 +959,8 @@ export interface FrigateCardMediaPlayer {
|
||||
* Home Assistant API types.
|
||||
*/
|
||||
|
||||
export const MEDIA_CLASS_PLAYLIST = "playlist" as const;
|
||||
export const MEDIA_CLASS_VIDEO = "video" as const;
|
||||
export const MEDIA_TYPE_VIDEO = "video" as const;
|
||||
export const MEDIA_CLASS_PLAYLIST = 'playlist' as const;
|
||||
export const MEDIA_TYPE_PLAYLIST = 'playlist' as const;
|
||||
|
||||
// Recursive type, cannot use type interference:
|
||||
// See: https://github.com/colinhacks/zod#recursive-types
|
||||
@@ -932,22 +978,24 @@ interface BrowseMediaSource {
|
||||
children?: BrowseMediaSource[] | null;
|
||||
}
|
||||
|
||||
export interface FrigateEvent {
|
||||
camera: string;
|
||||
end_time?: number;
|
||||
false_positive: boolean;
|
||||
has_clip: boolean;
|
||||
has_snapshot: boolean;
|
||||
id: string;
|
||||
label: string;
|
||||
start_time: number;
|
||||
top_score: number;
|
||||
zones: string[];
|
||||
retain_indefinitely?: boolean;
|
||||
}
|
||||
|
||||
export interface FrigateBrowseMediaSource extends BrowseMediaSource {
|
||||
children?: FrigateBrowseMediaSource[] | null;
|
||||
frigate?: {
|
||||
event: {
|
||||
camera: string;
|
||||
end_time: number;
|
||||
false_positive: boolean;
|
||||
has_clip: boolean;
|
||||
has_snapshot: boolean;
|
||||
id: string;
|
||||
label: string;
|
||||
start_time: number;
|
||||
top_score: number;
|
||||
zones: string[];
|
||||
retain_indefinitely: boolean;
|
||||
};
|
||||
event: FrigateEvent;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -976,7 +1024,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.
|
||||
start_time: z.number(),
|
||||
top_score: z.number(),
|
||||
zones: z.string().array(),
|
||||
retain_indefinitely: z.boolean(),
|
||||
retain_indefinitely: z.boolean().optional(),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
+36
-29
@@ -1,12 +1,16 @@
|
||||
import type { FrigateBrowseMediaSource, FrigateCardView } from './types.js';
|
||||
import { dispatchFrigateCardEvent } from './common.js';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
export interface ViewContext {}
|
||||
|
||||
export interface ViewEvolveParameters {
|
||||
view?: FrigateCardView;
|
||||
camera?: string;
|
||||
target?: FrigateBrowseMediaSource;
|
||||
childIndex?: number;
|
||||
previous?: View;
|
||||
target?: FrigateBrowseMediaSource | null;
|
||||
childIndex?: number | null;
|
||||
previous?: View | null;
|
||||
context?: ViewContext | null;
|
||||
}
|
||||
|
||||
export interface ViewParameters extends ViewEvolveParameters {
|
||||
@@ -17,16 +21,18 @@ export interface ViewParameters extends ViewEvolveParameters {
|
||||
export class View {
|
||||
view: FrigateCardView;
|
||||
camera: string;
|
||||
target?: FrigateBrowseMediaSource;
|
||||
childIndex?: number;
|
||||
previous?: View;
|
||||
target: FrigateBrowseMediaSource | null;
|
||||
childIndex: number | null;
|
||||
previous: View | null;
|
||||
context: ViewContext | null;
|
||||
|
||||
constructor(params: ViewParameters) {
|
||||
this.view = params?.view;
|
||||
this.camera = params?.camera;
|
||||
this.target = params?.target;
|
||||
this.childIndex = params?.childIndex;
|
||||
this.previous = params?.previous;
|
||||
this.view = params.view;
|
||||
this.camera = params.camera;
|
||||
this.target = params.target ?? null;
|
||||
this.childIndex = params.childIndex ?? null;
|
||||
this.previous = params.previous ?? null;
|
||||
this.context = params.context ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,7 +44,8 @@ export class View {
|
||||
camera: this.camera,
|
||||
target: this.target,
|
||||
childIndex: this.childIndex,
|
||||
previous: this.previous
|
||||
previous: this.previous,
|
||||
context: this.context,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -49,12 +56,16 @@ export class View {
|
||||
*/
|
||||
public evolve(params: ViewEvolveParameters): View {
|
||||
return new View({
|
||||
view: params.view ?? this.view,
|
||||
camera: params.camera ?? this.camera,
|
||||
target: params.target ?? this.target,
|
||||
childIndex: params.childIndex ?? this.childIndex,
|
||||
previous: params.previous ?? this.previous,
|
||||
})
|
||||
view: params.view !== undefined ? params.view : this.view,
|
||||
camera: params.camera !== undefined ? params.camera : this.camera,
|
||||
target: params.target !== undefined ? params.target : this.target,
|
||||
childIndex: params.childIndex !== undefined ? params.childIndex : this.childIndex,
|
||||
context: params.context !== undefined ? params.context : this.context,
|
||||
|
||||
// Special case: Set the previous to this of the evolved view (rather than
|
||||
// the previous of this).
|
||||
previous: params.previous !== undefined ? params.previous : this,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,24 +86,21 @@ export class View {
|
||||
* Determine if a view is of a piece of media (i.e. not the gallery).
|
||||
*/
|
||||
public isMediaView(): boolean {
|
||||
return !this.isGalleryView();
|
||||
return this.isViewerView() || this.is('live');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a view is for the media viewer.
|
||||
*/
|
||||
public isViewerView(): boolean {
|
||||
return ['clip', 'snapshot'].includes(
|
||||
this.view,
|
||||
);
|
||||
return ['clip', 'snapshot', 'event'].includes(this.view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a view is related to a clip or clips.
|
||||
*/
|
||||
public isClipRelatedView(): boolean {
|
||||
// TODO HACK HACK HACK
|
||||
return ['clip', 'clips', 'timeline'].includes(this.view);
|
||||
return ['clip', 'clips'].includes(this.view);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,14 +113,13 @@ export class View {
|
||||
/**
|
||||
* Get the media item that should be played.
|
||||
**/
|
||||
get media(): FrigateBrowseMediaSource | undefined {
|
||||
get media(): FrigateBrowseMediaSource | null {
|
||||
if (this.target) {
|
||||
if (this.target.children && this.childIndex !== undefined) {
|
||||
return this.target.children[this.childIndex];
|
||||
if (this.target.children && this.childIndex !== null) {
|
||||
return this.target.children[this.childIndex] ?? null;
|
||||
}
|
||||
return this.target;
|
||||
}
|
||||
return undefined;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user