diff --git a/README.md b/README.md index f8f60ad7..07d8695e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/package.json b/package.json index b1824898..91ddb3fd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index a79a8fc9..cd2d1c3a 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -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); diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index 37f19352..381053a8 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -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); } diff --git a/src/card.ts b/src/card.ts index 0b3f8cae..f73a3937 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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 { - 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 }; } diff --git a/src/common.ts b/src/common.ts index 77eb7e30..4bf4301c 100644 --- a/src/common.ts +++ b/src/common.ts @@ -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( - element: HTMLElement, + target: EventTarget, name: string, detail?: T, ): void { - element.dispatchEvent( + target.dispatchEvent( new CustomEvent(`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; +} diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 217506d9..9cd9b923 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -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 = {}; @@ -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(); diff --git a/src/components/drawer.ts b/src/components/drawer.ts new file mode 100644 index 00000000..2945b3f3 --- /dev/null +++ b/src/components/drawer.ts @@ -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 = createRef(); + protected _refSlot: Ref = 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` + + ${this.control + ? html` +
{ + this.open = !this.open; + }} + > + + +
+ ` + : ''} + +
+ `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(drawerStyle); + } +} diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 4428681a..b83c8382 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -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`