Merge pull request #508 from dermotduffy/thumbnails-on-the-side

Significantly flesh out timeline support
This commit is contained in:
Dermot Duffy
2022-04-11 21:10:41 -07:00
committed by GitHub
36 changed files with 2125 additions and 823 deletions
+42 -3
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+22 -3
View File
@@ -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();
+85
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+147
View File
@@ -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);
}
}
+71
View File
@@ -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);
}
}
+116 -55
View File
@@ -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>`;
}
/**
+141
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+57 -129
View File
@@ -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}"
+18
View File
@@ -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;
+70
View File
@@ -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">
+36 -1
View File
@@ -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
View File
@@ -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.
+43
View File
@@ -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%;
}
+59
View File
@@ -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;
}
+14
View File
@@ -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%;
}
+14 -14
View File
@@ -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;
}
-4
View File
@@ -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);
}
+5
View File
@@ -0,0 +1,5 @@
:host {
width: 100%;
height: 100%;
display: block;
}
+21
View File
@@ -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;
}
+12 -28
View File
@@ -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;
}
+68
View File
@@ -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;
}
+91
View File
@@ -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;
}
-16
View File
@@ -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 -69
View File
@@ -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;
}
}
-17
View File
@@ -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);
}
+9 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
/**