From 983aff4dd8f2a3717631197202a26364fc03afc8 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 6 Mar 2022 18:44:04 -0800 Subject: [PATCH 001/345] Initial skeleton timeline. --- package.json | 11 +++ rollup.config.js | 6 +- src/browse-media-util.ts | 48 ++++++------ src/card.ts | 9 +++ src/components/live.ts | 4 +- src/components/thumbnail-carousel.ts | 8 +- src/components/timeline.ts | 112 +++++++++++++++++++++++++++ src/components/viewer.ts | 21 ++--- src/resolved-media.ts | 4 +- src/scss/timeline.scss | 7 ++ src/types.ts | 90 ++++++++++++++------- src/view.ts | 11 +-- 12 files changed, 256 insertions(+), 75 deletions(-) create mode 100644 src/components/timeline.ts create mode 100644 src/scss/timeline.scss diff --git a/package.json b/package.json index 6f8cf9ee..faebdaf0 100644 --- a/package.json +++ b/package.json @@ -16,18 +16,29 @@ "license": "MIT", "dependencies": { "@cycjimmy/jsmpeg-player": "^5.1.1", + "@egjs/hammerjs": "^2.0.17", "@lit-labs/task": "^1.0.0", "@material/image-list": "^13.0.0", "@material/mwc-menu": "^0.25.3", "@material/rtl": "^13.0.0", + "component-emitter": "^1.3.0", + "crypto": "^1.0.1", "custom-card-helpers": "^1.8.0", "dayjs": "^1.11.0", "embla-carousel": "^6.1.1", "home-assistant-js-websocket": "^6.1.1", "lit": "^2.2.1", + "keycharm": "^0.4.0", "lodash-es": "^4.17.21", "quick-lru": "^6.1.0", + "moment": "^2.29.1", + "propagating-hammerjs": "^2.0.1", "screenfull": "^6.0.1", + "uuid": "^8.3.2", + "vis-data": "^7.1.3", + "vis-timeline": "^7.5.1", + "vis-util": "^5.0.2", + "xss": "^1.0.10", "zod": "^3.13.4" }, "devDependencies": { diff --git a/rollup.config.js b/rollup.config.js index 8bb3fc68..bfaa4b1e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -30,9 +30,11 @@ const plugins = [ }, }), image(), - nodeResolve({}), + nodeResolve({ + browser: true, + }), commonjs({ - include: 'node_modules/**' + include: 'node_modules/**', }), typescript(), json(), diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index 336dcc1c..c6069b1d 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -4,12 +4,12 @@ import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js'; import type { BrowseMediaQueryParameters, - BrowseMediaSource, + FrigateBrowseMediaSource, CameraConfig, ExtendedHomeAssistant, } from './types.js'; import { View } from './view.js'; -import { browseMediaSourceSchema } from './types.js'; +import { frigateBrowseMediaSourceSchema } from './types.js'; import { dispatchErrorMessageEvent, dispatchMessageEvent, @@ -21,11 +21,11 @@ dayjs.extend(dayjs_custom_parse_format); export class BrowseMediaUtil { /** - * Return the Frigate event_id given a BrowseMediaSource object. + * Return the Frigate event_id given a FrigateBrowseMediaSource object. * @param media The event to extract the id from. * @returns The `event_id` or `null` if not successfully parsed. */ - static extractEventID(media: BrowseMediaSource): string | null { + static extractEventID(media: FrigateBrowseMediaSource): string | null { const result = media.media_content_id.match( /^media-source:\/\/frigate\/.*\/(?[.0-9]+-[a-zA-Z0-9]+)$/, ); @@ -33,11 +33,11 @@ export class BrowseMediaUtil { } /** - * Return the event start time given a BrowseMediaSource object. + * Return the event start time given a FrigateBrowseMediaSource object. * @param browseMedia The media object to extract the start time from. * @returns The start time in unix/epoch time, or null if it cannot be determined. */ - static extractEventStartTime(browseMedia: BrowseMediaSource): number | null { + static extractEventStartTime(browseMedia: FrigateBrowseMediaSource): number | null { // Example: 2021-08-27 20:57:22 [10s, Person 76%] const result = browseMedia.title.match(/^(?.+) \[/); if (result && result.groups) { @@ -53,21 +53,23 @@ export class BrowseMediaUtil { } /** - * Determine if a BrowseMediaSource object is truly a media item (vs a folder). + * Determine if a FrigateBrowseMediaSource object is truly a media item (vs a folder). * @param media The media object. * @returns `true` if it's truly a media item, `false` otherwise. */ - static isTrueMedia(media: BrowseMediaSource): boolean { + static isTrueMedia(media: FrigateBrowseMediaSource): boolean { return !media.can_expand; } /** - * From a BrowseMediaSource item extract the first true media item from the + * From a FrigateBrowseMediaSource item extract the first true media item from the * children (i.e. a clip/snapshot, not a folder). * @param media The media object with children. * @returns The first true media item found. */ - static getFirstTrueMediaChildIndex(media: BrowseMediaSource | null): number | null { + static getFirstTrueMediaChildIndex( + media: FrigateBrowseMediaSource | null, + ): number | null { if (!media || !media.children) { return null; } @@ -79,17 +81,17 @@ export class BrowseMediaUtil { * Browse Frigate media with a media content id. May throw. * @param hass The HomeAssistant object. * @param media_content_id The media content id to browse. - * @returns A BrowseMediaSource object or null on malformed. + * @returns A FrigateBrowseMediaSource object or null on malformed. */ static async browseMedia( hass: HomeAssistant & ExtendedHomeAssistant, media_content_id: string, - ): Promise { + ): Promise { const request = { type: 'media_source/browse_media', media_content_id: media_content_id, }; - return homeAssistantWSRequest(hass, browseMediaSourceSchema, request); + return homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request); } // Browse Frigate media with query parameters. @@ -98,12 +100,12 @@ export class BrowseMediaUtil { * Browse Frigate media with a media query. May throw. * @param hass The HomeAssistant object. * @param params The search parameters to use to search for media. - * @returns A BrowseMediaSource object or null on malformed. + * @returns A FrigateBrowseMediaSource object or null on malformed. */ static async browseMediaQuery( hass: HomeAssistant & ExtendedHomeAssistant, params: BrowseMediaQueryParameters, - ): Promise { + ): Promise { return this.browseMedia( hass, // Defined in: @@ -180,7 +182,7 @@ export class BrowseMediaUtil { * @param hass The Home Assistant object. * @param view The current view to evolve. * @param browseMediaQueryParameters The media parameters to query with. - * @returns + * @returns */ static async fetchLatestMediaAndDispatchViewChange( node: HTMLElement, @@ -188,7 +190,7 @@ export class BrowseMediaUtil { view: Readonly, browseMediaQueryParameters: BrowseMediaQueryParameters, ): Promise { - let parent: BrowseMediaSource | null; + let parent: FrigateBrowseMediaSource | null; try { parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaQueryParameters); } catch (e) { @@ -216,21 +218,21 @@ export class BrowseMediaUtil { } /** - * Fetch the media of a child BrowseMediaSource object and dispatch a change - * view event to reflect the results. + * Fetch the media of a child FrigateBrowseMediaSource object and dispatch a change + * view event to reflect the results. * @param node The HTMLElement to dispatch events from. * @param hass The Home Assistant object. * @param view The current view to evolve. - * @param child The BrowseMediaSource child to query for. - * @returns + * @param child The FrigateBrowseMediaSource child to query for. + * @returns */ static async fetchChildMediaAndDispatchViewChange( node: HTMLElement, hass: HomeAssistant & ExtendedHomeAssistant, view: Readonly, - child: Readonly, + child: Readonly, ): Promise { - let parent: BrowseMediaSource; + let parent: FrigateBrowseMediaSource; try { parent = await BrowseMediaUtil.browseMedia(hass, child.media_content_id); } catch (e) { diff --git a/src/card.ts b/src/card.ts index 7b38ac74..7c758015 100644 --- a/src/card.ts +++ b/src/card.ts @@ -64,6 +64,7 @@ import './components/menu.js'; import './components/message.js'; import './components/viewer.js'; import './components/thumbnail-carousel.js'; +import './components/timeline.js'; import './patches/ha-camera-stream.js'; import './patches/ha-hls-player.js'; import './patches/ha-web-rtc-player.ts'; @@ -1260,6 +1261,14 @@ export class FrigateCard extends LitElement { > ` : ``} + ${!this._message && this._view.is('timeline') + ? html` + ` + : ``} ${ // Note: Subtle difference in condition below vs the other views in order // to always render the live view for live.preload mode. diff --git a/src/components/live.ts b/src/components/live.ts index cb54870a..d34c594f 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -7,7 +7,7 @@ import { PropertyValues, } from 'lit'; import { - BrowseMediaSource, + FrigateBrowseMediaSource, ExtendedHomeAssistant, CameraConfig, JSMPEGConfig, @@ -140,7 +140,7 @@ export class FrigateCardLive extends LitElement { if (!browseMediaParams) { return; } - let parent: BrowseMediaSource | null; + let parent: FrigateBrowseMediaSource | null; try { parent = await BrowseMediaUtil.browseMediaQuery(this.hass, browseMediaParams); } catch (e) { diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 3b109deb..7ad9dd18 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -3,7 +3,7 @@ import { CSSResultGroup, TemplateResult, html, unsafeCSS } from 'lit'; import { EmblaOptionsType } from 'embla-carousel'; import { customElement, property } from 'lit/decorators.js'; -import type { BrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; +import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; import { FrigateCardCarousel } from './carousel.js'; import { dispatchFrigateCardEvent, stopEventFromActivatingCardWideActions } from '../common.js'; @@ -11,14 +11,14 @@ import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; export interface ThumbnailCarouselTap { slideIndex: number; - target: BrowseMediaSource; + target: FrigateBrowseMediaSource; childIndex: number; } @customElement('frigate-card-thumbnail-carousel') export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { @property({ attribute: false }) - protected target?: BrowseMediaSource; + protected target?: FrigateBrowseMediaSource; protected _tapSelected?; @@ -96,7 +96,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { * @returns A template or void if the item could not be rendered. */ protected _renderThumbnail( - parent: BrowseMediaSource, + parent: FrigateBrowseMediaSource, childIndex: number, slideIndex: number, ): TemplateResult | void { diff --git a/src/components/timeline.ts b/src/components/timeline.ts new file mode 100644 index 00000000..43f8be2e --- /dev/null +++ b/src/components/timeline.ts @@ -0,0 +1,112 @@ +import { + CSSResultGroup, + LitElement, + TemplateResult, + html, + unsafeCSS, + PropertyValues, +} from 'lit'; +import { DataSet } from 'vis-data/esnext'; +import { HomeAssistant } from 'custom-card-helpers'; +import { Timeline } from 'vis-timeline/esnext'; +import { customElement, property } from 'lit/decorators.js'; +import { createRef, ref, Ref } from 'lit/directives/ref'; + +import { BrowseMediaUtil } from '../browse-media-util'; +import { CameraConfig, ExtendedHomeAssistant } from '../types'; +import { View } from '../view'; +import { renderProgressIndicator } from './message'; + +import timelineStyle from '../scss/timeline.scss'; + +interface FrigateCardTimelineData { + id: string; + content: string; + start: number; +} + +@customElement('frigate-card-timeline') +export class FrigateCardTimeline extends LitElement { + @property({ attribute: false }) + protected hass?: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected view?: Readonly; + + @property({ attribute: false }) + protected cameraConfig?: CameraConfig; + + protected _timelineRef: Ref = createRef(); + protected _timeline?: Timeline; + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.hass || !this.view || !this.cameraConfig) { + return; + } + + if (!this.view.target) { + const browseMediaQueryParameters = + BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError( + this, + this.view, + this.cameraConfig, + ); + if (!browseMediaQueryParameters) { + return; + } + + BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange( + this, + this.hass, + this.view, + browseMediaQueryParameters, + ); + return renderProgressIndicator(); + } + return html`
`; + } + + protected _buildDataset(): DataSet { + const items: FrigateCardTimelineData[] = []; + + this.view?.target?.children?.forEach((child) => { + if (child.frigate) { + const item = { + id: child.media_content_id, + content: child.frigate.event.id, + start: child.frigate.event.start_time * 1000, + }; + if (child.frigate.event.end_time) { + //item['end'] = child.frigate.event.end_time * 1000; + } + items.push(item); + } + }); + return new DataSet(items); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected updated(_changedProperties: PropertyValues): void { + // Configuration for the Timeline + const options = { + minHeight: '300px', + }; + + // Create a Timeline + if (this._timelineRef.value && !this._timeline) { + this._timeline = new Timeline( + this._timelineRef.value, + this._buildDataset(), + options, + ); + } + } + + static get styles(): CSSResultGroup { + return unsafeCSS(timelineStyle); + } +} diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 21dbc54f..7a713e99 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -18,7 +18,7 @@ import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import type { BrowseMediaNeighbors, BrowseMediaQueryParameters, - BrowseMediaSource, + FrigateBrowseMediaSource, CameraConfig, ExtendedHomeAssistant, MediaShowInfo, @@ -210,14 +210,17 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { @property({ attribute: false }) protected resolvedMediaCache?: ResolvedMediaCache; - // Mapping of slide # to BrowseMediaSource child #. + // Mapping of slide # to FrigateBrowseMediaSource child #. // (Folders are not media items that can be rendered). protected _slideToChild: Record = {}; // A task to resolve target media if lazy loading is disabled. - protected _mediaResolutionTask = new Task<[BrowseMediaSource | undefined], void>( + protected _mediaResolutionTask = new Task< + [FrigateBrowseMediaSource | undefined], + void + >( this, - async ([target]: (BrowseMediaSource | undefined)[]): Promise => { + async ([target]: (FrigateBrowseMediaSource | undefined)[]): Promise => { for ( let i = 0; !this.viewerConfig?.lazy_load && @@ -280,7 +283,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { /** * Unmute the media on the selected slide. */ - protected _autoUnmuteHandler(): void { + protected _autoUnmuteHandler(): void { if (this.viewerConfig?.auto_unmute) { super._autoUnmuteHandler(); } @@ -308,7 +311,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { * Get the transition effect to use. * @returns An TransitionEffect object. */ - protected _getTransitionEffect(): TransitionEffect | undefined { + protected _getTransitionEffect(): TransitionEffect | undefined { return this.viewerConfig?.transition_effect; } @@ -398,7 +401,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { * @returns The view that would show the matching clip. */ protected async _findRelatedClipView( - snapshot: BrowseMediaSource, + snapshot: FrigateBrowseMediaSource, ): Promise { if ( !this.hass || @@ -445,7 +448,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { return null; } - let clips: BrowseMediaSource | null; + let clips: FrigateBrowseMediaSource | null; try { clips = await BrowseMediaUtil.browseMediaQuery(this.hass, { @@ -689,7 +692,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { } protected _renderMediaItem( - mediaToRender: BrowseMediaSource, + mediaToRender: FrigateBrowseMediaSource, slideIndex: number, ): TemplateResult | void { // Skip folders as they cannot be rendered by this viewer. diff --git a/src/resolved-media.ts b/src/resolved-media.ts index 4268719e..6b7bd0e6 100644 --- a/src/resolved-media.ts +++ b/src/resolved-media.ts @@ -1,8 +1,8 @@ import { HomeAssistant } from 'custom-card-helpers'; import { homeAssistantWSRequest } from './common.js'; import { - BrowseMediaSource, ExtendedHomeAssistant, + FrigateBrowseMediaSource, ResolvedMedia, resolvedMediaSchema, } from './types.js'; @@ -39,7 +39,7 @@ export class ResolvedMediaCache { export class ResolvedMediaUtil { static async resolveMedia( hass: HomeAssistant & ExtendedHomeAssistant, - mediaSource?: BrowseMediaSource, + mediaSource?: FrigateBrowseMediaSource, cache?: ResolvedMediaCache, ): Promise { if (!mediaSource) { diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss new file mode 100644 index 00000000..eb7759dd --- /dev/null +++ b/src/scss/timeline.scss @@ -0,0 +1,7 @@ +@use "vis-timeline/dist/vis-timeline-graph2d.css"; + +:host { + width: 100%; + height: 100%; + display: block; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 04f3fb72..179f7efe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,12 +26,13 @@ declare global { */ const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ - 'live', // Live view. - 'clip', // Most recent clip. - 'clips', // Clips gallery. + 'live', // Live view. + 'clip', // Most recent clip. + 'clips', // Clips gallery. 'snapshot', // Most recent snapshot. - 'snapshots', // Snapshots gallery. - 'image', // Static image. + 'snapshots',// Snapshots gallery. + 'image', // Static image. + 'timeline', // Event timeline. ] as const; export type FrigateCardView = typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number]; @@ -409,11 +410,7 @@ const viewConfigSchema = z * Image view configuration section. */ -export const IMAGE_MODES = [ - 'screensaver', - 'camera', - 'url', -] as const; +export const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const; const imageConfigDefault = { mode: 'url' as const, refresh_seconds: 0, @@ -575,7 +572,9 @@ const liveConfigSchema = liveOverridableConfigSchema lazy_load: z.boolean().default(liveConfigDefault.lazy_load), lazy_unload: z.boolean().default(liveConfigDefault.lazy_unload), draggable: z.boolean().default(liveConfigDefault.draggable), - transition_effect: transitionEffectConfigSchema.default(liveConfigDefault.transition_effect), + transition_effect: transitionEffectConfigSchema.default( + liveConfigDefault.transition_effect, + ), }) .default(liveConfigDefault); export type LiveConfig = z.infer; @@ -660,7 +659,9 @@ const viewerConfigSchema = z auto_unmute: z.boolean().default(viewerConfigDefault.auto_unmute), lazy_load: z.boolean().default(viewerConfigDefault.lazy_load), draggable: z.boolean().default(viewerConfigDefault.draggable), - transition_effect: transitionEffectConfigSchema.default(viewerConfigDefault.transition_effect), + transition_effect: transitionEffectConfigSchema.default( + viewerConfigDefault.transition_effect, + ), controls: z .object({ next_previous: viewerNextPreviousControlConfigSchema.default( @@ -802,7 +803,7 @@ export const frigateCardConfigDefaults = { image: imageConfigDefault, }; -const menuButtonSchema = z.discriminatedUnion("type", [ +const menuButtonSchema = z.discriminatedUnion('type', [ menuIconSchema, menuStateIconSchema, menuSubmenuSchema, @@ -823,10 +824,10 @@ export interface BrowseMediaQueryParameters { } export interface BrowseMediaNeighbors { - previous: BrowseMediaSource | null; + previous: FrigateBrowseMediaSource | null; previousIndex: number | null; - next: BrowseMediaSource | null; + next: FrigateBrowseMediaSource | null; nextIndex: number | null; } @@ -867,7 +868,7 @@ export interface FrigateCardMediaPlayer { // See: https://github.com/colinhacks/zod#recursive-types // // Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L46 -export interface BrowseMediaSource { +interface BrowseMediaSource { title: string; media_class: string; media_content_type: string; @@ -879,18 +880,51 @@ export interface BrowseMediaSource { children?: BrowseMediaSource[] | null; } -export const browseMediaSourceSchema: z.ZodSchema = z.lazy(() => - z.object({ - title: z.string(), - media_class: z.string(), - media_content_type: z.string(), - media_content_id: z.string(), - can_play: z.boolean(), - can_expand: z.boolean(), - children_media_class: z.string().nullable().optional(), - thumbnail: z.string().nullable(), - children: z.array(browseMediaSourceSchema).nullable().optional(), - }), +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[]; + }; + }; +} + +export const frigateBrowseMediaSourceSchema: z.ZodSchema = z.lazy( + () => + z.object({ + title: z.string(), + media_class: z.string(), + media_content_type: z.string(), + media_content_id: z.string(), + can_play: z.boolean(), + can_expand: z.boolean(), + children_media_class: z.string().nullable().optional(), + thumbnail: z.string().nullable(), + children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(), + frigate: z.object({ + event: z.object({ + camera: z.string(), + end_time: z.number().nullable(), + false_positive: z.boolean(), + has_clip: z.boolean(), + has_snapshot: z.boolean(), + id: z.string(), + label: z.string(), + start_time: z.number(), + top_score: z.number(), + zones: z.string().array(), + }), + }).optional(), + }), ); // Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py diff --git a/src/view.ts b/src/view.ts index cd5a6bab..f16c9341 100644 --- a/src/view.ts +++ b/src/view.ts @@ -1,10 +1,10 @@ -import type { BrowseMediaSource, FrigateCardView } from './types.js'; +import type { FrigateBrowseMediaSource, FrigateCardView } from './types.js'; import { dispatchFrigateCardEvent } from './common.js'; export interface ViewEvolveParameters { view?: FrigateCardView; camera?: string; - target?: BrowseMediaSource; + target?: FrigateBrowseMediaSource; childIndex?: number; previous?: View; } @@ -17,7 +17,7 @@ export interface ViewParameters extends ViewEvolveParameters { export class View { view: FrigateCardView; camera: string; - target?: BrowseMediaSource; + target?: FrigateBrowseMediaSource; childIndex?: number; previous?: View; @@ -91,7 +91,8 @@ export class View { * Determine if a view is related to a clip or clips. */ public isClipRelatedView(): boolean { - return ['clip', 'clips'].includes(this.view); + // TODO HACK HACK HACK + return ['clip', 'clips', 'timeline'].includes(this.view); } /** @@ -104,7 +105,7 @@ export class View { /** * Get the media item that should be played. **/ - get media(): BrowseMediaSource | undefined { + get media(): FrigateBrowseMediaSource | undefined { if (this.target) { if (this.target.children && this.childIndex !== undefined) { return this.target.children[this.childIndex]; From b9cef866c9dc902e00160ee0e01e361e1263ed43 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 6 Mar 2022 19:52:26 -0800 Subject: [PATCH 002/345] Use the newly embedded Frigate event metadata. --- src/browse-media-util.ts | 30 ++++++++---------------------- src/card.ts | 2 +- src/components/viewer.ts | 6 +++--- 3 files changed, 12 insertions(+), 26 deletions(-) diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index c6069b1d..9d6966c6 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -22,34 +22,20 @@ dayjs.extend(dayjs_custom_parse_format); export class BrowseMediaUtil { /** * Return the Frigate event_id given a FrigateBrowseMediaSource object. - * @param media The event to extract the id from. + * @param media The event to get the id from. * @returns The `event_id` or `null` if not successfully parsed. */ - static extractEventID(media: FrigateBrowseMediaSource): string | null { - const result = media.media_content_id.match( - /^media-source:\/\/frigate\/.*\/(?[.0-9]+-[a-zA-Z0-9]+)$/, - ); - return result && result.groups ? result.groups['id'] : null; + static getEventID(media: FrigateBrowseMediaSource): string | null { + return media.frigate?.event.id ?? null; } /** * Return the event start time given a FrigateBrowseMediaSource object. - * @param browseMedia The media object to extract the start time from. + * @param browseMedia The media object to get the start time from. * @returns The start time in unix/epoch time, or null if it cannot be determined. */ - static extractEventStartTime(browseMedia: FrigateBrowseMediaSource): number | null { - // Example: 2021-08-27 20:57:22 [10s, Person 76%] - const result = browseMedia.title.match(/^(?.+) \[/); - if (result && result.groups) { - const iso_datetime_str = result.groups['iso_datetime']; - if (iso_datetime_str) { - const iso_datetime = dayjs(iso_datetime_str, 'YYYY-MM-DD HH:mm:ss', true); - if (iso_datetime.isValid()) { - return iso_datetime.unix(); - } - } - } - return null; + static getEventStartTime(media: FrigateBrowseMediaSource): number | null { + return media.frigate?.event.start_time ?? null; } /** @@ -116,8 +102,8 @@ export class BrowseMediaUtil { 'event-search', params.mediaType, '', // Name/Title to render (not necessary here) - params.after ? String(params.after) : '', - params.before ? String(params.before) : '', + params.after ? String(Math.floor(params.after)) : '', + params.before ? String(Math.ceil(params.before)) : '', params.cameraName, params.label, params.zone, diff --git a/src/card.ts b/src/card.ts index 7c758015..2aee36c5 100644 --- a/src/card.ts +++ b/src/card.ts @@ -746,7 +746,7 @@ export class FrigateCard extends LitElement { }); return; } - const event_id = BrowseMediaUtil.extractEventID(this._view.media); + const event_id = BrowseMediaUtil.getEventID(this._view.media); if (!event_id) { this._setMessageAndUpdate({ message: localize('error.download_no_event_id'), diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 7a713e99..a5aa231f 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -414,7 +414,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { return null; } - const snapshotStartTime = BrowseMediaUtil.extractEventStartTime(snapshot); + const snapshotStartTime = BrowseMediaUtil.getEventStartTime(snapshot); if (!snapshotStartTime) { return null; } @@ -435,7 +435,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { if (!BrowseMediaUtil.isTrueMedia(child)) { continue; } - const startTime = BrowseMediaUtil.extractEventStartTime(child); + const startTime = BrowseMediaUtil.getEventStartTime(child); if (startTime && (earliest === null || startTime < earliest)) { earliest = startTime; @@ -471,7 +471,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { if (!BrowseMediaUtil.isTrueMedia(child)) { continue; } - const clipStartTime = BrowseMediaUtil.extractEventStartTime(child); + const clipStartTime = BrowseMediaUtil.getEventStartTime(child); if (clipStartTime && clipStartTime === snapshotStartTime) { return new View({ view: 'clip', From 83a25e5b793e75dc4c6d6869e1c8f8657789a11c Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 6 Mar 2022 22:16:37 -0800 Subject: [PATCH 003/345] Add styling and interactivity. --- src/components/timeline.ts | 101 ++++++++++++++++++++++++++++++++++- src/scss/timeline-event.scss | 18 +++++++ src/scss/timeline.scss | 16 +++++- 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 src/scss/timeline-event.scss diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 43f8be2e..88562e37 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -13,11 +13,13 @@ import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref'; import { BrowseMediaUtil } from '../browse-media-util'; -import { CameraConfig, ExtendedHomeAssistant } from '../types'; +import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '../types'; import { View } from '../view'; +import { dispatchFrigateCardEvent } from '../common.js'; import { renderProgressIndicator } from './message'; import timelineStyle from '../scss/timeline.scss'; +import timelineEventStyle from '../scss/timeline-event.scss'; interface FrigateCardTimelineData { id: string; @@ -25,6 +27,40 @@ interface FrigateCardTimelineData { start: number; } +@customElement('frigate-card-timeline-event') +export class FrigateCardTimelineEvent extends LitElement { + @property({ attribute: true }) + protected media_id?: string; + + @property({ attribute: true }) + protected thumbnail?: string; + + @property({ attribute: true }) + protected label?: string; + + protected render(): TemplateResult | void { + if (!this.thumbnail) { + return; + } + + return html` { + // The view is not accessible from here, since this element is created + // from a string (see _buildEventContent below), so instead we emit an + // intermediate event that is caught by the timeline. + dispatchFrigateCardEvent(this, 'timeline-select', this.media_id); + }} + src="${this.thumbnail}" + title="${this.label || ''}" + aria-label="${this.label || ''}" + />`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(timelineEventStyle); + } +} + @customElement('frigate-card-timeline') export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) @@ -38,6 +74,7 @@ export class FrigateCardTimeline extends LitElement { protected _timelineRef: Ref = createRef(); protected _timeline?: Timeline; + protected _boundTimelineSelectHandler = this._timelineSelectHandler.bind(this); /** * Master render method. @@ -70,6 +107,53 @@ export class FrigateCardTimeline extends LitElement { return html`
`; } + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + document.addEventListener( + 'frigate-card:timeline-select', + this._boundTimelineSelectHandler, + ); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + document.removeEventListener( + 'frigate-card:timeline-select', + this._boundTimelineSelectHandler, + ); + super.disconnectedCallback(); + } + + protected _timelineSelectHandler(ev: Event): void { + const id = (ev as CustomEvent).detail; + const index = + this.view?.target?.children?.findIndex((item) => item.media_content_id == id) ?? + -1; + if (index >= 0) { + this.view + ?.evolve({ + view: 'clip', + childIndex: index, + }) + .dispatchChangeEvent(this); + } + } + + protected _buildEventContent(source: FrigateBrowseMediaSource): string { + return ` + + `; + } + protected _buildDataset(): DataSet { const items: FrigateCardTimelineData[] = []; @@ -77,8 +161,9 @@ export class FrigateCardTimeline extends LitElement { if (child.frigate) { const item = { id: child.media_content_id, - content: child.frigate.event.id, + content: this._buildEventContent(child), start: child.frigate.event.start_time * 1000, + selectable: false, }; if (child.frigate.event.end_time) { //item['end'] = child.frigate.event.end_time * 1000; @@ -94,6 +179,18 @@ export class FrigateCardTimeline extends LitElement { // Configuration for the Timeline const options = { minHeight: '300px', + margin: { + item: 75 + 10, + axis: 75 + 10, + }, + xss: { + disabled: false, + filterOptions: { + whiteList: { + 'frigate-card-timeline-event': ['thumbnail', 'label', 'media_id'], + }, + }, + }, }; // Create a Timeline diff --git a/src/scss/timeline-event.scss b/src/scss/timeline-event.scss new file mode 100644 index 00000000..3f974d39 --- /dev/null +++ b/src/scss/timeline-event.scss @@ -0,0 +1,18 @@ +:host { + display: block; + width: 100%; + height: 100%; +} + +img { + width: 75px; + height: 75px; + box-shadow: 0px 0px 20px 5px var(--primary-background-color); + display: block; + border-radius: 5px; + transition: transform 0.2s linear; +} + +img:hover { + transform: scale(1.04); +} diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index eb7759dd..423ac747 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -1,7 +1,19 @@ -@use "vis-timeline/dist/vis-timeline-graph2d.css"; +@use 'vis-timeline/dist/vis-timeline-graph2d.css'; :host { width: 100%; height: 100%; display: block; -} \ No newline at end of file +} + +.vis-item { + border-color: var(--primary-color); +} + +.vis-item.vis-box { + border-style: hidden; +} + +.vis-item .vis-item-content { + padding: 0px; +} From 05d5b246e2294460f240665a28777272ee63b0dd Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 7 Mar 2022 20:55:09 -0800 Subject: [PATCH 004/345] Add clustering. --- src/components/timeline.ts | 13 ++++++++++--- src/scss/timeline-event.scss | 2 +- src/scss/timeline.scss | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 88562e37..0f68c26a 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -176,11 +176,17 @@ export class FrigateCardTimeline extends LitElement { // eslint-disable-next-line @typescript-eslint/no-unused-vars protected updated(_changedProperties: PropertyValues): void { - // Configuration for the Timeline + // Configuration for the Timeline, see: + // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options const options = { + cluster: { + showStipes: true, + maxItems: 3, + }, minHeight: '300px', + maxHeight: '300px', margin: { - item: 75 + 10, + item: 50, axis: 75 + 10, }, xss: { @@ -188,13 +194,14 @@ export class FrigateCardTimeline extends LitElement { filterOptions: { whiteList: { 'frigate-card-timeline-event': ['thumbnail', 'label', 'media_id'], + 'div': ['title'], }, }, }, }; // Create a Timeline - if (this._timelineRef.value && !this._timeline) { + if (this._timelineRef.value) { this._timeline = new Timeline( this._timelineRef.value, this._buildDataset(), diff --git a/src/scss/timeline-event.scss b/src/scss/timeline-event.scss index 3f974d39..b59a6cf7 100644 --- a/src/scss/timeline-event.scss +++ b/src/scss/timeline-event.scss @@ -7,10 +7,10 @@ img { width: 75px; height: 75px; - box-shadow: 0px 0px 20px 5px var(--primary-background-color); display: block; border-radius: 5px; transition: transform 0.2s linear; + border: 1px solid black; } img:hover { diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 423ac747..7aa4bd41 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -8,6 +8,11 @@ .vis-item { border-color: var(--primary-color); + background: none; +} + +.vis-item:hover { + z-index: 2; } .vis-item.vis-box { @@ -17,3 +22,15 @@ .vis-item .vis-item-content { padding: 0px; } + +.vis-item.vis-cluster { + width: 30px; + height: 30px; + border-style: dotted; + border-radius: 50%; + padding: 5px; + + color: var(--primary-text-color); + background-color: var(--primary-background-color); + box-shadow: 0px 0px 20px 5px var(--primary-color); +} \ No newline at end of file From b17907a2c79608b60fc4c30765afaa4508da9aa5 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 7 Mar 2022 21:09:12 -0800 Subject: [PATCH 005/345] Add timeline to menu. --- src/card.ts | 14 ++++++++++++-- src/localize/languages/en.json | 3 ++- src/types.ts | 3 +++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/card.ts b/src/card.ts index 2aee36c5..b0a17de8 100644 --- a/src/card.ts +++ b/src/card.ts @@ -371,6 +371,16 @@ export class FrigateCard extends LitElement { }); } + if (this._getConfig().menu.buttons.timeline) { + buttons.push({ + type: 'custom:frigate-card-menu-icon', + title: localize('config.view.views.timeline'), + icon: 'mdi:chart-gantt', + style: this._view?.is('timeline') ? this._getEmphasizedStyle() : {}, + tap_action: createFrigateCardCustomAction('timeline') as FrigateCardCustomAction, + }); + } + if (this._getConfig().menu.buttons.download && this._view?.isViewerView()) { buttons.push({ type: 'custom:frigate-card-menu-icon', @@ -649,8 +659,7 @@ export class FrigateCard extends LitElement { // Load the default view. let camera; if (this._cameras?.size) { - if (this._view?.camera && - this._getConfig().view.update_cycle_camera) { + if (this._view?.camera && this._getConfig().view.update_cycle_camera) { const keys = Array.from(this._cameras.keys()); const currentIndex = keys.indexOf(this._view.camera); const targetIndex = currentIndex + 1 >= keys.length ? 0 : currentIndex + 1; @@ -827,6 +836,7 @@ export class FrigateCard extends LitElement { case 'live': case 'snapshot': case 'snapshots': + case 'timeline': if (this._view) { this._changeView({ view: new View({ diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index fee462b2..58613e91 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -42,7 +42,8 @@ "snapshot": "Most recent snapshot", "snapshots": "Snapshots gallery", "image": "Static image", - "current": "Current view" + "current": "Current view", + "timeline": "Timeline view" }, "timeout_seconds": "Reset to default view X seconds after user action (0=never)", "update_cycle_camera": "Cycle through cameras when default view updates", diff --git a/src/types.ts b/src/types.ts index 179f7efe..30a33f25 100644 --- a/src/types.ts +++ b/src/types.ts @@ -127,6 +127,7 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'live', 'snapshot', 'snapshots', + 'timeline', 'download', 'frigate_ui', 'fullscreen', @@ -591,6 +592,7 @@ const menuConfigDefault = { clips: true, snapshots: true, image: false, + timeline: true, download: true, frigate_ui: true, fullscreen: true, @@ -609,6 +611,7 @@ const menuConfigSchema = z clips: z.boolean().default(menuConfigDefault.buttons.clips), snapshots: z.boolean().default(menuConfigDefault.buttons.snapshots), image: z.boolean().default(menuConfigDefault.buttons.image), + timeline: z.boolean().default(menuConfigDefault.buttons.timeline), download: z.boolean().default(menuConfigDefault.buttons.download), frigate_ui: z.boolean().default(menuConfigDefault.buttons.frigate_ui), fullscreen: z.boolean().default(menuConfigDefault.buttons.fullscreen), From 37467eff8c397cf8a357b303c94735e5c93c0453 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 11 Mar 2022 15:40:31 -0800 Subject: [PATCH 006/345] Fix height sizing for timeline. --- src/components/timeline.ts | 64 +++++++++++++++++++++++++++++++------- src/scss/timeline.scss | 8 +++++ 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 0f68c26a..b4b2cc50 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -8,14 +8,14 @@ import { } from 'lit'; import { DataSet } from 'vis-data/esnext'; import { HomeAssistant } from 'custom-card-helpers'; -import { Timeline } from 'vis-timeline/esnext'; -import { customElement, property } from 'lit/decorators.js'; +import { Timeline, TimelineOptions } from 'vis-timeline/esnext'; +import { customElement, property, state } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref'; import { BrowseMediaUtil } from '../browse-media-util'; import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '../types'; import { View } from '../view'; -import { dispatchFrigateCardEvent } from '../common.js'; +import { contentsChanged, dispatchFrigateCardEvent } from '../common.js'; import { renderProgressIndicator } from './message'; import timelineStyle from '../scss/timeline.scss'; @@ -72,10 +72,21 @@ export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) protected cameraConfig?: CameraConfig; + @state({ hasChanged: contentsChanged }) + protected _timelineOptions?: TimelineOptions; + protected _timelineRef: Ref = createRef(); protected _timeline?: Timeline; protected _boundTimelineSelectHandler = this._timelineSelectHandler.bind(this); + protected _resizeObserver: ResizeObserver; + + constructor() { + super(); + this._resizeObserver = new ResizeObserver(this._setOptions.bind(this)); + this._setOptions(); + } + /** * Master render method. * @returns A rendered template. @@ -104,7 +115,7 @@ export class FrigateCardTimeline extends LitElement { ); return renderProgressIndicator(); } - return html`
`; + return html`
`; } /** @@ -116,6 +127,7 @@ export class FrigateCardTimeline extends LitElement { 'frigate-card:timeline-select', this._boundTimelineSelectHandler, ); + this._resizeObserver.observe(this); } /** @@ -126,9 +138,14 @@ export class FrigateCardTimeline extends LitElement { 'frigate-card:timeline-select', this._boundTimelineSelectHandler, ); + this._resizeObserver.disconnect(); super.disconnectedCallback(); } + /** + * Called when an item on the timeline is selected. + * @param ev The click event. + */ protected _timelineSelectHandler(ev: Event): void { const id = (ev as CustomEvent).detail; const index = @@ -144,6 +161,11 @@ export class FrigateCardTimeline extends LitElement { } } + /** + * Build the content of a single event on the timeline. + * @param source The FrigateBrowseMediaSource object for this event. + * @returns A string to include on the timeline. + */ protected _buildEventContent(source: FrigateBrowseMediaSource): string { return ` `; } + /** + * Build the visjs dataset to render on the timeline. + * @returns The dataset. + */ protected _buildDataset(): DataSet { const items: FrigateCardTimelineData[] = []; @@ -174,17 +200,19 @@ export class FrigateCardTimeline extends LitElement { return new DataSet(items); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected updated(_changedProperties: PropertyValues): void { + /** + * Handle timeline resize. + */ + protected _setOptions(): void { // Configuration for the Timeline, see: // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options - const options = { + this._timelineOptions = { cluster: { showStipes: true, maxItems: 3, }, - minHeight: '300px', - maxHeight: '300px', + minHeight: '100%', + maxHeight: '100%', margin: { item: 50, axis: 75 + 10, @@ -194,22 +222,34 @@ export class FrigateCardTimeline extends LitElement { filterOptions: { whiteList: { 'frigate-card-timeline-event': ['thumbnail', 'label', 'media_id'], - 'div': ['title'], + div: ['title'], }, }, }, }; + } - // Create a Timeline + /** + * Called when the component is updated. + * @param changedProps The changed properties if any. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected updated(_changedProperties: PropertyValues): void { if (this._timelineRef.value) { + if (this._timeline) { + this._timeline.destroy(); + } this._timeline = new Timeline( this._timelineRef.value, this._buildDataset(), - options, + this._timelineOptions, ); } } + /** + * Return compiled CSS styles. + */ static get styles(): CSSResultGroup { return unsafeCSS(timelineStyle); } diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 7aa4bd41..4862e731 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -6,6 +6,14 @@ display: block; } +div.timeline { + height: 100%; +} + +.vis-timeline { + border: hidden; +} + .vis-item { border-color: var(--primary-color); background: none; From b35b9d0e36759e1c85326092012959284279b43e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 12 Mar 2022 10:15:37 -0800 Subject: [PATCH 007/345] Initial settings for timeline. --- src/card.ts | 1 + src/components/gallery.ts | 5 +- src/components/timeline.ts | 94 ++++++++++++++++++++++++++++-------- src/scss/timeline-event.scss | 6 ++- src/scss/timeline.scss | 7 +++ src/types.ts | 88 +++++++++++++++++++++++++-------- 6 files changed, 155 insertions(+), 46 deletions(-) diff --git a/src/card.ts b/src/card.ts index b0a17de8..98d200df 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1276,6 +1276,7 @@ export class FrigateCard extends LitElement { .hass=${this._hass} .view=${this._view} .cameraConfig=${cameraConfig} + .timelineConfig=${this._getConfig().timeline} > ` : ``} diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 13fa510f..21d26fd8 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -8,6 +8,7 @@ import { CameraConfig, ExtendedHomeAssistant, GalleryConfig, + THUMBNAIL_WIDTH_MAX, frigateCardConfigDefaults, } from '../types.js'; import { BrowseMediaUtil } from '../browse-media-util.js'; @@ -17,8 +18,6 @@ import { stopEventFromActivatingCardWideActions } from '../common.js'; import galleryStyle from '../scss/gallery.scss'; -const MAX_THUMBNAIL_WIDTH = 175; - @customElement('frigate-card-gallery') export class FrigateCardGallery extends LitElement { @property({ attribute: false }) @@ -124,7 +123,7 @@ export class FrigateCardGalleryCore extends LitElement { this._columns = Math.max( this.galleryConfig?.min_columns ?? frigateCardConfigDefaults.event_gallery.min_columns, - Math.ceil(this.clientWidth / MAX_THUMBNAIL_WIDTH), + Math.ceil(this.clientWidth / THUMBNAIL_WIDTH_MAX), ); } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index b4b2cc50..a0e0b53c 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -8,12 +8,18 @@ import { } from 'lit'; import { DataSet } from 'vis-data/esnext'; import { HomeAssistant } from 'custom-card-helpers'; -import { Timeline, TimelineOptions } from 'vis-timeline/esnext'; +import { Timeline, TimelineOptions, TimelineOptionsCluster } from 'vis-timeline/esnext'; import { customElement, property, state } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref'; import { BrowseMediaUtil } from '../browse-media-util'; -import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '../types'; +import { + CameraConfig, + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + TimelineConfig, + frigateCardConfigDefaults, +} from '../types'; import { View } from '../view'; import { contentsChanged, dispatchFrigateCardEvent } from '../common.js'; import { renderProgressIndicator } from './message'; @@ -38,6 +44,23 @@ export class FrigateCardTimelineEvent extends LitElement { @property({ attribute: true }) protected label?: string; + @property({ attribute: true, type: Number }) + protected thumbnail_size?: number; + + /** + * Ensure there is a cached value before an update. + * @param _changedProps The changed properties + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected willUpdate(_changedProps: PropertyValues): void { + if (this.thumbnail_size !== undefined) { + this.style.setProperty( + '--frigate-card-timeline-thumbnail-size', + `${this.thumbnail_size}px`, + ); + } + } + protected render(): TemplateResult | void { if (!this.thumbnail) { return; @@ -72,6 +95,17 @@ export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) protected cameraConfig?: CameraConfig; + /** + * Set the Home Assistant object. + */ + set timelineConfig(timelineConfig: TimelineConfig) { + this._timelineConfig = timelineConfig; + this._setOptions(); + } + + @state() + protected _timelineConfig?: TimelineConfig; + @state({ hasChanged: contentsChanged }) protected _timelineOptions?: TimelineOptions; @@ -79,14 +113,6 @@ export class FrigateCardTimeline extends LitElement { protected _timeline?: Timeline; protected _boundTimelineSelectHandler = this._timelineSelectHandler.bind(this); - protected _resizeObserver: ResizeObserver; - - constructor() { - super(); - this._resizeObserver = new ResizeObserver(this._setOptions.bind(this)); - this._setOptions(); - } - /** * Master render method. * @returns A rendered template. @@ -127,7 +153,6 @@ export class FrigateCardTimeline extends LitElement { 'frigate-card:timeline-select', this._boundTimelineSelectHandler, ); - this._resizeObserver.observe(this); } /** @@ -138,7 +163,6 @@ export class FrigateCardTimeline extends LitElement { 'frigate-card:timeline-select', this._boundTimelineSelectHandler, ); - this._resizeObserver.disconnect(); super.disconnectedCallback(); } @@ -172,6 +196,10 @@ export class FrigateCardTimeline extends LitElement { media_id=${source.media_content_id} thumbnail="${source.thumbnail}" label="${source.title}" + thumbnail_size="${ + this._timelineConfig?.controls.thumbnails.size_pixels ?? + frigateCardConfigDefaults.timeline.controls.thumbnails.size_pixels + }" > `; } @@ -204,24 +232,48 @@ export class FrigateCardTimeline extends LitElement { * Handle timeline resize. */ protected _setOptions(): void { + if (!this._timelineConfig) { + return; + } + + const thumbnailConfig = + this._timelineConfig?.controls.thumbnails ?? + frigateCardConfigDefaults.timeline.controls.thumbnails; + const gap = 5; + // Configuration for the Timeline, see: // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options this._timelineOptions = { - cluster: { - showStipes: true, - maxItems: 3, - }, + cluster: thumbnailConfig.clustering_threshold > 0 + ? { + showStipes: true, + // It would be better to automatically calculate `maxItems` from the + // rendered height of the timeline (or group within the timeline) so + // as to not waste vertical space (e.g. after the user changes to + // fullscreen mode). Unfortunately this is not easy to do, as we + // don't know the height of the timeline until after it renders -- + // and if we adjust `maxItems` then we can get into an infinite + // resize loop. Adjusting the `maxItems` of a timeline, after it's + // created, also does not appear to work as expected. + maxItems: thumbnailConfig.clustering_threshold, + } + : false as TimelineOptionsCluster, minHeight: '100%', maxHeight: '100%', margin: { - item: 50, - axis: 75 + 10, + item: thumbnailConfig.size_pixels - thumbnailConfig.overlap_pixels + gap, + axis: thumbnailConfig.size_pixels + gap, }, xss: { disabled: false, filterOptions: { whiteList: { - 'frigate-card-timeline-event': ['thumbnail', 'label', 'media_id'], + 'frigate-card-timeline-event': [ + 'thumbnail', + 'label', + 'media_id', + 'thumbnail_size', + ], div: ['title'], }, }, @@ -234,7 +286,9 @@ export class FrigateCardTimeline extends LitElement { * @param changedProps The changed properties if any. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected updated(_changedProperties: PropertyValues): void { + protected updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + if (this._timelineRef.value) { if (this._timeline) { this._timeline.destroy(); diff --git a/src/scss/timeline-event.scss b/src/scss/timeline-event.scss index b59a6cf7..7d03c7ff 100644 --- a/src/scss/timeline-event.scss +++ b/src/scss/timeline-event.scss @@ -2,11 +2,13 @@ display: block; width: 100%; height: 100%; + + --frigate-card-timeline-thumbnail-size: 75px; } img { - width: 75px; - height: 75px; + width: var(--frigate-card-timeline-thumbnail-size); + height: var(--frigate-card-timeline-thumbnail-size); display: block; border-radius: 5px; transition: transform 0.2s linear; diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 4862e731..253fdd29 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -4,12 +4,18 @@ 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: hidden; } @@ -20,6 +26,7 @@ div.timeline { } .vis-item:hover { + // Float icons upwards when the user hovers over them. z-index: 2; } diff --git a/src/types.ts b/src/types.ts index 30a33f25..df8a8f79 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,13 +26,13 @@ declare global { */ const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ - 'live', // Live view. - 'clip', // Most recent clip. - 'clips', // Clips gallery. - 'snapshot', // Most recent snapshot. - 'snapshots',// Snapshots gallery. - 'image', // Static image. - 'timeline', // Event timeline. + 'live', + 'clip', + 'clips', + 'snapshot', + 'snapshots', + 'image', + 'timeline', ] as const; export type FrigateCardView = typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number]; @@ -59,6 +59,9 @@ export type LiveProvider = typeof LIVE_PROVIDERS[number]; export class FrigateCardError extends Error {} +// The maximum width thumbnail Frigate returns +export const THUMBNAIL_WIDTH_MAX = 175; + /** * Action Types (for "Picture Elements" / Menu) */ @@ -738,6 +741,45 @@ const dimensionsConfigSchema = z }) .default(dimensionsConfigDefault); +/** + * Timeline configuration section. + */ +const timelineConfigDefault = { + controls: { + thumbnails: { + size_pixels: 75, + overlap_pixels: 25, + clustering_threshold: 3, + }, + }, +}; +const timelineConfigSchema = z + .object({ + 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), + }) + .default(timelineConfigDefault.controls.thumbnails), + }) + .default(timelineConfigDefault.controls), + }) + .default(timelineConfigDefault); +export type TimelineConfig = z.infer; + /** * Configuration overrides */ @@ -781,6 +823,7 @@ export const frigateCardConfigSchema = z.object({ image: imageConfigSchema, elements: pictureElementsSchema, dimensions: dimensionsConfigSchema, + timeline: timelineConfigSchema, // Configuration overrides. overrides: overridesSchema, @@ -804,6 +847,7 @@ export const frigateCardConfigDefaults = { event_viewer: viewerConfigDefault, event_gallery: galleryConfigDefault, image: imageConfigDefault, + timeline: timelineConfigDefault, }; const menuButtonSchema = z.discriminatedUnion('type', [ @@ -913,20 +957,22 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema = z. children_media_class: z.string().nullable().optional(), thumbnail: z.string().nullable(), children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(), - frigate: z.object({ - event: z.object({ - camera: z.string(), - end_time: z.number().nullable(), - false_positive: z.boolean(), - has_clip: z.boolean(), - has_snapshot: z.boolean(), - id: z.string(), - label: z.string(), - start_time: z.number(), - top_score: z.number(), - zones: z.string().array(), - }), - }).optional(), + frigate: z + .object({ + event: z.object({ + camera: z.string(), + end_time: z.number().nullable(), + false_positive: z.boolean(), + has_clip: z.boolean(), + has_snapshot: z.boolean(), + id: z.string(), + label: z.string(), + start_time: z.number(), + top_score: z.number(), + zones: z.string().array(), + }), + }) + .optional(), }), ); From f2c9888ce93cac34f5d9e0e283897fea36a30721 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 12 Mar 2022 10:20:52 -0800 Subject: [PATCH 008/345] Rename fullScreen -> fullscreen --- src/card.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/card.ts b/src/card.ts index 98d200df..5e600b23 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1051,7 +1051,7 @@ export class FrigateCard extends LitElement { /** * Handler called when fullscreen is toggled. */ - protected _fullScreenHandler(): void { + protected _fullscreenHandler(): void { this._generateConditionState(); // Re-render after a change to fullscreen mode to take advantage of // the expanded screen real-estate (vs staying in aspect-ratio locked @@ -1065,7 +1065,7 @@ export class FrigateCard extends LitElement { connectedCallback(): void { super.connectedCallback(); if (screenfull.isEnabled) { - screenfull.on('change', this._fullScreenHandler.bind(this)); + screenfull.on('change', this._fullscreenHandler.bind(this)); } } @@ -1074,7 +1074,7 @@ export class FrigateCard extends LitElement { */ disconnectedCallback(): void { if (screenfull.isEnabled) { - screenfull.off('change', this._fullScreenHandler.bind(this)); + screenfull.off('change', this._fullscreenHandler.bind(this)); } super.disconnectedCallback(); } From 343d7ead3a7a6bd30310751bff4b5f21b2394837 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 14 Mar 2022 22:55:54 -0700 Subject: [PATCH 009/345] Convert timeline to bars rather than icons. --- package.json | 2 +- src/browse-media-util.ts | 4 +- src/card.ts | 1 + src/components/timeline.ts | 381 ++++++++++++++++++++++++----------- src/scss/timeline-event.scss | 10 +- src/scss/timeline.scss | 26 ++- src/types.ts | 1 + 7 files changed, 291 insertions(+), 134 deletions(-) diff --git a/package.json b/package.json index faebdaf0..506f154c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^5.1.1", "@egjs/hammerjs": "^2.0.17", - "@lit-labs/task": "^1.0.0", + "@lit-labs/task": "^1.1.1", "@material/image-list": "^13.0.0", "@material/mwc-menu": "^0.25.3", "@material/rtl": "^13.0.0", diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index 9d6966c6..f5d01c24 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -101,7 +101,9 @@ export class BrowseMediaUtil { params.clientId, 'event-search', params.mediaType, - '', // Name/Title to render (not necessary here) + + // If the name field ends in '.all' the integration will return up to 10K events. + params.unlimited ? '.all' : '', params.after ? String(Math.floor(params.after)) : '', params.before ? String(Math.ceil(params.before)) : '', params.cameraName, diff --git a/src/card.ts b/src/card.ts index 5e600b23..0b3f8cae 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1276,6 +1276,7 @@ export class FrigateCard extends LitElement { .hass=${this._hass} .view=${this._view} .cameraConfig=${cameraConfig} + .cameras=${this._cameras} .timelineConfig=${this._getConfig().timeline} > ` diff --git a/src/components/timeline.ts b/src/components/timeline.ts index a0e0b53c..ee985035 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -8,9 +8,14 @@ import { } from 'lit'; import { DataSet } from 'vis-data/esnext'; import { HomeAssistant } from 'custom-card-helpers'; -import { Timeline, TimelineOptions, TimelineOptionsCluster } from 'vis-timeline/esnext'; +import { + DataGroupCollectionType, + Timeline, + TimelineOptions, + TimelineOptionsCluster, +} from 'vis-timeline/esnext'; import { customElement, property, state } from 'lit/decorators.js'; -import { createRef, ref, Ref } from 'lit/directives/ref'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { BrowseMediaUtil } from '../browse-media-util'; import { @@ -21,12 +26,20 @@ import { frigateCardConfigDefaults, } from '../types'; import { View } from '../view'; -import { contentsChanged, dispatchFrigateCardEvent } from '../common.js'; -import { renderProgressIndicator } from './message'; +import { + contentsChanged, + dispatchErrorMessageEvent, + dispatchFrigateCardEvent, + getCameraTitle, +} from '../common.js'; import timelineStyle from '../scss/timeline.scss'; import timelineEventStyle from '../scss/timeline-event.scss'; +interface FrigateCardGroupData { + id: string; + content: string; +} interface FrigateCardTimelineData { id: string; content: string; @@ -84,6 +97,95 @@ export class FrigateCardTimelineEvent extends LitElement { } } +class TimelineEventManager { + protected _dataset = new DataSet(); + + protected _contentCallback?: (FrigateBrowseMediaSource) => string; + protected _tooltipCallback?: (FrigateBrowseMediaSource) => string; + + constructor(params: { + contentCallback?: (source: FrigateBrowseMediaSource) => string; + tooltipCallback?: (source: FrigateBrowseMediaSource) => string; + }) { + this._contentCallback = params.contentCallback; + this._tooltipCallback = params.tooltipCallback; + } + + get dataset(): DataSet { + return this._dataset; + } + + public isEmpty(): boolean { + return this._dataset.length === 0; + } + + public clear(): void { + this._dataset.clear(); + } + + protected _addMediaSource(camera: string, target: FrigateBrowseMediaSource): void { + const items: FrigateCardTimelineData[] = []; + target.children?.forEach((child) => { + if (child.frigate) { + const item = { + id: child.media_content_id, + group: camera, + content: this._contentCallback?.(child) ?? '', + title: this._tooltipCallback?.(child) ?? '', + start: child.frigate.event.start_time * 1000, + }; + if (child.frigate.event.end_time) { + item['end'] = child.frigate.event.end_time * 1000; + item['type'] = 'range'; + } else { + item['type'] = 'point'; + } + items.push(item); + } + }); + this._dataset.update(items); + } + + public async fetchEvents( + node: HTMLElement, + hass: HomeAssistant & ExtendedHomeAssistant, + cameras: Map, + start: Date, + end: Date, + ): Promise { + console.info(`fetchEvents: ${start} -> ${end}`); + + // const output = new Map(); + const fetchCameraEvents = async (camera: string): Promise => { + const cameraConfig = cameras.get(camera); + if (!cameraConfig) { + return; + } + const browseMediaQueryParameters = BrowseMediaUtil.getBrowseMediaQueryParameters( + 'clips', + cameraConfig, + ); + if (!browseMediaQueryParameters) { + return; + } + + try { + this._addMediaSource( + camera, + await BrowseMediaUtil.browseMediaQuery(hass, { + ...browseMediaQueryParameters, + unlimited: true, + }), + ); + } catch (e) { + return dispatchErrorMessageEvent(node, (e as Error).message); + } + }; + + await Promise.all(Array.from(cameras.keys()).map(fetchCameraEvents.bind(this))); + } +} + @customElement('frigate-card-timeline') export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) @@ -93,10 +195,10 @@ export class FrigateCardTimeline extends LitElement { protected view?: Readonly; @property({ attribute: false }) - protected cameraConfig?: CameraConfig; + protected cameras?: Map; /** - * Set the Home Assistant object. + * Set the timeline configuration. */ set timelineConfig(timelineConfig: TimelineConfig) { this._timelineConfig = timelineConfig; @@ -111,86 +213,17 @@ export class FrigateCardTimeline extends LitElement { protected _timelineRef: Ref = createRef(); protected _timeline?: Timeline; - protected _boundTimelineSelectHandler = this._timelineSelectHandler.bind(this); - /** - * Master render method. - * @returns A rendered template. - */ - protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this.cameraConfig) { - return; - } - - if (!this.view.target) { - const browseMediaQueryParameters = - BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError( - this, - this.view, - this.cameraConfig, - ); - if (!browseMediaQueryParameters) { - return; - } - - BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange( - this, - this.hass, - this.view, - browseMediaQueryParameters, - ); - return renderProgressIndicator(); - } - return html`
`; - } - - /** - * Component connected callback. - */ - connectedCallback(): void { - super.connectedCallback(); - document.addEventListener( - 'frigate-card:timeline-select', - this._boundTimelineSelectHandler, - ); - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - document.removeEventListener( - 'frigate-card:timeline-select', - this._boundTimelineSelectHandler, - ); - super.disconnectedCallback(); - } - - /** - * Called when an item on the timeline is selected. - * @param ev The click event. - */ - protected _timelineSelectHandler(ev: Event): void { - const id = (ev as CustomEvent).detail; - const index = - this.view?.target?.children?.findIndex((item) => item.media_content_id == id) ?? - -1; - if (index >= 0) { - this.view - ?.evolve({ - view: 'clip', - childIndex: index, - }) - .dispatchChangeEvent(this); - } - } + protected _events = new TimelineEventManager({ + tooltipCallback: this._generateTooltip.bind(this), + }); /** * Build the content of a single event on the timeline. * @param source The FrigateBrowseMediaSource object for this event. * @returns A string to include on the timeline. */ - protected _buildEventContent(source: FrigateBrowseMediaSource): string { + protected _generateTooltip(source: FrigateBrowseMediaSource): string { return ` `; } + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.hass || !this.view) { + return; + } + return html`
`; + } + + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + super.disconnectedCallback(); + } + + protected _timelineRangeHandler(properties: { + start: Date; + end: Date; + byUser: boolean; + event: Event; + }): void { + console.info( + `Range changed: ${properties.start} -> ${properties.end} [${this._events.dataset.length}]`, + ); + if (this.hass && this.cameras) { + // This is not performant in that it refetches all events in the time + // range, when some/all may already be fetched. A more optimal approach + // would be to only fetch events in time windows that haven't already been + // fetched PLUS events that did not previously have an end_time. That's + // not trivial to implement, and it's not yet clear it's worth the extra + // complexity. + this._events.fetchEvents( + this, + this.hass, + this.cameras, + properties.start, + properties.end, + ); + } + } + + /** + * Called when an object on the timeline is selected. + * @param data The data about the selection. + * @returns + */ + protected _timelineSelectHandler(data: { items: string[]; event: Event }): void { + if (data.items.length <= 0) { + return; + } + + // TODO: Make a parent, attach a select bunch of children to it and evolve the view. + } + /** * Build the visjs dataset to render on the timeline. * @returns The dataset. */ - protected _buildDataset(): DataSet { - const items: FrigateCardTimelineData[] = []; - - this.view?.target?.children?.forEach((child) => { - if (child.frigate) { - const item = { - id: child.media_content_id, - content: this._buildEventContent(child), - start: child.frigate.event.start_time * 1000, - selectable: false, - }; - if (child.frigate.event.end_time) { - //item['end'] = child.frigate.event.end_time * 1000; - } - items.push(item); - } + protected _getGroups(): DataGroupCollectionType { + const groups: FrigateCardGroupData[] = []; + this.cameras?.forEach((cameraConfig, camera) => { + groups.push({ + id: camera, + content: getCameraTitle(this.hass, cameraConfig), + }); }); - return new DataSet(items); + return new DataSet(groups); } /** @@ -239,31 +327,36 @@ export class FrigateCardTimeline extends LitElement { const thumbnailConfig = this._timelineConfig?.controls.thumbnails ?? frigateCardConfigDefaults.timeline.controls.thumbnails; - const gap = 5; // Configuration for the Timeline, see: // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options this._timelineOptions = { - cluster: thumbnailConfig.clustering_threshold > 0 - ? { - showStipes: true, - // It would be better to automatically calculate `maxItems` from the - // rendered height of the timeline (or group within the timeline) so - // as to not waste vertical space (e.g. after the user changes to - // fullscreen mode). Unfortunately this is not easy to do, as we - // don't know the height of the timeline until after it renders -- - // and if we adjust `maxItems` then we can get into an infinite - // resize loop. Adjusting the `maxItems` of a timeline, after it's - // created, also does not appear to work as expected. - maxItems: thumbnailConfig.clustering_threshold, - } - : false as TimelineOptionsCluster, + cluster: + thumbnailConfig.clustering_threshold > 0 + ? { + showStipes: true, + // It would be better to automatically calculate `maxItems` from the + // rendered height of the timeline (or group within the timeline) so + // as to not waste vertical space (e.g. after the user changes to + // fullscreen mode). Unfortunately this is not easy to do, as we + // don't know the height of the timeline until after it renders -- + // and if we adjust `maxItems` then we can get into an infinite + // resize loop. Adjusting the `maxItems` of a timeline, after it's + // created, also does not appear to work as expected. + maxItems: thumbnailConfig.clustering_threshold, + } + : (false as TimelineOptionsCluster), minHeight: '100%', maxHeight: '100%', - margin: { - item: thumbnailConfig.size_pixels - thumbnailConfig.overlap_pixels + gap, - axis: thumbnailConfig.size_pixels + gap, + tooltip: { + followMouse: true, + overflowMethod: 'cap', }, + zoomMax: 31 * 24 * 60 * 60 * 1000, + zoomMin: 1 * 1000, + start: this._getYesterday(), + end: this._getToday(), + groupHeightMode: 'fixed', xss: { disabled: false, filterOptions: { @@ -275,29 +368,85 @@ export class FrigateCardTimeline extends LitElement { 'thumbnail_size', ], div: ['title'], + span: ['style'], }, }, }, }; } + /** + * Get today date object. + * @returns A date object for today. + */ + protected _getToday(): Date { + return new Date(); + } + + /** + * Get yesterday date object. + * @returns A date object for yesterday. + */ + protected _getYesterday(): Date { + const yesterday = new Date(); + yesterday.setDate(this._getToday().getDate() - 1); + return yesterday; + } + + /** + * Determine if the component should be updated. + * @param _changedProps The changed properties. + * @returns + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected shouldUpdate(_changedProps: PropertyValues): boolean { + return !!this.hass && !!this.cameras && this.cameras.size > 0; + } + + /** + * Called on the first update. + * @param changedProps The changed properties. + */ + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + + if (changedProps.has('cameras')) { + this._events.clear(); + } + + if (this._events.isEmpty() && this.hass && this.cameras) { + // Fetch an initial 1-day worth of events. + this._events.fetchEvents( + this, + this.hass, + this.cameras, + this._getToday(), + this._getYesterday(), + ); + } + } + /** * Called when the component is updated. * @param changedProps The changed properties if any. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); if (this._timelineRef.value) { if (this._timeline) { this._timeline.destroy(); + this._timeline = undefined; } + this._timeline = new Timeline( this._timelineRef.value, - this._buildDataset(), + this._events.dataset, + this._getGroups(), this._timelineOptions, ); + this._timeline.on('select', this._timelineSelectHandler.bind(this)); + this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this)); } } diff --git a/src/scss/timeline-event.scss b/src/scss/timeline-event.scss index 7d03c7ff..18294c49 100644 --- a/src/scss/timeline-event.scss +++ b/src/scss/timeline-event.scss @@ -4,17 +4,13 @@ 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; - border-radius: 5px; - transition: transform 0.2s linear; - border: 1px solid black; -} - -img:hover { - transform: scale(1.04); } diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 253fdd29..346e585e 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -17,12 +17,19 @@ div.timeline { } .vis-timeline { - border: hidden; + 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 { @@ -31,7 +38,7 @@ div.timeline { } .vis-item.vis-box { - border-style: hidden; + border: none; } .vis-item .vis-item-content { @@ -39,13 +46,14 @@ div.timeline { } .vis-item.vis-cluster { - width: 30px; - height: 30px; border-style: dotted; - border-radius: 50%; - padding: 5px; - color: var(--primary-text-color); background-color: var(--primary-background-color); - box-shadow: 0px 0px 20px 5px var(--primary-color); -} \ No newline at end of file + box-shadow: 0px 0px 5px 1px var(--primary-color); +} + +div.vis-tooltip { + padding: 0px; + background-color: unset; + border: none; +} diff --git a/src/types.ts b/src/types.ts index df8a8f79..51090315 100644 --- a/src/types.ts +++ b/src/types.ts @@ -868,6 +868,7 @@ export interface BrowseMediaQueryParameters { zone?: string; before?: number; after?: number; + unlimited?: boolean; } export interface BrowseMediaNeighbors { From 669930261220301f0306b9a6cb9c39d10453265e Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 14 Mar 2022 23:15:44 -0700 Subject: [PATCH 010/345] Timeline stylistic tweaks. --- src/scss/timeline.scss | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 346e585e..de85c980 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -52,6 +52,20 @@ div.timeline { 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; From e7c2d7827ce313f17e15079ad36d477d8b7fbcb7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 16 Mar 2022 20:48:04 -0700 Subject: [PATCH 011/345] Allow selecting an item. --- src/components/timeline.ts | 54 ++++++++++++++++++++++++++++++++++++-- src/types.ts | 4 +++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/components/timeline.ts b/src/components/timeline.ts index ee985035..6aa0ef67 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -22,6 +22,9 @@ import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource, + MEDIA_CLASS_PLAYLIST, + MEDIA_TYPE_VIDEO, + MEDIA_CLASS_VIDEO, TimelineConfig, frigateCardConfigDefaults, } from '../types'; @@ -44,6 +47,8 @@ interface FrigateCardTimelineData { id: string; content: string; start: number; + end?: number; + source: FrigateBrowseMediaSource; } @customElement('frigate-card-timeline-event') @@ -133,6 +138,7 @@ class TimelineEventManager { content: this._contentCallback?.(child) ?? '', title: this._tooltipCallback?.(child) ?? '', start: child.frigate.event.start_time * 1000, + source: child, }; if (child.frigate.event.end_time) { item['end'] = child.frigate.event.end_time * 1000; @@ -294,11 +300,55 @@ export class FrigateCardTimeline extends LitElement { * @returns */ protected _timelineSelectHandler(data: { items: string[]; event: Event }): void { - if (data.items.length <= 0) { + if (data.items.length <= 0 || !this._timeline) { return; } - // TODO: Make a parent, attach a select bunch of children to it and evolve the view. + const timelineWindow = this._timeline.getWindow(); + const start = timelineWindow.start.getTime(); + const end = timelineWindow.end.getTime(); + + const children: FrigateBrowseMediaSource[] = []; + let childIndex: number | null = null; + + // Fetch all the events that match the extent of the visible window (cannot + // use getVisibleItems() since it does not return clustered items). + this._events.dataset + .get({ + filter: (item) => + (item.start >= start && item.start <= end) || + (item.start <= start && !!item.end && item.end >= end), + }) + .forEach((item) => { + if (item.source.can_play) { + if ((item.id = data.items[0])) { + childIndex = children.length; + } + children.push(item.source); + } + }); + + if (!children.length) { + return; + } + + this.view + ?.evolve({ + target: { + title: `Timeline ${start} - ${end}`, + media_class: MEDIA_CLASS_PLAYLIST, + media_content_type: MEDIA_TYPE_VIDEO, + media_content_id: '', + can_play: false, + can_expand: true, + children_media_class: MEDIA_CLASS_VIDEO, + thumbnail: null, + children: children, + }, + childIndex: childIndex ?? 0, + view: 'clip', + }) + .dispatchChangeEvent(this); } /** diff --git a/src/types.ts b/src/types.ts index 51090315..73856a39 100644 --- a/src/types.ts +++ b/src/types.ts @@ -912,6 +912,10 @@ 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; + // Recursive type, cannot use type interference: // See: https://github.com/colinhacks/zod#recursive-types // From f9022741883c9432d62ff7ce8721d80b3333c482 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 19 Mar 2022 08:46:01 -0700 Subject: [PATCH 012/345] Cleanup dependencies. --- package.json | 7 ++++--- src/browse-media-util.ts | 4 ---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 506f154c..b1824898 100644 --- a/package.json +++ b/package.json @@ -21,19 +21,20 @@ "@material/image-list": "^13.0.0", "@material/mwc-menu": "^0.25.3", "@material/rtl": "^13.0.0", + "@types/bluebird": "^3.5.36", "component-emitter": "^1.3.0", "crypto": "^1.0.1", "custom-card-helpers": "^1.8.0", - "dayjs": "^1.11.0", "embla-carousel": "^6.1.1", "home-assistant-js-websocket": "^6.1.1", "lit": "^2.2.1", "keycharm": "^0.4.0", "lodash-es": "^4.17.21", - "quick-lru": "^6.1.0", "moment": "^2.29.1", "propagating-hammerjs": "^2.0.1", + "quick-lru": "^6.1.0", "screenfull": "^6.0.1", + "ts-toolbelt": "^9.6.0", "uuid": "^8.3.2", "vis-data": "^7.1.3", "vis-timeline": "^7.5.1", @@ -56,7 +57,7 @@ "eslint": "^8.11.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-prettier": "^8.5.0", - "eslint-plugin-import": "^2.24.2", + "eslint-plugin-import": "^2.25.4", "eslint-plugin-prettier": "^4.0.0", "npm-check-updates": "^12.5.3", "prettier": "^2.6.0", diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index f5d01c24..37f19352 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -1,6 +1,4 @@ import { HomeAssistant } from 'custom-card-helpers'; -import dayjs from 'dayjs'; -import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat.js'; import type { BrowseMediaQueryParameters, @@ -17,8 +15,6 @@ import { } from './common.js'; import { localize } from './localize/localize.js'; -dayjs.extend(dayjs_custom_parse_format); - export class BrowseMediaUtil { /** * Return the Frigate event_id given a FrigateBrowseMediaSource object. From 16dc3d244ad9aa963817d0b6494b19b07cc54651 Mon Sep 17 00:00:00 2001 From: Nick Mowen Date: Sun, 20 Mar 2022 14:57:51 -0600 Subject: [PATCH 013/345] Adding favorite icon --- src/components/thumbnail-carousel.ts | 65 ++++++++++++++++++++-------- src/scss/thumbnail-carousel.scss | 8 ++++ src/types.ts | 2 + 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 7ad9dd18..5eefae89 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -109,25 +109,52 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { return; } - return html`
{ - if (this._carousel && this._carousel.clickAllowed()) { - dispatchFrigateCardEvent(this, 'carousel:tap', { - slideIndex: slideIndex, - target: parent, - childIndex: childIndex, - }); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - -
`; + console.log("Checking for retain: " + mediaToRender?.frigate?.event?.retain_indefinitely) + if (mediaToRender.frigate?.event?.retain_indefinitely == true) { + return html`
{ + if (this._carousel && this._carousel.clickAllowed()) { + dispatchFrigateCardEvent(this, 'carousel:tap', { + slideIndex: slideIndex, + target: parent, + childIndex: childIndex, + }); + } + stopEventFromActivatingCardWideActions(ev); + }} + > + + +
`; + } else { + return html`
{ + if (this._carousel && this._carousel.clickAllowed()) { + dispatchFrigateCardEvent(this, 'carousel:tap', { + slideIndex: slideIndex, + target: parent, + childIndex: childIndex, + }); + } + stopEventFromActivatingCardWideActions(ev); + }} + > + +
`; + } } /** diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index 22055ca5..d7def473 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -25,4 +25,12 @@ // 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; + height: 30px; + width: 30px; + top: 8; + right: 8; + color: yellow; } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 73856a39..6aeb65b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -946,6 +946,7 @@ export interface FrigateBrowseMediaSource extends BrowseMediaSource { start_time: number; top_score: number; zones: string[]; + retain_indefinitely: boolean; }; }; } @@ -975,6 +976,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema = z. start_time: z.number(), top_score: z.number(), zones: z.string().array(), + retain_indefinitely: z.boolean(), }), }) .optional(), From ca837774bfbf3379512a0feb367998bf7cbd076b Mon Sep 17 00:00:00 2001 From: Nick Mowen Date: Sun, 20 Mar 2022 17:34:11 -0600 Subject: [PATCH 014/345] Fix positioning of icon and use ternary --- src/components/thumbnail-carousel.ts | 64 +++++++++------------------- src/scss/thumbnail-carousel.scss | 11 +++-- 2 files changed, 28 insertions(+), 47 deletions(-) diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 5eefae89..3110a020 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -109,52 +109,30 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { return; } - console.log("Checking for retain: " + mediaToRender?.frigate?.event?.retain_indefinitely) - if (mediaToRender.frigate?.event?.retain_indefinitely == true) { - return html`
{ - if (this._carousel && this._carousel.clickAllowed()) { - dispatchFrigateCardEvent(this, 'carousel:tap', { - slideIndex: slideIndex, - target: parent, - childIndex: childIndex, - }); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - + return html`
{ + if (this._carousel && this._carousel.clickAllowed()) { + dispatchFrigateCardEvent(this, 'carousel:tap', { + slideIndex: slideIndex, + target: parent, + childIndex: childIndex, + }); + } + stopEventFromActivatingCardWideActions(ev); + }} + > + + ${mediaToRender?.frigate?.event?.retain_indefinitely ? html` -
`; - } else { - return html`
{ - if (this._carousel && this._carousel.clickAllowed()) { - dispatchFrigateCardEvent(this, 'carousel:tap', { - slideIndex: slideIndex, - target: parent, - childIndex: childIndex, - }); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - -
`; - } + />` : ``} +
`; } /** diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index d7def473..25dc7342 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -7,6 +7,8 @@ flex: 0 0 var(--frigate-card-carousel-thumbnail-size); opacity: var(--frigate-card-carousel-thumbnail-opacity); transition: opacity 0.6s ease, transform 0.2s linear; + position: relative; + display: inline-block; } .embla__slide.slide-selected { opacity: 1.0; @@ -28,9 +30,10 @@ } .favorite { position: absolute; - height: 30px; - width: 30px; - top: 8; - right: 8; + transform: translate(-50%, -50%); + height: 24px; + width: 24px; + top: 12%; + left: 90%; color: yellow; } \ No newline at end of file From 57afd5cdda6669f9f5a58722f1eaec8b2d03a230 Mon Sep 17 00:00:00 2001 From: Nick Mowen Date: Sun, 20 Mar 2022 17:41:47 -0600 Subject: [PATCH 015/345] Add favorite icon to gallery --- src/components/gallery.ts | 5 ++++- src/scss/gallery.scss | 9 +++++++++ src/scss/thumbnail-carousel.scss | 2 -- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 21d26fd8..4428681a 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -218,7 +218,10 @@ export class FrigateCardGalleryCore extends LitElement { } stopEventFromActivatingCardWideActions(ev); }} - />` + />${child.frigate?.event?.retain_indefinitely ? html`` : ``}` : ``} `, diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index 5f367850..f8c5b8d2 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -43,4 +43,13 @@ ha-card.frigate-card-gallery-folder { padding: 10px; height: 100%; line-height: 1; +} +.favorite { + position: absolute; + transform: translate(-50%, -50%); + height: 24px; + width: 24px; + top: 12%; + left: 90%; + color: yellow; } \ No newline at end of file diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index 25dc7342..d8d3ca67 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -7,8 +7,6 @@ flex: 0 0 var(--frigate-card-carousel-thumbnail-size); opacity: var(--frigate-card-carousel-thumbnail-opacity); transition: opacity 0.6s ease, transform 0.2s linear; - position: relative; - display: inline-block; } .embla__slide.slide-selected { opacity: 1.0; From e7901ebe48ef816a811ede2684e06a40f68dd727 Mon Sep 17 00:00:00 2001 From: Nick Mowen Date: Sun, 20 Mar 2022 17:44:25 -0600 Subject: [PATCH 016/345] Tweak positioning --- src/scss/gallery.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index f8c5b8d2..03ad3fdd 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -49,7 +49,7 @@ ha-card.frigate-card-gallery-folder { transform: translate(-50%, -50%); height: 24px; width: 24px; - top: 12%; + top: 10%; left: 90%; color: yellow; } \ No newline at end of file From 304abcd82b0e30873c7db97ccfade0e9aa21745b Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 19 Mar 2022 10:29:45 -0700 Subject: [PATCH 017/345] Add drawer component and support side thumbnails. --- package.json | 5 ++- src/components/carousel.ts | 13 +++++- src/components/drawer.ts | 63 ++++++++++++++++++++++++++++ src/components/thumbnail-carousel.ts | 20 +++++++-- src/scss/carousel.scss | 15 ++++++- src/scss/drawer-inject.scss | 34 +++++++++++++++ src/scss/drawer.scss | 3 ++ src/scss/timeline.scss | 3 ++ 8 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 src/components/drawer.ts create mode 100644 src/scss/drawer-inject.scss create mode 100644 src/scss/drawer.scss diff --git a/package.json b/package.json index b1824898..6d8a11b6 100644 --- a/package.json +++ b/package.json @@ -24,16 +24,17 @@ "@types/bluebird": "^3.5.36", "component-emitter": "^1.3.0", "crypto": "^1.0.1", - "custom-card-helpers": "^1.8.0", + "custom-card-helpers": "^1.9.0", "embla-carousel": "^6.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/components/carousel.ts b/src/components/carousel.ts index 217506d9..85236657 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -1,4 +1,6 @@ import { CSSResultGroup, LitElement, unsafeCSS, PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + import EmblaCarousel, { EmblaCarouselType, EmblaOptionsType, @@ -15,6 +17,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 = {}; @@ -98,7 +103,13 @@ 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..4f3986de --- /dev/null +++ b/src/components/drawer.ts @@ -0,0 +1,63 @@ +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'; + + /** + * Set the timeline configuration. + */ + @property({ type: Boolean, reflect: true, attribute: true }) + set open(open: boolean) { + if (this._drawerRef.value) { + const old = this._drawerRef.value.open; + this._drawerRef.value.open = open; + this.requestUpdate('open', old); + } + } + + get open(): boolean { + return this._drawerRef.value?.open ?? false; + } + + protected _drawerRef: 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._drawerRef.value?.shadowRoot?.appendChild(style); + } + + protected render(): TemplateResult { + return html` + + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(drawerStyle); + } +} diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 3110a020..e21a50e5 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -1,11 +1,14 @@ 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 type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; import { FrigateCardCarousel } from './carousel.js'; -import { dispatchFrigateCardEvent, stopEventFromActivatingCardWideActions } from '../common.js'; +import { + dispatchFrigateCardEvent, + stopEventFromActivatingCardWideActions, +} from '../common.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; @@ -18,7 +21,7 @@ export interface ThumbnailCarouselTap { @customElement('frigate-card-thumbnail-carousel') export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { @property({ attribute: false }) - protected target?: FrigateBrowseMediaSource; + public target?: FrigateBrowseMediaSource; protected _tapSelected?; @@ -90,6 +93,17 @@ 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 (this._carousel && changedProperties.has('target')) { + this._destroyCarousel(); + } + super.updated(changedProperties); + } + /** * Render a given thumbnail. * @param mediaToRender The media item to render. diff --git a/src/scss/carousel.scss b/src/scss/carousel.scss index 68a2132e..46c973c2 100644 --- a/src/scss/carousel.scss +++ b/src/scss/carousel.scss @@ -23,11 +23,19 @@ img,video { width: 100%; height: 100%; + flex-direction: column; + user-select: none; -webkit-touch-callout: none; -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 +58,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. has similar added directly in // its element. diff --git a/src/scss/drawer-inject.scss b/src/scss/drawer-inject.scss new file mode 100644 index 00000000..30b060ac --- /dev/null +++ b/src/scss/drawer-inject.scss @@ -0,0 +1,34 @@ +:host { + // Drawer width sizes to contents. + width: unset; +}; + +#d, #fs { + // Override width/height to be 100% instead of 100v[wh]. + height: 100%; + + // Position absolutely. + position: absolute; +} + +: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 rgba(0, 0, 0, 0.5); +} + +#fs { + width: 100%; + inset: 0; +} + +#ifs { + // Override width/height to be 100% instead of 100v[wh]. + height: 100%; +}; \ No newline at end of file diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss new file mode 100644 index 00000000..3d23bb67 --- /dev/null +++ b/src/scss/drawer.scss @@ -0,0 +1,3 @@ +side-drawer { + background-color: var(--card-background-color); +} diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index de85c980..ff274ecc 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -6,6 +6,9 @@ display: block; background-color: var(--card-background-color); padding-bottom: 5px; + + // So that absolute sidedrawer is relative to this host. + position: relative; } div.timeline { From 50596cb68a4a0daa183d9f04552eac84a947b916 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Mar 2022 12:04:42 -0700 Subject: [PATCH 018/345] Improve drawer controls. --- src/components/drawer.ts | 27 ++++++- src/components/thumbnail-carousel.ts | 45 ++++++----- src/components/timeline.ts | 109 +++++++++++++++++++++------ src/components/viewer.ts | 4 +- src/scss/drawer-inject.scss | 63 +++++++++------- src/scss/drawer.scss | 48 +++++++++++- src/scss/timeline.scss | 9 +++ 7 files changed, 227 insertions(+), 78 deletions(-) diff --git a/src/components/drawer.ts b/src/components/drawer.ts index 4f3986de..fff6936c 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -18,6 +18,9 @@ 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; + /** * Set the timeline configuration. */ @@ -52,9 +55,27 @@ export class FrigateCardDrawer extends LitElement { } protected render(): TemplateResult { - return html` - - `; + return html` + + ${this.control + ? html` +
{ + this.open = !this.open; + }} + > + + +
+ ` + : ''} + +
+ `; } static get styles(): CSSResultGroup { diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index e21a50e5..837b10fd 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -1,11 +1,13 @@ import { BrowseMediaUtil } from '../browse-media-util.js'; import { CSSResultGroup, TemplateResult, html, unsafeCSS, PropertyValues } from 'lit'; import { EmblaOptionsType } from 'embla-carousel'; +import { classMap } from 'lit/directives/class-map.js'; import { customElement, property } from 'lit/decorators.js'; import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; import { FrigateCardCarousel } from './carousel.js'; import { + contentsChanged, dispatchFrigateCardEvent, stopEventFromActivatingCardWideActions, } from '../common.js'; @@ -20,10 +22,11 @@ export interface ThumbnailCarouselTap { @customElement('frigate-card-thumbnail-carousel') export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { - @property({ attribute: false }) + @property({ attribute: false, hasChanged: contentsChanged }) public target?: FrigateBrowseMediaSource; - protected _tapSelected?; + @property({ attribute: false, reflect: true }) + public selected?: number | null; @property({ attribute: false }) set config(config: ThumbnailsControlConfig | undefined) { @@ -55,25 +58,6 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { }; } - /** - * 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. @@ -98,10 +82,20 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { * @param changedProperties The properties that were changed in this render. */ updated(changedProperties: PropertyValues): void { - if (this._carousel && changedProperties.has('target')) { + 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); + } + } + }); + } } /** @@ -123,8 +117,13 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { return; } + const classes = { + 'embla__slide': true, + 'slide-selected': this.selected == childIndex, + }; + return html`
{ if (this._carousel && this._carousel.clickAllowed()) { dispatchFrigateCardEvent(this, 'carousel:tap', { diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 6aa0ef67..3305b84d 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -14,6 +14,7 @@ import { TimelineOptions, TimelineOptionsCluster, } from 'vis-timeline/esnext'; +import { classMap } from 'lit/directives/class-map.js'; import { customElement, property, state } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; @@ -22,12 +23,18 @@ import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource, + ThumbnailsControlConfig, MEDIA_CLASS_PLAYLIST, MEDIA_TYPE_VIDEO, MEDIA_CLASS_VIDEO, TimelineConfig, frigateCardConfigDefaults, } from '../types'; +import { FrigateCardDrawer } from './drawer'; +import { + FrigateCardThumbnailCarousel, + ThumbnailCarouselTap, +} from './thumbnail-carousel'; import { View } from '../view'; import { contentsChanged, @@ -39,6 +46,8 @@ import { import timelineStyle from '../scss/timeline.scss'; import timelineEventStyle from '../scss/timeline-event.scss'; +import './drawer.js'; + interface FrigateCardGroupData { id: string; content: string; @@ -217,7 +226,9 @@ export class FrigateCardTimeline extends LitElement { @state({ hasChanged: contentsChanged }) protected _timelineOptions?: TimelineOptions; + protected _drawerRef: Ref = createRef(); protected _timelineRef: Ref = createRef(); + protected _thumbnailsRef: Ref = createRef(); protected _timeline?: Timeline; protected _events = new TimelineEventManager({ @@ -251,7 +262,41 @@ export class FrigateCardTimeline extends LitElement { if (!this.hass || !this.view) { return; } - return html`
`; + + const config: ThumbnailsControlConfig = { + mode: 'above', + }; + + // TODO move to configuration later. + const drawerLocation: "left" | "right" = "left" as const; + + const timelineClasses = { + "timeline": true, + "left-margin": drawerLocation == "left", + //"right-margin": drawerLocation == "right", + } + + return html` + ) => { + if (ev.detail.target && ev.detail.childIndex) { + this.view + ?.evolve({ + target: ev.detail.target, + childIndex: ev.detail.childIndex, + view: 'clip', + }) + .dispatchChangeEvent(this); + } + }} + > + + +
`; } /** @@ -290,7 +335,9 @@ export class FrigateCardTimeline extends LitElement { this.cameras, properties.start, properties.end, - ); + ).then(() => { + this._updateThumbnails(); + }) } } @@ -299,11 +346,21 @@ export class FrigateCardTimeline extends LitElement { * @param data The data about the selection. * @returns */ - protected _timelineSelectHandler(data: { items: string[]; event: Event }): void { - if (data.items.length <= 0 || !this._timeline) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected _timelineSelectHandler(_data: { items: string[]; event: Event }): void { + this._updateThumbnails(); + if (this._drawerRef.value) { + this._drawerRef.value.open = true; + } + } + + protected _updateThumbnails(): void { + if (!this._timeline) { return; } + const selected = this._timeline?.getSelection(); + const timelineWindow = this._timeline.getWindow(); const start = timelineWindow.start.getTime(); const end = timelineWindow.end.getTime(); @@ -316,12 +373,17 @@ export class FrigateCardTimeline extends LitElement { this._events.dataset .get({ filter: (item) => + // Start within the window. (item.start >= start && item.start <= end) || + // End within the window. + (!!item.end && item.end >= start && item.end <= end) || + // Item lifetime extends past the window (item.start <= start && !!item.end && item.end >= end), + order: 'start', }) .forEach((item) => { if (item.source.can_play) { - if ((item.id = data.items[0])) { + if (childIndex === null && selected.includes(item.id)) { childIndex = children.length; } children.push(item.source); @@ -332,23 +394,24 @@ export class FrigateCardTimeline extends LitElement { return; } - this.view - ?.evolve({ - target: { - title: `Timeline ${start} - ${end}`, - media_class: MEDIA_CLASS_PLAYLIST, - media_content_type: MEDIA_TYPE_VIDEO, - media_content_id: '', - can_play: false, - can_expand: true, - children_media_class: MEDIA_CLASS_VIDEO, - thumbnail: null, - children: children, - }, - childIndex: childIndex ?? 0, - view: 'clip', - }) - .dispatchChangeEvent(this); + const target = { + title: `Timeline ${start} - ${end}`, + media_class: MEDIA_CLASS_PLAYLIST, + media_content_type: MEDIA_TYPE_VIDEO, + media_content_id: '', + can_play: false, + can_expand: true, + children_media_class: MEDIA_CLASS_VIDEO, + thumbnail: null, + children: children, + }; + + if (this._drawerRef.value) { + if (this._thumbnailsRef.value) { + this._thumbnailsRef.value.target = target; + this._thumbnailsRef.value.selected = childIndex ?? undefined; + } + } } /** @@ -384,6 +447,7 @@ export class FrigateCardTimeline extends LitElement { cluster: thumbnailConfig.clustering_threshold > 0 ? { + fitOnDoubleClick: true, showStipes: true, // It would be better to automatically calculate `maxItems` from the // rendered height of the timeline (or group within the timeline) so @@ -404,6 +468,7 @@ export class FrigateCardTimeline extends LitElement { }, zoomMax: 31 * 24 * 60 * 60 * 1000, zoomMin: 1 * 1000, + selectable: true, start: this._getYesterday(), end: this._getToday(), groupHeightMode: 'fixed', diff --git a/src/components/viewer.ts b/src/components/viewer.ts index a5aa231f..d0aa7754 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -135,8 +135,8 @@ export class FrigateCardViewerCore extends LitElement { protected _syncThumbnailCarousel(): void { const mediaSelected = this._viewerCarouselRef.value?.carouselSelected(); - if (mediaSelected !== undefined) { - this._thumbnailCarouselRef.value?.carouselScrollTo(mediaSelected); + if (mediaSelected !== undefined && this._thumbnailCarouselRef.value) { + this._thumbnailCarouselRef.value.selected = mediaSelected; } } diff --git a/src/scss/drawer-inject.scss b/src/scss/drawer-inject.scss index 30b060ac..2033531f 100644 --- a/src/scss/drawer-inject.scss +++ b/src/scss/drawer-inject.scss @@ -1,34 +1,43 @@ :host { - // Drawer width sizes to contents. - width: unset; -}; - -#d, #fs { - // Override width/height to be 100% instead of 100v[wh]. - height: 100%; - - // Position absolutely. - position: absolute; -} - -: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 rgba(0, 0, 0, 0.5); + // Drawer width sizes to contents. + width: unset; } #fs { - width: 100%; - inset: 0; + // 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 { + // Adding for control, may not need this? + 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 rgba(0, 0, 0, 0.5); } #ifs { - // Override width/height to be 100% instead of 100v[wh]. - height: 100%; -}; \ No newline at end of file + // Override width/height to be 100% instead of 100v[wh]. + height: 100%; +} diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss index 3d23bb67..3ed79a2f 100644 --- a/src/scss/drawer.scss +++ b/src/scss/drawer.scss @@ -1,3 +1,49 @@ +$drawer-icon-size: 20px; +$drawer-padding-extend: 20px; + side-drawer { - background-color: var(--card-background-color); + background-color: var(--card-background-color); +} + +div.control-surround { + position: absolute; + bottom: 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.6); + opacity: 0.6; + pointer-events: all; + + --mdc-icon-size: #{$drawer-icon-size}; + padding-top: $drawer-padding-extend; + padding-bottom: $drawer-padding-extend; +} + +: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; } diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index ff274ecc..fe15c743 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -1,4 +1,5 @@ @use 'vis-timeline/dist/vis-timeline-graph2d.css'; +@use 'drawer'; :host { width: 100%; @@ -14,6 +15,14 @@ div.timeline { height: 100%; } +div.timeline.left-margin { + // Clearance for the drawer button. + margin-left: drawer.$drawer-icon-size; +} +div.timeline.right-margin { + // Clearance for the drawer button. + margin-right: drawer.$drawer-icon-size; +} .vis-text { color: var(--primary-text-color) !important; From b9a627702f0483ef4bcca5a3f9b9b6bbcaff3a58 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Mar 2022 14:49:09 -0700 Subject: [PATCH 019/345] Break out thumbnails into their own component. --- package.json | 1 + src/common.ts | 36 +++++++++++++- src/components/drawer.ts | 2 +- src/components/live.ts | 2 +- src/components/thumbnail-carousel.ts | 63 ++++++++++------------- src/components/thumbnail.ts | 74 ++++++++++++++++++++++++++++ src/components/timeline.ts | 2 +- src/localize/languages/en.json | 5 ++ src/scss/thumbnail-carousel.scss | 31 ++---------- src/scss/thumbnail.scss | 65 ++++++++++++++++++++++++ src/types.ts | 28 ++++++----- 11 files changed, 227 insertions(+), 82 deletions(-) create mode 100644 src/components/thumbnail.ts create mode 100644 src/scss/thumbnail.scss diff --git a/package.json b/package.json index 6d8a11b6..b502b25f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "component-emitter": "^1.3.0", "crypto": "^1.0.1", "custom-card-helpers": "^1.9.0", + "date-fns": "^2.28.0", "embla-carousel": "^6.1.1", "home-assistant-js-websocket": "^6.1.1", "keycharm": "^0.4.0", diff --git a/src/common.ts b/src/common.ts index 77eb7e30..0b3016ab 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, @@ -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 - minutes * 60; + let duration = ''; + + if (hours) { + duration += `${hours}h `; + } + if (minutes) { + duration += `${minutes}m `; + } + duration += `${seconds}s`; + return duration; +} diff --git a/src/components/drawer.ts b/src/components/drawer.ts index fff6936c..0ae5c1ee 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -67,7 +67,7 @@ export class FrigateCardDrawer extends LitElement { >
diff --git a/src/components/live.ts b/src/components/live.ts index d34c594f..1d17320a 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -152,7 +152,7 @@ export class FrigateCardLive extends LitElement { .target=${parent} .view=${this.view} .config=${config.controls.thumbnails} - .highlightSelected=${false} + .highlight_selected=${false} @frigate-card:carousel:tap=${(ev: CustomEvent) => { const mediaType = browseMediaParams.mediaType; if (mediaType && this.view && ['snapshots', 'clips'].includes(mediaType)) { diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 837b10fd..5506f55b 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -3,6 +3,7 @@ import { CSSResultGroup, TemplateResult, html, unsafeCSS, PropertyValues } from import { EmblaOptionsType } from 'embla-carousel'; import { classMap } from 'lit/directives/class-map.js'; import { customElement, property } from 'lit/decorators.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; import { FrigateCardCarousel } from './carousel.js'; @@ -12,6 +13,8 @@ import { stopEventFromActivatingCardWideActions, } from '../common.js'; +import "./thumbnail.js"; + import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; export interface ThumbnailCarouselTap { @@ -29,18 +32,10 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { public selected?: number | null; @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; - } - } - protected _config?: ThumbnailsControlConfig; + public config?: ThumbnailsControlConfig; @property({ attribute: false }) - set highlightSelected(value: boolean) { + set highlight_selected(value: boolean) { this.style.setProperty( '--frigate-card-carousel-thumbnail-opacity', value ? '0.6' : '1.0', @@ -113,39 +108,33 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { } const mediaToRender = parent.children[childIndex]; - if (!BrowseMediaUtil.isTrueMedia(mediaToRender) || !mediaToRender.thumbnail) { + if (!BrowseMediaUtil.isTrueMedia(mediaToRender)) { return; } const classes = { - 'embla__slide': true, + embla__slide: true, 'slide-selected': this.selected == childIndex, }; - return html`
{ - if (this._carousel && this._carousel.clickAllowed()) { - dispatchFrigateCardEvent(this, 'carousel:tap', { - slideIndex: slideIndex, - target: parent, - childIndex: childIndex, - }); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - - ${mediaToRender?.frigate?.event?.retain_indefinitely ? html` - ` : ``} -
`; + return html` + { + if (this._carousel && this._carousel.clickAllowed()) { + dispatchFrigateCardEvent(this, 'carousel:tap', { + slideIndex: slideIndex, + target: parent, + childIndex: childIndex, + }); + } + stopEventFromActivatingCardWideActions(ev); + }} + > + `; } /** @@ -154,7 +143,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { */ protected render(): TemplateResult | void { const slides = this._getSlides(); - if (!slides || !this._config || this._config.mode == 'none') { + if (!slides || !this.config || this.config.mode == 'none') { return; } diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts new file mode 100644 index 00000000..fa3d5031 --- /dev/null +++ b/src/components/thumbnail.ts @@ -0,0 +1,74 @@ +import { CSSResult, TemplateResult, html, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { format, fromUnixTime } from 'date-fns'; + +import type { FrigateBrowseMediaSource } from '../types.js'; +import { FrigateCardCarousel } from './carousel.js'; +import { getEventDurationString, prettifyFrigateName } from '../common.js'; +import { localize } from '../localize/localize.js'; + +import thumbnailStyle from '../scss/thumbnail.scss'; + +@customElement('frigate-card-thumbnail') +export class FrigateCardThumbnail extends FrigateCardCarousel { + @property({ attribute: false }) + public media?: FrigateBrowseMediaSource; + + @property({ attribute: true, type: Boolean, reflect: true }) + public details = false; + + @property({ attribute: false }) + set thumbnail_size(size: number) { + this.style.setProperty('--frigate-card-thumbnail-size', String(size)); + } + + /** + * Render the element. + * @returns A template to display to the user. + */ + protected render(): TemplateResult | void { + if (!this.media || !this.media.thumbnail) { + return; + } + const event = this.media.frigate?.event; + return html` + + ${event?.retain_indefinitely + ? html` ` + : ``} + ${this.details && event + ? html`
+
+
${prettifyFrigateName(event.label)}
+
+ + ${localize('event.start')}: + ${format(fromUnixTime(event.start_time), 'HH:mm:ss')} + +
+
+ + ${localize('event.duration')}: + ${getEventDurationString(event)} + +
+
+
+
${(event.top_score * 100).toFixed(2) + '%'}
+
+
` + : html``} + `; + } + + /** + * Get element styles. + */ + static get styles(): CSSResult { + return unsafeCSS(thumbnailStyle); + } +} diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 3305b84d..66982756 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -281,7 +281,7 @@ export class FrigateCardTimeline extends LitElement { ${ref(this._thumbnailsRef)} direction="vertical" .config=${config} - .highlightSelected=${true} + .highlight_selected=${true} @frigate-card:carousel:tap=${(ev: CustomEvent) => { if (ev.detail.target && ev.detail.childIndex) { this.view diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 58613e91..068653ab 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -206,6 +206,11 @@ "overrides": "Overrides are active", "overrides_secondary": "Dynamic configuration overrides detected" }, + "event": { + "start": "Start", + "duration": "Duration", + "in_progress": "In Progress" + }, "error": { "empty_response": "Received empty response from Home Assistant for request", "invalid_response": "Received invalid response from Home Assistant for request", diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index d8d3ca67..f99a6d9d 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -1,37 +1,12 @@ :host { - --frigate-card-carousel-thumbnail-size: 100px; - --frigate-card-carousel-thumbnail-opacity: 0.6; + --frigate-card-carousel-thumbnail-opacity: 0.8; } .embla__slide { - flex: 0 0 var(--frigate-card-carousel-thumbnail-size); + flex: 0 0 fit-content; 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; } \ No newline at end of file diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss new file mode 100644 index 00000000..67406a63 --- /dev/null +++ b/src/scss/thumbnail.scss @@ -0,0 +1,65 @@ +:host { + display: flex; + flex-direction: row; + + --frigate-card-thumbnail-size: 100px; +} + +:host([details]) { + border: 1px solid var(--primary-color); + border-radius: 5px; + padding: 2px; +} + +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-thumbnail-size); + max-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; +} + +.favorite { + position: absolute; + transform: translate(-50%, -50%); + height: 24px; + width: 24px; + top: 12%; + left: 90%; + color: yellow; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 6aeb65b3..a7e2d71d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -932,22 +932,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, }; } From c4ec94573ca55b0fe2a6bc2ff4dfdc32b5d3ef12 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Mar 2022 18:04:15 -0700 Subject: [PATCH 020/345] Add 'show_details' open to show details next to thumbnails. --- README.md | 2 ++ src/components/thumbnail-carousel.ts | 2 +- src/components/timeline.ts | 1 + src/const.ts | 4 ++++ src/editor.ts | 10 ++++++++++ src/localize/languages/en.json | 2 ++ src/types.ts | 10 ++++++++++ 7 files changed, 30 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f8f60ad7..3fe21bee 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,7 @@ live: | - | - | - | - | | `mode` | `none` | :white_check_mark: | Whether to show the thumbnail carousel `below` the media, `above` 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.| | `media` | `clips` | :white_check_mark: | Whether to show `clips` or `snapshots` in the thumbnail carousel in the `live` view.| #### Live Controls: Next / Previous @@ -342,6 +343,7 @@ event_viewer: | - | - | - | - | | `mode` | `none` | :heavy_multiplication_x: | Whether to show the thumbnail carousel `below` the media, `above` 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.| #### Event Viewer Controls: Title diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 5506f55b..fa3b3e52 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -120,7 +120,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { return html` { diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 66982756..6937a785 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -265,6 +265,7 @@ export class FrigateCardTimeline extends LitElement { const config: ThumbnailsControlConfig = { mode: 'above', + show_details: true, }; // TODO move to configuration later. diff --git a/src/const.ts b/src/const.ts index 48b571a4..99b116ad 100644 --- a/src/const.ts +++ b/src/const.ts @@ -45,6 +45,8 @@ 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_SIZE = `${CONF_EVENT_VIEWER}.controls.thumbnails.size` as const; export const CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE = @@ -64,6 +66,8 @@ 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_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; diff --git a/src/editor.ts b/src/editor.ts index 30afefa1..f08a4ca7 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -32,6 +32,7 @@ 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_DETAILS, CONF_EVENT_VIEWER_CONTROLS_THUMBNAILS_SIZE, CONF_EVENT_VIEWER_CONTROLS_TITLE_DURATION_SECONDS, CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE, @@ -46,6 +47,7 @@ import { CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA, CONF_LIVE_CONTROLS_THUMBNAILS_MODE, + CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_LIVE_CONTROLS_THUMBNAILS_SIZE, CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS, CONF_LIVE_CONTROLS_TITLE_MODE, @@ -881,6 +883,10 @@ 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._renderNumberInput( CONF_LIVE_CONTROLS_TITLE_DURATION_SECONDS, 0, @@ -928,6 +934,10 @@ 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._renderOptionSelector( CONF_EVENT_VIEWER_CONTROLS_TITLE_MODE, this._titleModes, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 068653ab..1e8f45a7 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -76,6 +76,7 @@ "thumbnails": { "mode": "Event Viewer thumbnails mode", "size": "Event Viewer thumbnails size (e.g. '100px')", + "show_details": "Show event details with thumbnails", "modes": { "below": "Thumbnails below the media", "above": "Thumbnails above the media", @@ -115,6 +116,7 @@ "thumbnails": { "mode": "Live thumbnails mode", "size": "Live thumbnails size (e.g. '100px')", + "show_details": "Show event details with thumbnails", "media": "Whether to show thumbnails of clips or snapshots", "medias": { "clips": "Clip thumbnails", diff --git a/src/types.ts b/src/types.ts index a7e2d71d..8c945372 100644 --- a/src/types.ts +++ b/src/types.ts @@ -436,6 +436,7 @@ export type ImageViewConfig = z.infer; const thumbnailsControlSchema = z.object({ mode: z.enum(['none', 'above', 'below']), size: z.string().optional(), + show_details: z.boolean().optional(), }); export type ThumbnailsControlConfig = z.infer; @@ -488,6 +489,7 @@ const liveConfigDefault = { thumbnails: { media: 'clips' as const, size: '100px', + show_details: false, mode: 'none' as const, }, title: { @@ -548,6 +550,9 @@ const liveOverridableConfigSchema = z size: thumbnailsControlSchema.shape.size.default( liveConfigDefault.controls.thumbnails.size, ), + show_details: thumbnailsControlSchema.shape.show_details.default( + liveConfigDefault.controls.thumbnails.show_details, + ), media: z .enum(['clips', 'snapshots']) .default(liveConfigDefault.controls.thumbnails.media), @@ -642,6 +647,7 @@ const viewerConfigDefault = { thumbnails: { size: '100px', mode: 'none' as const, + show_details: false, }, title: { mode: 'popup-bottom-right' as const, @@ -654,6 +660,7 @@ const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.ex .enum(['none', 'thumbnails', 'chevrons']) .default(viewerConfigDefault.controls.next_previous.style), size: z.string().default(viewerConfigDefault.controls.next_previous.size), + }); export type ViewerNextPreviousControlConfig = z.infer< typeof viewerNextPreviousControlConfigSchema @@ -681,6 +688,9 @@ const viewerConfigSchema = z size: thumbnailsControlSchema.shape.size.default( viewerConfigDefault.controls.thumbnails.size, ), + show_details: thumbnailsControlSchema.shape.show_details.default( + viewerConfigDefault.controls.thumbnails.show_details, + ), }) .default(viewerConfigDefault.controls.thumbnails), title: titleControlConfigSchema From 0ae9afef4b9f76228cfc89add8254bdb649d4776 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Mar 2022 20:06:20 -0700 Subject: [PATCH 021/345] Tweak retain_definitely to work wide the newer thumbnails. --- src/components/gallery.ts | 2 ++ src/components/thumbnail.ts | 6 +++++- src/localize/languages/en.json | 3 ++- src/scss/drawer.scss | 6 ++++++ src/scss/gallery.scss | 28 ++++++++++++++-------------- src/scss/thumbnail.scss | 19 ++++++++++--------- src/types.ts | 4 ++-- 7 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 4428681a..8f15f4f5 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'; @@ -221,6 +222,7 @@ export class FrigateCardGalleryCore extends LitElement { />${child.frigate?.event?.retain_indefinitely ? html`` : ``}` : ``} diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index fa3d5031..243524c3 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -38,7 +38,11 @@ export class FrigateCardThumbnail extends FrigateCardCarousel { title="${this.media.title}" /> ${event?.retain_indefinitely - ? html` ` + ? html` ` : ``} ${this.details && event ? html`
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 1e8f45a7..4d417a97 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -211,7 +211,8 @@ "event": { "start": "Start", "duration": "Duration", - "in_progress": "In Progress" + "in_progress": "In Progress", + "retain_indefinitely": "Event will be indefinitely retained" }, "error": { "empty_response": "Received empty response from Home Assistant for request", diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss index 3ed79a2f..e2157dc6 100644 --- a/src/scss/drawer.scss +++ b/src/scss/drawer.scss @@ -38,6 +38,12 @@ ha-icon.control { padding-bottom: $drawer-padding-extend; } +:host([open]) ha-icon.control { + // When the drawer is open 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; diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index 03ad3fdd..eaac8f8c 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -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 'thumbnail'; :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; -} \ No newline at end of file + +@include thumbnail.thumbnail-favorite() { + opacity: 0.8; +} ; diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index 67406a63..2bb746b9 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -54,12 +54,13 @@ span.heading { font-weight: bold; } -.favorite { - position: absolute; - transform: translate(-50%, -50%); - height: 24px; - width: 24px; - top: 12%; - left: 90%; - color: yellow; -} \ No newline at end of file +@mixin thumbnail-favorite() { + ha-icon.favorite { + position: absolute; + color: var(--primary-color); + padding: 2px; + @content; + } +} + +@include thumbnail-favorite() \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 8c945372..34e660e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -953,7 +953,7 @@ export interface FrigateEvent { start_time: number; top_score: number; zones: string[]; - retain_indefinitely: boolean; + retain_indefinitely?: boolean; } export interface FrigateBrowseMediaSource extends BrowseMediaSource { @@ -988,7 +988,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema = z. start_time: z.number(), top_score: z.number(), zones: z.string().array(), - retain_indefinitely: z.boolean(), + retain_indefinitely: z.boolean().optional(), }), }) .optional(), From 69ee18871f4b4f94d52c08f917977f9975dff140 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Mar 2022 20:10:10 -0700 Subject: [PATCH 022/345] Make the drawer shadow darker. --- src/scss/drawer-inject.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scss/drawer-inject.scss b/src/scss/drawer-inject.scss index 2033531f..92ac13ae 100644 --- a/src/scss/drawer-inject.scss +++ b/src/scss/drawer-inject.scss @@ -34,7 +34,7 @@ :host([location='right'][open]) #d { transform: none; - box-shadow: 0px 0px 25px 0px rgba(0, 0, 0, 0.5); + box-shadow: 0px 0px 25px 0px black; } #ifs { From 643066336207d31528bc4ba80831237cf6ae4bce Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Mar 2022 21:23:11 -0700 Subject: [PATCH 023/345] Fix carousel auto content sizing. --- src/components/thumbnail.ts | 5 ++--- src/scss/favorite.scss | 5 +++++ src/scss/gallery.scss | 6 +++--- src/scss/thumbnail-carousel.scss | 2 +- src/scss/thumbnail.scss | 18 +++++++----------- 5 files changed, 18 insertions(+), 18 deletions(-) create mode 100644 src/scss/favorite.scss diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 243524c3..35be6241 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -1,16 +1,15 @@ -import { CSSResult, TemplateResult, html, unsafeCSS } from 'lit'; +import { CSSResult, TemplateResult, html, unsafeCSS, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { format, fromUnixTime } from 'date-fns'; import type { FrigateBrowseMediaSource } from '../types.js'; -import { FrigateCardCarousel } from './carousel.js'; import { getEventDurationString, prettifyFrigateName } from '../common.js'; import { localize } from '../localize/localize.js'; import thumbnailStyle from '../scss/thumbnail.scss'; @customElement('frigate-card-thumbnail') -export class FrigateCardThumbnail extends FrigateCardCarousel { +export class FrigateCardThumbnail extends LitElement { @property({ attribute: false }) public media?: FrigateBrowseMediaSource; diff --git a/src/scss/favorite.scss b/src/scss/favorite.scss new file mode 100644 index 00000000..c89adc2b --- /dev/null +++ b/src/scss/favorite.scss @@ -0,0 +1,5 @@ +ha-icon.favorite { + position: absolute; + color: var(--primary-color); + padding: 2px; +} \ No newline at end of file diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index eaac8f8c..eb9d212e 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -1,6 +1,6 @@ @use '@material/image-list/mdc-image-list'; @use '@material/image-list'; -@use 'thumbnail'; +@use './favorite.scss'; :host { display: block; @@ -50,6 +50,6 @@ ha-card.frigate-card-gallery-folder { line-height: 1; } -@include thumbnail.thumbnail-favorite() { +ha-icon.favorite { opacity: 0.8; -} ; +} diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index f99a6d9d..d98c7bed 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -3,7 +3,7 @@ } .embla__slide { - flex: 0 0 fit-content; + flex: 0 0 auto; opacity: var(--frigate-card-carousel-thumbnail-opacity); transition: opacity 0.6s ease; } diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index 2bb746b9..d851b148 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -1,8 +1,15 @@ +@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]) { @@ -53,14 +60,3 @@ div.larger { span.heading { font-weight: bold; } - -@mixin thumbnail-favorite() { - ha-icon.favorite { - position: absolute; - color: var(--primary-color); - padding: 2px; - @content; - } -} - -@include thumbnail-favorite() \ No newline at end of file From cd7c4a3980cad97da136eabb03ce6886b145f4c8 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Mar 2022 21:30:30 -0700 Subject: [PATCH 024/345] Match the thumbnail radius to the card radius. --- src/scss/thumbnail.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index d851b148..14795b33 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -14,12 +14,12 @@ :host([details]) { border: 1px solid var(--primary-color); - border-radius: 5px; + border-radius: var(--ha-card-border-radius, 4px); padding: 2px; } img { - border-radius: 5px; + 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 From b04d6c11fcce8932d3030a852c66659282303e73 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Mar 2022 21:44:50 -0700 Subject: [PATCH 025/345] Remove the old thumbnail based visualization. --- src/components/timeline.ts | 84 ++------------------------------------ src/scss/drawer.scss | 2 + 2 files changed, 5 insertions(+), 81 deletions(-) diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 6937a785..ab69726e 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -39,12 +39,10 @@ import { View } from '../view'; import { contentsChanged, dispatchErrorMessageEvent, - dispatchFrigateCardEvent, getCameraTitle, } from '../common.js'; import timelineStyle from '../scss/timeline.scss'; -import timelineEventStyle from '../scss/timeline-event.scss'; import './drawer.js'; @@ -60,69 +58,15 @@ interface FrigateCardTimelineData { source: FrigateBrowseMediaSource; } -@customElement('frigate-card-timeline-event') -export class FrigateCardTimelineEvent extends LitElement { - @property({ attribute: true }) - protected media_id?: string; - - @property({ attribute: true }) - protected thumbnail?: string; - - @property({ attribute: true }) - protected label?: string; - - @property({ attribute: true, type: Number }) - protected thumbnail_size?: number; - - /** - * Ensure there is a cached value before an update. - * @param _changedProps The changed properties - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected willUpdate(_changedProps: PropertyValues): void { - if (this.thumbnail_size !== undefined) { - this.style.setProperty( - '--frigate-card-timeline-thumbnail-size', - `${this.thumbnail_size}px`, - ); - } - } - - protected render(): TemplateResult | void { - if (!this.thumbnail) { - return; - } - - return html` { - // The view is not accessible from here, since this element is created - // from a string (see _buildEventContent below), so instead we emit an - // intermediate event that is caught by the timeline. - dispatchFrigateCardEvent(this, 'timeline-select', this.media_id); - }} - src="${this.thumbnail}" - title="${this.label || ''}" - aria-label="${this.label || ''}" - />`; - } - - static get styles(): CSSResultGroup { - return unsafeCSS(timelineEventStyle); - } -} - class TimelineEventManager { protected _dataset = new DataSet(); protected _contentCallback?: (FrigateBrowseMediaSource) => string; - protected _tooltipCallback?: (FrigateBrowseMediaSource) => string; - constructor(params: { + constructor(params?: { contentCallback?: (source: FrigateBrowseMediaSource) => string; - tooltipCallback?: (source: FrigateBrowseMediaSource) => string; }) { - this._contentCallback = params.contentCallback; - this._tooltipCallback = params.tooltipCallback; + this._contentCallback = params?.contentCallback; } get dataset(): DataSet { @@ -145,7 +89,6 @@ class TimelineEventManager { id: child.media_content_id, group: camera, content: this._contentCallback?.(child) ?? '', - title: this._tooltipCallback?.(child) ?? '', start: child.frigate.event.start_time * 1000, source: child, }; @@ -231,28 +174,7 @@ export class FrigateCardTimeline extends LitElement { protected _thumbnailsRef: Ref = createRef(); protected _timeline?: Timeline; - protected _events = new TimelineEventManager({ - tooltipCallback: this._generateTooltip.bind(this), - }); - - /** - * Build the content of a single event on the timeline. - * @param source The FrigateBrowseMediaSource object for this event. - * @returns A string to include on the timeline. - */ - protected _generateTooltip(source: FrigateBrowseMediaSource): string { - return ` - - `; - } + protected _events = new TimelineEventManager() /** * Master render method. diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss index e2157dc6..3743303e 100644 --- a/src/scss/drawer.scss +++ b/src/scss/drawer.scss @@ -36,6 +36,8 @@ ha-icon.control { --mdc-icon-size: #{$drawer-icon-size}; padding-top: $drawer-padding-extend; padding-bottom: $drawer-padding-extend; + + transition: opacity 1s ease; } :host([open]) ha-icon.control { From d2a7e877c369d28f863478fef77f46a6d16d07c4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Mar 2022 22:47:23 -0700 Subject: [PATCH 026/345] Allow thumbnails above/below on timeline. --- src/components/thumbnail-carousel.ts | 16 ++-- src/components/timeline.ts | 113 +++++++++++++-------------- src/scss/thumbnail.scss | 2 + src/scss/timeline-event.scss | 16 ---- src/scss/timeline.scss | 4 +- src/types.ts | 45 +++++------ 6 files changed, 91 insertions(+), 105 deletions(-) delete mode 100644 src/scss/timeline-event.scss diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index fa3b3e52..e0ab4225 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -2,7 +2,7 @@ import { BrowseMediaUtil } from '../browse-media-util.js'; import { CSSResultGroup, TemplateResult, html, unsafeCSS, PropertyValues } from 'lit'; import { EmblaOptionsType } from 'embla-carousel'; import { classMap } from 'lit/directives/class-map.js'; -import { customElement, property } from 'lit/decorators.js'; +import { customElement, property, state } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; import type { FrigateBrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; @@ -32,7 +32,13 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { public selected?: number | null; @property({ attribute: false }) - public config?: ThumbnailsControlConfig; + set config(config: ThumbnailsControlConfig) { + this.direction = ['left', 'right'].includes(config.mode) ? 'vertical' : 'horizontal'; + this._config = config; + } + + @state() + protected _config?: ThumbnailsControlConfig; @property({ attribute: false }) set highlight_selected(value: boolean) { @@ -120,8 +126,8 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { return html` { if (this._carousel && this._carousel.clickAllowed()) { @@ -143,7 +149,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { */ protected render(): TemplateResult | void { const slides = this._getSlides(); - if (!slides || !this.config || this.config.mode == 'none') { + if (!slides || !this._config || this._config.mode == 'none') { return; } diff --git a/src/components/timeline.ts b/src/components/timeline.ts index ab69726e..86d79910 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -174,52 +174,64 @@ export class FrigateCardTimeline extends LitElement { protected _thumbnailsRef: Ref = createRef(); protected _timeline?: Timeline; - protected _events = new TimelineEventManager() + protected _events = new TimelineEventManager(); /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view) { + if (!this.hass || !this.view || !this._timelineConfig) { return; } - const config: ThumbnailsControlConfig = { - mode: 'above', - show_details: true, - }; + const thumbnailsConfig = this._timelineConfig.controls.thumbnails; - // TODO move to configuration later. - const drawerLocation: "left" | "right" = "left" as const; + const drawer = ['left', 'right'].includes(thumbnailsConfig.mode) + ? (thumbnailsConfig.mode as 'left' | 'right') + : null; const timelineClasses = { - "timeline": true, - "left-margin": drawerLocation == "left", - //"right-margin": drawerLocation == "right", - } + timeline: true, + 'left-margin': drawer === 'left', + 'right-margin': drawer === 'right', + }; - return html` - ) => { - if (ev.detail.target && ev.detail.childIndex) { - this.view - ?.evolve({ - target: ev.detail.target, - childIndex: ev.detail.childIndex, - view: 'clip', - }) - .dispatchChangeEvent(this); - } - }} - > - - -
`; + const renderThumbnails = (): TemplateResult => { + const renderCarousel = (): TemplateResult => { + return html` + ) => { + if (ev.detail.target && ev.detail.childIndex) { + this.view + ?.evolve({ + target: ev.detail.target, + childIndex: ev.detail.childIndex, + view: 'clip', + }) + .dispatchChangeEvent(this); + } + }} + > + + `; + }; + + return drawer + ? html` + ${renderCarousel()} + ` + : renderCarousel(); + }; + + return html` + ${thumbnailsConfig.mode === 'above' ? renderThumbnails() : ''} +
+ ${thumbnailsConfig.mode !== 'above' ? renderThumbnails() : ''} + `; } /** @@ -252,15 +264,11 @@ export class FrigateCardTimeline extends LitElement { // fetched PLUS events that did not previously have an end_time. That's // not trivial to implement, and it's not yet clear it's worth the extra // complexity. - this._events.fetchEvents( - this, - this.hass, - this.cameras, - properties.start, - properties.end, - ).then(() => { - this._updateThumbnails(); - }) + this._events + .fetchEvents(this, this.hass, this.cameras, properties.start, properties.end) + .then(() => { + this._updateThumbnails(); + }); } } @@ -329,11 +337,9 @@ export class FrigateCardTimeline extends LitElement { children: children, }; - if (this._drawerRef.value) { - if (this._thumbnailsRef.value) { - this._thumbnailsRef.value.target = target; - this._thumbnailsRef.value.selected = childIndex ?? undefined; - } + if (this._thumbnailsRef.value) { + this._thumbnailsRef.value.target = target; + this._thumbnailsRef.value.selected = childIndex ?? undefined; } } @@ -360,17 +366,12 @@ export class FrigateCardTimeline extends LitElement { return; } - const thumbnailConfig = - this._timelineConfig?.controls.thumbnails ?? - frigateCardConfigDefaults.timeline.controls.thumbnails; - // Configuration for the Timeline, see: // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options this._timelineOptions = { cluster: - thumbnailConfig.clustering_threshold > 0 + this._timelineConfig.clustering_threshold > 0 ? { - fitOnDoubleClick: true, showStipes: true, // It would be better to automatically calculate `maxItems` from the // rendered height of the timeline (or group within the timeline) so @@ -380,15 +381,11 @@ export class FrigateCardTimeline extends LitElement { // and if we adjust `maxItems` then we can get into an infinite // resize loop. Adjusting the `maxItems` of a timeline, after it's // created, also does not appear to work as expected. - maxItems: thumbnailConfig.clustering_threshold, + maxItems: this._timelineConfig.clustering_threshold, } : (false as TimelineOptionsCluster), minHeight: '100%', maxHeight: '100%', - tooltip: { - followMouse: true, - overflowMethod: 'cap', - }, zoomMax: 31 * 24 * 60 * 60 * 1000, zoomMin: 1 * 1000, selectable: true, diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index 14795b33..d48e60e8 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -29,6 +29,8 @@ img { // 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; } diff --git a/src/scss/timeline-event.scss b/src/scss/timeline-event.scss deleted file mode 100644 index 18294c49..00000000 --- a/src/scss/timeline-event.scss +++ /dev/null @@ -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; -} diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index fe15c743..7a65f9ec 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -17,11 +17,11 @@ div.timeline { } div.timeline.left-margin { // Clearance for the drawer button. - margin-left: drawer.$drawer-icon-size; + margin-left: calc(drawer.$drawer-icon-size + 1px); } div.timeline.right-margin { // Clearance for the drawer button. - margin-right: drawer.$drawer-icon-size; + margin-right: calc(drawer.$drawer-icon-size + 1px); } .vis-text { diff --git a/src/types.ts b/src/types.ts index 34e660e1..3cd65d15 100644 --- a/src/types.ts +++ b/src/types.ts @@ -434,7 +434,7 @@ export type ImageViewConfig = z.infer; */ 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(), }); @@ -660,7 +660,6 @@ const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.ex .enum(['none', 'thumbnails', 'chevrons']) .default(viewerConfigDefault.controls.next_previous.style), size: z.string().default(viewerConfigDefault.controls.next_previous.size), - }); export type ViewerNextPreviousControlConfig = z.infer< typeof viewerNextPreviousControlConfigSchema @@ -755,33 +754,31 @@ const dimensionsConfigSchema = z * Timeline configuration section. */ const timelineConfigDefault = { + clustering_threshold: 3, controls: { thumbnails: { - size_pixels: 75, - overlap_pixels: 25, - clustering_threshold: 3, + mode: 'left' as const, + size: '100px' as const, + show_details: true, }, }, }; const timelineConfigSchema = z .object({ + clustering_threshold: z.number().default(timelineConfigDefault.clustering_threshold), 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, + ), }) .default(timelineConfigDefault.controls.thumbnails), }) @@ -922,9 +919,9 @@ 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_CLASS_VIDEO = 'video' as const; +export const MEDIA_TYPE_VIDEO = 'video' as const; // Recursive type, cannot use type interference: // See: https://github.com/colinhacks/zod#recursive-types @@ -959,7 +956,7 @@ export interface FrigateEvent { export interface FrigateBrowseMediaSource extends BrowseMediaSource { children?: FrigateBrowseMediaSource[] | null; frigate?: { - event: FrigateEvent, + event: FrigateEvent; }; } From da34daf5bd71fd3014cd9c97abe18bb9004203aa Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Mar 2022 22:57:57 -0700 Subject: [PATCH 027/345] Fix fullscreen bug. --- src/components/thumbnail-carousel.ts | 2 +- src/scss/thumbnail-carousel.scss | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index e0ab4225..35bcfc2a 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -44,7 +44,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { set highlight_selected(value: boolean) { this.style.setProperty( '--frigate-card-carousel-thumbnail-opacity', - value ? '0.6' : '1.0', + value ? '0.4' : '1.0', ); } diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index d98c7bed..f34aca5d 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -1,5 +1,9 @@ :host { --frigate-card-carousel-thumbnail-opacity: 0.8; + + // In fullscreen mode, without explicitly setting the height to auto Chrome + // will construct a stylesheet with 100% height. + height: auto; } .embla__slide { From c53a3cb81808f3f717636522ebb87c064ae1ff47 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 20 Mar 2022 23:19:04 -0700 Subject: [PATCH 028/345] Fix tiny timing bug. --- src/common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common.ts b/src/common.ts index 0b3016ab..27bf72e7 100644 --- a/src/common.ts +++ b/src/common.ts @@ -611,7 +611,7 @@ export function getEventDurationString(event: FrigateEvent): string { 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 - minutes * 60; + const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60; let duration = ''; if (hours) { From b649401f115323796973e9fd40e846e51a2787de Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 21 Mar 2022 19:48:09 -0700 Subject: [PATCH 029/345] Add wheel gestures and fix scrolling issue. --- package.json | 1 + src/components/carousel.ts | 9 +++++++-- src/components/live.ts | 3 ++- src/components/viewer.ts | 3 ++- src/scss/thumbnail-carousel.scss | 5 +++++ src/scss/timeline.scss | 7 +++++-- 6 files changed, 22 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index b502b25f..7079863d 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "custom-card-helpers": "^1.9.0", "date-fns": "^2.28.0", "embla-carousel": "^6.1.1", + "embla-carousel-wheel-gestures": "^2.1.1", "home-assistant-js-websocket": "^6.1.1", "keycharm": "^0.4.0", "lit": "^2.2.1", diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 85236657..a3bd237a 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -6,6 +6,7 @@ import EmblaCarousel, { EmblaOptionsType, EmblaPluginType, } from 'embla-carousel'; +import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures' import { TransitionEffect } from '../types'; import { dispatchFrigateCardEvent } from '../common'; @@ -76,8 +77,12 @@ 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 { diff --git a/src/components/live.ts b/src/components/live.ts index 1d17320a..cccd9a6d 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -344,8 +344,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) diff --git a/src/components/viewer.ts b/src/components/viewer.ts index d0aa7754..88875885 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -331,8 +331,9 @@ 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) diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index f34aca5d..07d0eacb 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -1,6 +1,11 @@ :host { --frigate-card-carousel-thumbnail-opacity: 0.8; +} +: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; diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 7a65f9ec..4d90158a 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -4,16 +4,19 @@ :host { width: 100%; height: 100%; - display: block; 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 { - height: 100%; + flex: 1; } div.timeline.left-margin { // Clearance for the drawer button. From a4fcf2513b062f6be023ae285fbab3f36d79b1cb Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 22 Mar 2022 19:58:34 -0700 Subject: [PATCH 030/345] Add generic support for a 'surround'. --- src/common.ts | 4 +- src/components/drawer.ts | 37 +++---- src/components/surround-thumbnails.ts | 103 +++++++++++++++++++ src/components/surround.ts | 68 +++++++++++++ src/components/timeline.ts | 140 ++++++++++++-------------- src/scss/surround-thumbnails.scss | 5 + src/scss/surround.scss | 17 ++++ src/scss/timeline-core.scss | 88 ++++++++++++++++ src/scss/timeline.scss | 87 +--------------- 9 files changed, 370 insertions(+), 179 deletions(-) create mode 100644 src/components/surround-thumbnails.ts create mode 100644 src/components/surround.ts create mode 100644 src/scss/surround-thumbnails.scss create mode 100644 src/scss/surround.scss create mode 100644 src/scss/timeline-core.scss diff --git a/src/common.ts b/src/common.ts index 27bf72e7..4bf4301c 100644 --- a/src/common.ts +++ b/src/common.ts @@ -115,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, diff --git a/src/components/drawer.ts b/src/components/drawer.ts index 0ae5c1ee..2945b3f3 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -21,23 +21,11 @@ export class FrigateCardDrawer extends LitElement { @property({ attribute: true, reflect: true, type: Boolean }) public control = true; - /** - * Set the timeline configuration. - */ @property({ type: Boolean, reflect: true, attribute: true }) - set open(open: boolean) { - if (this._drawerRef.value) { - const old = this._drawerRef.value.open; - this._drawerRef.value.open = open; - this.requestUpdate('open', old); - } - } + public open = false; - get open(): boolean { - return this._drawerRef.value?.open ?? false; - } - - protected _drawerRef: Ref = createRef(); + protected _refDrawer: Ref = createRef(); + protected _refSlot: Ref = createRef(); /** * Called on the first update. @@ -51,12 +39,25 @@ export class FrigateCardDrawer extends LitElement { // override the style to customize the drawer to be absolute within the div. const style = document.createElement('style'); style.innerHTML = drawerInjectStyle; - this._drawerRef.value?.shadowRoot?.appendChild(style); + 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`
` : ''} - + `; } diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts new file mode 100644 index 00000000..3283a62b --- /dev/null +++ b/src/components/surround-thumbnails.ts @@ -0,0 +1,103 @@ +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { HomeAssistant } from 'custom-card-helpers'; +import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { customElement, property } from 'lit/decorators.js'; + +import { + ExtendedHomeAssistant, + FrigateBrowseMediaSource, + FrigateCardView, + ThumbnailsControlConfig, +} from '../types.js'; +import { + FrigateCardThumbnailCarousel, + ThumbnailCarouselTap, +} from './thumbnail-carousel.js'; +import { View } from '../view.js'; +import { dispatchFrigateCardEvent } from '../common.js'; + +import './surround.js'; + +import surroundThumbnailsStyle from '../scss/surround.scss'; + +interface FrigateCardThumbnailsSet { + target: FrigateBrowseMediaSource; + childIndex?: number; +} + +@customElement('frigate-card-surround-thumbnails') +export class FrigateCardSurround extends LitElement { + @property({ attribute: false }) + protected hass?: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected view?: Readonly; + + @property({ attribute: false }) + protected config?: ThumbnailsControlConfig; + + @property({ attribute: false }) + protected targetView?: FrigateCardView; + + protected _refThumbnails: Ref = createRef(); + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.hass || !this.view || !this.config) { + return; + } + + return html` ) => { + if (this._refThumbnails.value) { + this._refThumbnails.value.target = ev.detail.target; + this._refThumbnails.value.selected = ev.detail.childIndex ?? undefined; + } + }} + @frigate-card:thumbnails:open=${(ev: CustomEvent) => { + if (this.config && ['left', 'right'].includes(this.config.mode)) { + // Protects 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 + // . + dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:open', { + drawer: this.config.mode, + }); + } + }} + > + ${this.config?.mode !== 'none' + ? html` ) => { + if (ev.detail.target && ev.detail.childIndex) { + this.view + ?.evolve({ + ...(this.targetView && { view: this.targetView }), + target: ev.detail.target, + childIndex: ev.detail.childIndex, + }) + .dispatchChangeEvent(this); + } + }} + > + ` + : ''} + + `; + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(surroundThumbnailsStyle); + } +} diff --git a/src/components/surround.ts b/src/components/surround.ts new file mode 100644 index 00000000..e929b95e --- /dev/null +++ b/src/components/surround.ts @@ -0,0 +1,68 @@ +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 = createRef(); + protected _refDrawerRight: Ref = createRef(); + protected _boundDrawerOpenHandler = this._drawerOpen.bind(this); + + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + this.addEventListener('frigate-card:drawer:open', this._boundDrawerOpenHandler); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('frigate-card:drawer:open', this._boundDrawerOpenHandler); + } + + protected _drawerOpen(ev: Event) { + const drawer = (ev as CustomEvent).detail.drawer; + if (drawer === 'left' && this._refDrawerLeft.value) { + this._refDrawerLeft.value.open = true; + } else if (drawer === 'right' && this._refDrawerRight.value) { + this._refDrawerRight.value.open = true; + } + } + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + return html` + + + + + + + + `; + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(surroundStyle); + } +} diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 86d79910..f8376a75 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -23,28 +23,23 @@ import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource, - ThumbnailsControlConfig, MEDIA_CLASS_PLAYLIST, MEDIA_TYPE_VIDEO, MEDIA_CLASS_VIDEO, TimelineConfig, - frigateCardConfigDefaults, } from '../types'; -import { FrigateCardDrawer } from './drawer'; -import { - FrigateCardThumbnailCarousel, - ThumbnailCarouselTap, -} from './thumbnail-carousel'; import { View } from '../view'; import { contentsChanged, dispatchErrorMessageEvent, + dispatchFrigateCardEvent, getCameraTitle, } from '../common.js'; +import timelineCoreStyle from '../scss/timeline-core.scss'; import timelineStyle from '../scss/timeline.scss'; -import './drawer.js'; +import './surround-thumbnails.js'; interface FrigateCardGroupData { id: string; @@ -155,6 +150,53 @@ export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) protected cameras?: Map; + @property({ attribute: false }) + protected timelineConfig?: TimelineConfig; + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + if (!this.timelineConfig) { + return html``; + } + + return html` + + + `; + } + + /** + * Return compiled CSS styles. + */ + static get styles(): CSSResultGroup { + return unsafeCSS(timelineStyle); + } +} + +@customElement('frigate-card-timeline-core') +export class FrigateCardTimelineCore extends LitElement { + @property({ attribute: false }) + protected hass?: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected view?: Readonly; + + @property({ attribute: false }) + protected cameras?: Map; + /** * Set the timeline configuration. */ @@ -169,9 +211,7 @@ export class FrigateCardTimeline extends LitElement { @state({ hasChanged: contentsChanged }) protected _timelineOptions?: TimelineOptions; - protected _drawerRef: Ref = createRef(); protected _timelineRef: Ref = createRef(); - protected _thumbnailsRef: Ref = createRef(); protected _timeline?: Timeline; protected _events = new TimelineEventManager(); @@ -186,68 +226,22 @@ export class FrigateCardTimeline extends LitElement { } const thumbnailsConfig = this._timelineConfig.controls.thumbnails; - - const drawer = ['left', 'right'].includes(thumbnailsConfig.mode) - ? (thumbnailsConfig.mode as 'left' | 'right') - : null; - const timelineClasses = { timeline: true, - 'left-margin': drawer === 'left', - 'right-margin': drawer === 'right', + 'left-margin': thumbnailsConfig.mode === 'left', + 'right-margin': thumbnailsConfig.mode === 'right', }; - const renderThumbnails = (): TemplateResult => { - const renderCarousel = (): TemplateResult => { - return html` - ) => { - if (ev.detail.target && ev.detail.childIndex) { - this.view - ?.evolve({ - target: ev.detail.target, - childIndex: ev.detail.childIndex, - view: 'clip', - }) - .dispatchChangeEvent(this); - } - }} - > - - `; - }; - - return drawer - ? html` - ${renderCarousel()} - ` - : renderCarousel(); - }; - - return html` - ${thumbnailsConfig.mode === 'above' ? renderThumbnails() : ''} -
- ${thumbnailsConfig.mode !== 'above' ? renderThumbnails() : ''} - `; + return html`
`; } /** - * Component connected callback. + * Handle a range change in the timeline. + * @param properties vis.js provided range information. */ - connectedCallback(): void { - super.connectedCallback(); - } - - /** - * Component disconnected callback. - */ - disconnectedCallback(): void { - super.disconnectedCallback(); - } - protected _timelineRangeHandler(properties: { start: Date; end: Date; @@ -274,15 +268,13 @@ export class FrigateCardTimeline extends LitElement { /** * Called when an object on the timeline is selected. - * @param data The data about the selection. + * @param _data The data about the selection. * @returns */ // eslint-disable-next-line @typescript-eslint/no-unused-vars protected _timelineSelectHandler(_data: { items: string[]; event: Event }): void { this._updateThumbnails(); - if (this._drawerRef.value) { - this._drawerRef.value.open = true; - } + dispatchFrigateCardEvent(this, 'thumbnails:open'); } protected _updateThumbnails(): void { @@ -337,10 +329,10 @@ export class FrigateCardTimeline extends LitElement { children: children, }; - if (this._thumbnailsRef.value) { - this._thumbnailsRef.value.target = target; - this._thumbnailsRef.value.selected = childIndex ?? undefined; - } + dispatchFrigateCardEvent(this, 'thumbnails:set', { + target: target, + childIndex: childIndex ?? undefined, + }); } /** @@ -463,7 +455,7 @@ export class FrigateCardTimeline extends LitElement { /** * Called when the component is updated. - * @param changedProps The changed properties if any. + * @param changedProperties The changed properties if any. */ protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); @@ -489,6 +481,6 @@ export class FrigateCardTimeline extends LitElement { * Return compiled CSS styles. */ static get styles(): CSSResultGroup { - return unsafeCSS(timelineStyle); + return unsafeCSS(timelineCoreStyle); } } diff --git a/src/scss/surround-thumbnails.scss b/src/scss/surround-thumbnails.scss new file mode 100644 index 00000000..f0f64c07 --- /dev/null +++ b/src/scss/surround-thumbnails.scss @@ -0,0 +1,5 @@ +:host { + width: 100%; + height: 100%; + display: block; +} \ No newline at end of file diff --git a/src/scss/surround.scss b/src/scss/surround.scss new file mode 100644 index 00000000..47236da5 --- /dev/null +++ b/src/scss/surround.scss @@ -0,0 +1,17 @@ +:host { + width: 100%; + height: 100%; + + // Share the screen space with thumbnails that may be above/below. + display: flex; + flex-direction: column; + + // So the drawer is relative to this host. + position: relative; +} + +::slotted:not([name]) { + // Expand the main body to fill available content not otherwise used by the + // surround. + flex: 1; +} diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss new file mode 100644 index 00000000..4d90158a --- /dev/null +++ b/src/scss/timeline-core.scss @@ -0,0 +1,88 @@ +@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; +} diff --git a/src/scss/timeline.scss b/src/scss/timeline.scss index 4d90158a..f0f64c07 100644 --- a/src/scss/timeline.scss +++ b/src/scss/timeline.scss @@ -1,88 +1,5 @@ -@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; -} + display: block; +} \ No newline at end of file From 079c7c5da0afa8d511d32900444f139e5ba0f1dd Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 24 Mar 2022 19:12:13 -0700 Subject: [PATCH 031/345] Update live to use the new surround. --- src/browse-media-util.ts | 10 +-- src/components/carousel.ts | 19 +++--- src/components/live.ts | 89 +++++--------------------- src/components/surround-thumbnails.ts | 83 +++++++++++++++++------- src/components/thumbnail-carousel.ts | 91 ++++++++++++++++++--------- src/scss/carousel.scss | 10 ++- src/scss/drawer.scss | 12 ++-- src/scss/surround.scss | 2 +- src/scss/thumbnail-carousel.scss | 2 +- 9 files changed, 169 insertions(+), 149 deletions(-) diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index 37f19352..1fbfe1d6 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -116,9 +116,9 @@ export class BrowseMediaUtil { static getBrowseMediaQueryParameters( mediaType: 'clips' | 'snapshots', cameraConfig?: CameraConfig, - ): BrowseMediaQueryParameters | undefined { + ): BrowseMediaQueryParameters | null { if (!cameraConfig || !cameraConfig.camera_name) { - return undefined; + return null; } return { mediaType: mediaType, @@ -137,9 +137,9 @@ export class BrowseMediaUtil { node: HTMLElement, view: View, cameraConfig: CameraConfig, - ): BrowseMediaQueryParameters | undefined { + ): BrowseMediaQueryParameters | null { if (!view.isClipRelatedView() && !view.isSnapshotRelatedView()) { - return undefined; + return null; } // Verify there is a camera name, otherwise getBrowseMediaQueryParameters() @@ -149,7 +149,7 @@ export class BrowseMediaUtil { node, localize('error.no_camera_name') + `: ${JSON.stringify(cameraConfig)}`, ); - return undefined; + return null; } return BrowseMediaUtil.getBrowseMediaQueryParameters( diff --git a/src/components/carousel.ts b/src/components/carousel.ts index a3bd237a..9cd9b923 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -6,7 +6,7 @@ import EmblaCarousel, { EmblaOptionsType, EmblaPluginType, } from 'embla-carousel'; -import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures' +import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { TransitionEffect } from '../types'; import { dispatchFrigateCardEvent } from '../common'; @@ -78,11 +78,13 @@ export class FrigateCardCarousel extends LitElement { * @returns An EmblaOptionsType object or undefined for no options. */ protected _getPlugins(): EmblaPluginType[] { - return [WheelGesturesPlugin({ - // Whether the carousel is vertical or horizontal, interpret y-axis wheel - // gestures as scrolling for the carousel. - forceWheelAxis: 'y', - })]; + return [ + WheelGesturesPlugin({ + // Whether the carousel is vertical or horizontal, interpret y-axis wheel + // gestures as scrolling for the carousel. + forceWheelAxis: 'y', + }), + ]; } protected _destroyCarousel(): void { @@ -112,9 +114,10 @@ export class FrigateCardCarousel extends LitElement { carouselNode, { axis: this.direction == 'horizontal' ? 'x' : 'y', - ...this._getOptions() + ...this._getOptions(), }, - plugins); + plugins, + ); this._carousel.on('init', () => dispatchFrigateCardEvent(this, 'carousel:init')); this._carousel.on('select', () => { const selected = this.carouselSelected(); diff --git a/src/components/live.ts b/src/components/live.ts index cccd9a6d..62be17ff 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -120,79 +120,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 => { - 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`) => { - 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); - } - }} - > - `; - } - }; - - 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`
` - : 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 +135,24 @@ export class FrigateCardLive extends LitElement { this.conditionState, ) as LiveConfig; + const browseMediaParams = BrowseMediaUtil.getBrowseMediaQueryParameters( + config.controls.thumbnails.media, + this.cameras.get(this.view.camera), + ); + if (!browseMediaParams) { + return; + } + // 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` - ${config.controls.thumbnails.mode === 'below' ? this.renderThumbnails(config) : ''} - `; + `; } /** diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 3283a62b..ca375539 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -1,20 +1,20 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { HomeAssistant } from 'custom-card-helpers'; -import { createRef, ref, Ref } from 'lit/directives/ref.js'; -import { customElement, property } from 'lit/decorators.js'; +import { Task } from '@lit-labs/task'; +import { customElement, property, state } from 'lit/decorators.js'; +import { BrowseMediaUtil } from '../browse-media-util.js'; import { + BrowseMediaQueryParameters, ExtendedHomeAssistant, FrigateBrowseMediaSource, FrigateCardView, ThumbnailsControlConfig, } from '../types.js'; -import { - FrigateCardThumbnailCarousel, - ThumbnailCarouselTap, -} from './thumbnail-carousel.js'; + +import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; import { View } from '../view.js'; -import { dispatchFrigateCardEvent } from '../common.js'; +import { dispatchErrorMessageEvent, dispatchFrigateCardEvent } from '../common.js'; import './surround.js'; @@ -39,7 +39,48 @@ export class FrigateCardSurround extends LitElement { @property({ attribute: false }) protected targetView?: FrigateCardView; - protected _refThumbnails: Ref = createRef(); + @property({ attribute: false }) + protected browseMediaParams?: BrowseMediaQueryParameters; + + @state() + protected _thumbnailTarget?: FrigateBrowseMediaSource; + + @state() + protected _thumbnailSelected?: number | null; + + // A task to await the load of the WebRTC component. + protected _browseTask = new Task(this, this._fetchMedia.bind(this), () => [ + this.hass, + this.browseMediaParams, + ]); + + /** + * Fetch thumbnail media. + * @param param Task parameters. + * @returns + */ + protected async _fetchMedia([hass, browseMediaParams]: ( + | (HomeAssistant & ExtendedHomeAssistant) + | BrowseMediaQueryParameters + | undefined + )[]): Promise { + hass = hass as HomeAssistant & ExtendedHomeAssistant; + browseMediaParams = browseMediaParams as BrowseMediaQueryParameters; + + if (!hass || !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._thumbnailTarget = parent; + this._thumbnailSelected = null; + } + } /** * Master render method. @@ -52,10 +93,8 @@ export class FrigateCardSurround extends LitElement { return html` ) => { - if (this._refThumbnails.value) { - this._refThumbnails.value.target = ev.detail.target; - this._refThumbnails.value.selected = ev.detail.childIndex ?? undefined; - } + this._thumbnailTarget = ev.detail.target; + this._thumbnailSelected = ev.detail.childIndex; }} @frigate-card:thumbnails:open=${(ev: CustomEvent) => { if (this.config && ['left', 'right'].includes(this.config.mode)) { @@ -73,19 +112,17 @@ export class FrigateCardSurround extends LitElement { ${this.config?.mode !== 'none' ? html` ) => { - if (ev.detail.target && ev.detail.childIndex) { - this.view - ?.evolve({ - ...(this.targetView && { view: this.targetView }), - target: ev.detail.target, - childIndex: ev.detail.childIndex, - }) - .dispatchChangeEvent(this); - } + this.view + ?.evolve({ + ...(this.targetView && { view: this.targetView }), + target: ev.detail.target, + childIndex: ev.detail.childIndex, + }) + .dispatchChangeEvent(this); }} > ` diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 35bcfc2a..4fa71dc4 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -13,7 +13,7 @@ import { stopEventFromActivatingCardWideActions, } from '../common.js'; -import "./thumbnail.js"; +import './thumbnail.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; @@ -28,8 +28,24 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { @property({ attribute: false, hasChanged: contentsChanged }) public target?: FrigateBrowseMediaSource; - @property({ attribute: false, reflect: true }) - public selected?: number | 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 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'); + } + } @property({ attribute: false }) set config(config: ThumbnailsControlConfig) { @@ -40,12 +56,32 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { @state() protected _config?: ThumbnailsControlConfig; - @property({ attribute: false }) - set highlight_selected(value: boolean) { - this.style.setProperty( - '--frigate-card-carousel-thumbnail-opacity', - value ? '0.4' : '1.0', - ); + @state() + protected _selected?: number | null; + + /** + * Handle gallery resize. + */ + protected _resizeHandler(): void { + if (this._carousel) { + this._carousel.reInit(); + } + } + + /** + * Component connected callback. + */ + connectedCallback(): void { + super.connectedCallback(); + this._resizeObserver.observe(this); + } + + /** + * Component disconnected callback. + */ + disconnectedCallback(): void { + this._resizeObserver.disconnect(); + super.disconnectedCallback(); } /** @@ -120,27 +156,26 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { const classes = { embla__slide: true, - 'slide-selected': this.selected == childIndex, + 'slide-selected': this.selected === childIndex, }; - return html` - { - if (this._carousel && this._carousel.clickAllowed()) { - dispatchFrigateCardEvent(this, 'carousel:tap', { - slideIndex: slideIndex, - target: parent, - childIndex: childIndex, - }); - } - stopEventFromActivatingCardWideActions(ev); - }} - > - `; + return html` { + if (this._carousel && this._carousel.clickAllowed()) { + dispatchFrigateCardEvent(this, 'carousel:tap', { + slideIndex: slideIndex, + target: parent, + childIndex: childIndex, + }); + } + stopEventFromActivatingCardWideActions(ev); + }} + > + `; } /** diff --git a/src/scss/carousel.scss b/src/scss/carousel.scss index 46c973c2..32dc9147 100644 --- a/src/scss/carousel.scss +++ b/src/scss/carousel.scss @@ -23,17 +23,15 @@ img,video { width: 100%; height: 100%; - flex-direction: column; - user-select: none; -webkit-touch-callout: none; -khtml-user-select: none; -webkit-tap-highlight-color: transparent; } -:host([direction="vertical"]) .embla__container { +:host([direction=vertical]) .embla__container { flex-direction: column; } -:host([direction="horizontal"]) .embla__container { +:host([direction=horizontal]) .embla__container { flex-direction: row; } @@ -60,10 +58,10 @@ img,video { height: 100%; overflow: visible; } -:host([direction="vertical"]) .embla__slide { +:host([direction=vertical]) .embla__slide { margin-bottom: 5px; } -:host([direction="horizontal"]) .embla__slide { +:host([direction=horizontal]) .embla__slide { margin-right: 5px; } .embla__slide img,video { diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss index 3743303e..96cda137 100644 --- a/src/scss/drawer.scss +++ b/src/scss/drawer.scss @@ -8,6 +8,7 @@ side-drawer { div.control-surround { position: absolute; bottom: 50%; + transform: translateY(50%); z-index: 0; padding-top: $drawer-padding-extend; padding-bottom: $drawer-padding-extend; @@ -29,19 +30,20 @@ div.control-surround { ha-icon.control { color: var(--secondary-color, white); - background-color: rgba(0, 0, 0, 0.6); - opacity: 0.6; + 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 1s ease; + transition: opacity 0.5s ease; } -:host([open]) ha-icon.control { - // When the drawer is open make the button to close it more prominent. +: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; } diff --git a/src/scss/surround.scss b/src/scss/surround.scss index 47236da5..173850ba 100644 --- a/src/scss/surround.scss +++ b/src/scss/surround.scss @@ -6,7 +6,7 @@ display: flex; flex-direction: column; - // So the drawer is relative to this host. + // Set the drawer relative to this host. position: relative; } diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index 07d0eacb..bf20b658 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -1,5 +1,5 @@ :host { - --frigate-card-carousel-thumbnail-opacity: 0.8; + --frigate-card-carousel-thumbnail-opacity: 1.0; } :host([direction=vertical]) { From 40ed9ee3366dcae7baa6f6e4bbda150e4677c853 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 25 Mar 2022 18:05:06 -0700 Subject: [PATCH 032/345] Convert viewer to use the new surround. --- src/components/surround-thumbnails.ts | 10 ++- src/components/thumbnail-carousel.ts | 8 +- src/components/viewer.ts | 110 ++++++-------------------- src/scss/viewer-core.scss | 17 ---- src/scss/viewer.scss | 11 ++- 5 files changed, 41 insertions(+), 115 deletions(-) delete mode 100644 src/scss/viewer-core.scss diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index ca375539..fdb03974 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -21,7 +21,7 @@ import './surround.js'; import surroundThumbnailsStyle from '../scss/surround.scss'; interface FrigateCardThumbnailsSet { - target: FrigateBrowseMediaSource; + target?: FrigateBrowseMediaSource; childIndex?: number; } @@ -93,7 +93,9 @@ export class FrigateCardSurround extends LitElement { return html` ) => { - this._thumbnailTarget = ev.detail.target; + if (ev.detail.target) { + this._thumbnailTarget = ev.detail.target; + } this._thumbnailSelected = ev.detail.childIndex; }} @frigate-card:thumbnails:open=${(ev: CustomEvent) => { @@ -113,8 +115,8 @@ export class FrigateCardSurround extends LitElement { ? html` ) => { this.view ?.evolve({ diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 4fa71dc4..6c28d63c 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -124,11 +124,11 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { } super.updated(changedProperties); - if (changedProperties.has('selected')) { + if (changedProperties.has('_selected')) { this.updateComplete.then(() => { if (this._carousel) { - if (this.selected !== undefined && this.selected !== null) { - this.carouselScrollTo(this.selected); + if (this._selected !== undefined && this._selected !== null) { + this.carouselScrollTo(this._selected); } } }); @@ -156,7 +156,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { const classes = { embla__slide: true, - 'slide-selected': this.selected === childIndex, + 'slide-selected': this._selected === childIndex, }; return html` - `; + ) => { + // When a slide is selected in the viewer carousel, send a new event + // from the same source asking for the thumbnails to be updated. + dispatchFrigateCardEvent(ev.composedPath()[0], 'thumbnails:set', { + childIndex: ev.detail.index, + }); + }} + > + + `; } /** @@ -112,82 +122,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; - - // See note on viewerConfig in . - @property({ attribute: false, hasChanged: contentsChanged }) - protected viewerConfig?: ViewerConfig; - - @property({ attribute: false }) - protected browseMediaQueryParameters?: BrowseMediaQueryParameters; - - @property({ attribute: false }) - protected resolvedMediaCache?: ResolvedMediaCache; - - protected _viewerCarouselRef: Ref = createRef(); - protected _thumbnailCarouselRef: Ref = createRef(); - - protected _syncThumbnailCarousel(): void { - const mediaSelected = this._viewerCarouselRef.value?.carouselSelected(); - if (mediaSelected !== undefined && this._thumbnailCarouselRef.value) { - this._thumbnailCarouselRef.value.selected = mediaSelected; - } - } - - protected _renderThumbnails(): TemplateResult { - if (!this.view || !this.viewerConfig) { - return html``; - } - - return html` ) => { - this._viewerCarouselRef.value?.carouselScrollTo(ev.detail.slideIndex); - }} - @frigate-card:carousel:init=${this._syncThumbnailCarousel.bind(this)} - > - `; - } - - protected render(): TemplateResult | void { - if (!this.view || !this.viewerConfig) { - return html``; - } - return html` ${this.viewerConfig && - this.viewerConfig.controls.thumbnails.mode === 'above' - ? this._renderThumbnails() - : ''} - - - ${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 }) diff --git a/src/scss/viewer-core.scss b/src/scss/viewer-core.scss deleted file mode 100644 index f8822b5a..00000000 --- a/src/scss/viewer-core.scss +++ /dev/null @@ -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); -} \ No newline at end of file diff --git a/src/scss/viewer.scss b/src/scss/viewer.scss index e792d174..19bc1a0b 100644 --- a/src/scss/viewer.scss +++ b/src/scss/viewer.scss @@ -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; +} \ No newline at end of file From b49081c5c8d1d68d0cc424dfae766fa129b73dfa Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Fri, 25 Mar 2022 19:22:44 -0700 Subject: [PATCH 033/345] Add left/right to editor & README. --- README.md | 4 ++-- src/editor.ts | 8 ++++++++ src/localize/languages/en.json | 2 ++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3fe21bee..adddb390 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ 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.| | `media` | `clips` | :white_check_mark: | Whether to show `clips` or `snapshots` in the thumbnail carousel in the `live` view.| @@ -341,7 +341,7 @@ 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.| diff --git a/src/editor.ts b/src/editor.ts index f08a4ca7..85cf2169 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -262,6 +262,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 = [ diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 4d417a97..d10f5c85 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -80,6 +80,8 @@ "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" } }, From a8770ec61a30bd8bf3ac3199a5df85caf0b1d738 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 26 Mar 2022 08:27:30 -0700 Subject: [PATCH 034/345] Ensure drawer content is hidden. --- src/scss/drawer-inject.scss | 6 +++--- src/scss/drawer.scss | 8 ++++---- src/scss/surround.scss | 4 ++++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/scss/drawer-inject.scss b/src/scss/drawer-inject.scss index 92ac13ae..830ca6a8 100644 --- a/src/scss/drawer-inject.scss +++ b/src/scss/drawer-inject.scss @@ -20,19 +20,19 @@ } #d { - // Adding for control, may not need this? + // Need to allow drawer controls to be visible. overflow: visible; visibility: visible; } -:host([location='right']) #d { +:host([location=right]) #d { // Position to the right. left: unset; right: 0; transform: translateX(100%); } -:host([location='right'][open]) #d { +:host([location=right][open]) #d { transform: none; box-shadow: 0px 0px 25px 0px black; } diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss index 96cda137..d69570af 100644 --- a/src/scss/drawer.scss +++ b/src/scss/drawer.scss @@ -13,14 +13,14 @@ div.control-surround { padding-top: $drawer-padding-extend; padding-bottom: $drawer-padding-extend; } -:host([location='left']) div.control-surround { +:host([location=left]) div.control-surround { @if $drawer-icon-size < 32 { // Ensure the clickable area is at least 32px wide. padding-right: calc(32px - $drawer-icon-size); } left: 100%; } -:host([location='right']) div.control-surround { +:host([location=right]) div.control-surround { @if $drawer-icon-size < 32 { // See note above. padding-left: calc(32px - $drawer-icon-size); @@ -48,12 +48,12 @@ ha-icon.control { background-color: black; } -:host([location='left']) ha-icon.control { +:host([location=left]) ha-icon.control { border-top-right-radius: $drawer-icon-size; border-bottom-right-radius: $drawer-icon-size; } -:host([location='right']) ha-icon.control { +:host([location=right]) ha-icon.control { border-top-left-radius: $drawer-icon-size; border-bottom-left-radius: $drawer-icon-size; } diff --git a/src/scss/surround.scss b/src/scss/surround.scss index 173850ba..d6ff998a 100644 --- a/src/scss/surround.scss +++ b/src/scss/surround.scss @@ -8,6 +8,10 @@ // 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]) { From a94f53f8daf0f2d9c32e0cddd4474fedb466b759 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 2 Apr 2022 09:26:34 -0700 Subject: [PATCH 035/345] Maintain the selected range in the view. --- src/components/gallery.ts | 2 +- src/components/surround-thumbnails.ts | 47 +-- src/components/surround.ts | 15 +- src/components/thumbnail-carousel.ts | 31 +- src/components/thumbnail.ts | 51 ++- src/components/timeline.ts | 496 ++++++++++++++++++-------- src/components/viewer.ts | 8 +- src/localize/languages/en.json | 7 +- src/scss/favorite.scss | 9 +- src/types.ts | 4 +- src/view.ts | 18 +- 11 files changed, 464 insertions(+), 224 deletions(-) diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 8f15f4f5..5f4d8137 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -222,7 +222,7 @@ export class FrigateCardGalleryCore extends LitElement { />${child.frigate?.event?.retain_indefinitely ? html`` : ``}` : ``}
diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index fdb03974..3f213bd1 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -20,11 +20,6 @@ import './surround.js'; import surroundThumbnailsStyle from '../scss/surround.scss'; -interface FrigateCardThumbnailsSet { - target?: FrigateBrowseMediaSource; - childIndex?: number; -} - @customElement('frigate-card-surround-thumbnails') export class FrigateCardSurround extends LitElement { @property({ attribute: false }) @@ -42,15 +37,10 @@ export class FrigateCardSurround extends LitElement { @property({ attribute: false }) protected browseMediaParams?: BrowseMediaQueryParameters; - @state() - protected _thumbnailTarget?: FrigateBrowseMediaSource; - - @state() - protected _thumbnailSelected?: number | null; - // 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, ]); @@ -59,15 +49,17 @@ export class FrigateCardSurround extends LitElement { * @param param Task parameters. * @returns */ - protected async _fetchMedia([hass, browseMediaParams]: ( + protected async _fetchMedia([hass, view, browseMediaParams]: ( | (HomeAssistant & ExtendedHomeAssistant) + | Readonly | BrowseMediaQueryParameters | undefined )[]): Promise { hass = hass as HomeAssistant & ExtendedHomeAssistant; + view = view as Readonly; browseMediaParams = browseMediaParams as BrowseMediaQueryParameters; - if (!hass || !browseMediaParams) { + if (!hass || !view || !browseMediaParams) { return; } let parent: FrigateBrowseMediaSource | null; @@ -77,8 +69,13 @@ export class FrigateCardSurround extends LitElement { return dispatchErrorMessageEvent(this, (e as Error).message); } if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) { - this._thumbnailTarget = parent; - this._thumbnailSelected = null; + this.view + ?.evolve({ + ...(this.targetView && { view: this.targetView }), + target: parent, + childIndex: undefined, + }) + .dispatchChangeEvent(this); } } @@ -92,12 +89,6 @@ export class FrigateCardSurround extends LitElement { } return html` ) => { - if (ev.detail.target) { - this._thumbnailTarget = ev.detail.target; - } - this._thumbnailSelected = ev.detail.childIndex; - }} @frigate-card:thumbnails:open=${(ev: CustomEvent) => { if (this.config && ['left', 'right'].includes(this.config.mode)) { // Protects encapsulation: Catches the request to view thumbnails and @@ -110,13 +101,23 @@ export class FrigateCardSurround extends LitElement { }); } }} + @frigate-card:change-view=${(ev) => { + // Close the drawer if the carousel or thumbnail requests a view change + // (e.g. playing the clip, or viewing something on the timeline). + if (this.config && ['left', 'right'].includes(this.config.mode)) { + dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:close', { + drawer: this.config.mode, + }); + } + }} > ${this.config?.mode !== 'none' ? html` ) => { this.view ?.evolve({ diff --git a/src/components/surround.ts b/src/components/surround.ts index e929b95e..586f5191 100644 --- a/src/components/surround.ts +++ b/src/components/surround.ts @@ -16,14 +16,15 @@ interface FrigateCardDrawerOpen { export class FrigateCardSurround extends LitElement { protected _refDrawerLeft: Ref = createRef(); protected _refDrawerRight: Ref = createRef(); - protected _boundDrawerOpenHandler = this._drawerOpen.bind(this); + protected _boundDrawerHandler = this._drawerHandler.bind(this); /** * Component connected callback. */ connectedCallback(): void { super.connectedCallback(); - this.addEventListener('frigate-card:drawer:open', this._boundDrawerOpenHandler); + this.addEventListener('frigate-card:drawer:open', this._boundDrawerHandler); + this.addEventListener('frigate-card:drawer:close', this._boundDrawerHandler); } /** @@ -31,15 +32,17 @@ export class FrigateCardSurround extends LitElement { */ disconnectedCallback(): void { super.disconnectedCallback(); - this.removeEventListener('frigate-card:drawer:open', this._boundDrawerOpenHandler); + this.removeEventListener('frigate-card:drawer:open', this._boundDrawerHandler); + this.removeEventListener('frigate-card:drawer:close', this._boundDrawerHandler); } - protected _drawerOpen(ev: Event) { + protected _drawerHandler(ev: Event) { const drawer = (ev as CustomEvent).detail.drawer; + const open = ev.type.endsWith(':open'); if (drawer === 'left' && this._refDrawerLeft.value) { - this._refDrawerLeft.value.open = true; + this._refDrawerLeft.value.open = open; } else if (drawer === 'right' && this._refDrawerRight.value) { - this._refDrawerRight.value.open = true; + this._refDrawerRight.value.open = open; } } diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 6c28d63c..9be59991 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -4,9 +4,14 @@ import { EmblaOptionsType } from 'embla-carousel'; 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 { View } from '../view.js'; import { contentsChanged, dispatchFrigateCardEvent, @@ -25,6 +30,11 @@ export interface ThumbnailCarouselTap { @customElement('frigate-card-thumbnail-carousel') export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { + @property({ attribute: false }) + protected view?: Readonly; + + // 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; @@ -62,7 +72,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { /** * Handle gallery resize. */ - protected _resizeHandler(): void { + protected _resizeHandler(): void { if (this._carousel) { this._carousel.reInit(); } @@ -71,7 +81,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { /** * Component connected callback. */ - connectedCallback(): void { + connectedCallback(): void { super.connectedCallback(); this._resizeObserver.observe(this); } @@ -145,12 +155,11 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { childIndex: number, slideIndex: number, ): TemplateResult | void { - if (!parent.children || !parent.children.length) { - return; - } - - const mediaToRender = parent.children[childIndex]; - if (!BrowseMediaUtil.isTrueMedia(mediaToRender)) { + if ( + !parent.children || + !parent.children.length || + !BrowseMediaUtil.isTrueMedia(parent.children[childIndex]) + ) { return; } @@ -160,7 +169,9 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { }; return html` ; + + @property({ attribute: false }) + public target?: FrigateBrowseMediaSource; + + @property({ attribute: false }) + public childIndex?: number; @property({ attribute: true, type: Boolean, reflect: true }) public details = false; @@ -26,21 +37,25 @@ export class FrigateCardThumbnail extends LitElement { * @returns A template to display to the user. */ protected render(): TemplateResult | void { - if (!this.media || !this.media.thumbnail) { + if (!this.target || !this.target.children || !this.childIndex) { return; } - const event = this.media.frigate?.event; - return html` - ${event?.retain_indefinitely ? html` ` : ``} ${this.details && event @@ -65,7 +80,21 @@ export class FrigateCardThumbnail extends LitElement {
` : html``} - `; + { + stopEventFromActivatingCardWideActions(ev); + this.view + ?.evolve({ + view: 'timeline', + target: this.target, + childIndex: this.childIndex, + }) + .dispatchChangeEvent(this); + }} + >`; } /** diff --git a/src/components/timeline.ts b/src/components/timeline.ts index f8376a75..f13bfedb 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,3 +1,10 @@ +// TODO: Clips vs snapshots: Should be able to navigate from snapshots view and it should just work. +// TODO: Remove HACK in view.ts on clips +// TODO: Hover over an event should show something useful. +// TODO: Periodically refetch events. +// TODO: Search for TODOs and logging statements. +// TODO: Allow download of selected event in timeline. + import { CSSResultGroup, LitElement, @@ -7,16 +14,21 @@ import { PropertyValues, } from 'lit'; import { DataSet } from 'vis-data/esnext'; -import { HomeAssistant } from 'custom-card-helpers'; import { DataGroupCollectionType, + IdType, Timeline, + TimelineItem, TimelineOptions, TimelineOptionsCluster, + TimelineWindow, } from 'vis-timeline/esnext'; +import { HomeAssistant } from 'custom-card-helpers'; import { classMap } from 'lit/directives/class-map.js'; -import { customElement, property, state } from 'lit/decorators.js'; +import { customElement, property } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; +import { add, fromUnixTime, sub } from 'date-fns'; +import { isEqual } from 'lodash-es'; import { BrowseMediaUtil } from '../browse-media-util'; import { @@ -27,10 +39,10 @@ import { MEDIA_TYPE_VIDEO, MEDIA_CLASS_VIDEO, TimelineConfig, + FrigateEvent, } from '../types'; -import { View } from '../view'; +import { View, ViewContext } from '../view'; import { - contentsChanged, dispatchErrorMessageEvent, dispatchFrigateCardEvent, getCameraTitle, @@ -45,18 +57,26 @@ interface FrigateCardGroupData { id: string; content: string; } -interface FrigateCardTimelineData { - id: string; - content: string; - start: number; - end?: number; +interface FrigateCardTimelineItem extends TimelineItem { source: FrigateBrowseMediaSource; } -class TimelineEventManager { - protected _dataset = new DataSet(); +interface TimelineViewContext extends ViewContext { + window: TimelineWindow; +} - protected _contentCallback?: (FrigateBrowseMediaSource) => string; +/** + * A manager to maintain/fetch timeline events. + */ +class TimelineEventManager { + protected _dataset = new DataSet(); + + // The earliest date managed. + protected _dateStart?: Date; + + // The latest date managed. + protected _dateEnd?: Date; + protected _contentCallback?: (source: FrigateBrowseMediaSource) => string; constructor(params?: { contentCallback?: (source: FrigateBrowseMediaSource) => string; @@ -64,20 +84,35 @@ class TimelineEventManager { this._contentCallback = params?.contentCallback; } - get dataset(): DataSet { + /** + * Retrieve the underlying dataset. + */ + get dataset(): DataSet { return this._dataset; } + /** + * Determine if the dataset is empty. + * @returns + */ public isEmpty(): boolean { return this._dataset.length === 0; } + /** + * Clear the dataset. + */ public clear(): void { this._dataset.clear(); } + /** + * Add a FrigateBrowseMediaSource object to the managed timeline. + * @param camera The id the camera this object is from. + * @param target The FrigateBrowseMediaSource to add. + */ protected _addMediaSource(camera: string, target: FrigateBrowseMediaSource): void { - const items: FrigateCardTimelineData[] = []; + const items: FrigateCardTimelineItem[] = []; target.children?.forEach((child) => { if (child.frigate) { const item = { @@ -99,19 +134,68 @@ class TimelineEventManager { this._dataset.update(items); } - public async fetchEvents( - node: HTMLElement, + /** + * Determine if the timeline has coverage for a given range of dates. + * @param start The start of the date range. + * @param end An optional end of the date range. + * @returns + */ + public hasCoverage(start: Date, end?: Date): boolean { + return ( + !!this._dateStart && + start >= this._dateStart && + (!end || (!!this._dateEnd && end <= this._dateEnd)) + ); + } + + /** + * Fetch events if no coverage in given range. + * @param element The element to send error events from. + * @param hass The HomeAssistant object. + * @param cameras The cameras map. + * @param start Fetch events that start later than this date. + * @param end Fetch events that start earlier than this date. + * @returns `true` if events were fetched, `false` otherwise. + */ + public async fetchEventsIfNecessary( + element: HTMLElement, + hass: HomeAssistant & ExtendedHomeAssistant, + cameras: Map, + start: Date, + end: Date, + ): Promise { + if (!this.hasCoverage(start, end)) { + await this._fetchEvents(element, hass, cameras, start, end); + return true; + } + return false; + } + + /** + * Fetch events for the timeline. + * @param element The element to send error events from. + * @param hass The HomeAssistant object. + * @param cameras The cameras map. + * @param start Fetch events that start later than this date. + * @param end Fetch events that start earlier than this date. + */ + protected async _fetchEvents( + element: HTMLElement, hass: HomeAssistant & ExtendedHomeAssistant, cameras: Map, start: Date, end: Date, ): Promise { - console.info(`fetchEvents: ${start} -> ${end}`); + if (!this._dateStart || start < this._dateStart) { + this._dateStart = start; + } + if (!this._dateEnd || end > this._dateEnd) { + this._dateEnd = end; + } - // const output = new Map(); const fetchCameraEvents = async (camera: string): Promise => { const cameraConfig = cameras.get(camera); - if (!cameraConfig) { + if (!cameraConfig || !this._dateStart || !this._dateEnd) { return; } const browseMediaQueryParameters = BrowseMediaUtil.getBrowseMediaQueryParameters( @@ -127,11 +211,17 @@ class TimelineEventManager { camera, await BrowseMediaUtil.browseMediaQuery(hass, { ...browseMediaQueryParameters, + + // Events are always fetched for the maximum extent of the managed + // range. This is because events may change at any point in time + // (e.g. a long-running event that ends). + before: this._dateEnd.getTime() / 1000, + after: this._dateStart.getTime() / 1000, unlimited: true, }), ); } catch (e) { - return dispatchErrorMessageEvent(node, (e as Error).message); + return dispatchErrorMessageEvent(element, (e as Error).message); } }; @@ -197,35 +287,24 @@ export class FrigateCardTimelineCore extends LitElement { @property({ attribute: false }) protected cameras?: Map; - /** - * Set the timeline configuration. - */ - set timelineConfig(timelineConfig: TimelineConfig) { - this._timelineConfig = timelineConfig; - this._setOptions(); - } - - @state() - protected _timelineConfig?: TimelineConfig; - - @state({ hasChanged: contentsChanged }) - protected _timelineOptions?: TimelineOptions; - - protected _timelineRef: Ref = createRef(); - protected _timeline?: Timeline; + @property({ attribute: false }) + protected timelineConfig?: TimelineConfig; protected _events = new TimelineEventManager(); + protected _refTimeline: Ref = createRef(); + protected _thumbnails?: FrigateBrowseMediaSource; + protected _timeline?: Timeline; /** * Master render method. * @returns A rendered template. */ protected render(): TemplateResult | void { - if (!this.hass || !this.view || !this._timelineConfig) { + if (!this.hass || !this.view || !this.timelineConfig) { return; } - const thumbnailsConfig = this._timelineConfig.controls.thumbnails; + const thumbnailsConfig = this.timelineConfig.controls.thumbnails; const timelineClasses = { timeline: true, 'left-margin': thumbnailsConfig.mode === 'left', @@ -234,7 +313,7 @@ export class FrigateCardTimelineCore extends LitElement { return html`
`; } @@ -251,74 +330,87 @@ export class FrigateCardTimelineCore extends LitElement { console.info( `Range changed: ${properties.start} -> ${properties.end} [${this._events.dataset.length}]`, ); - if (this.hass && this.cameras) { - // This is not performant in that it refetches all events in the time - // range, when some/all may already be fetched. A more optimal approach - // would be to only fetch events in time windows that haven't already been - // fetched PLUS events that did not previously have an end_time. That's - // not trivial to implement, and it's not yet clear it's worth the extra - // complexity. + if (this.hass && this.cameras && this._timeline) { this._events - .fetchEvents(this, this.hass, this.cameras, properties.start, properties.end) - .then(() => { - this._updateThumbnails(); + .fetchEventsIfNecessary( + this, + this.hass, + this.cameras, + properties.start, + properties.end, + ) + .then((fetched: boolean) => { + if (fetched) { + this._generateThumbnails(); + } }); + + // Update the view to ensure that future view changes do not cause a + // scroll. + this.view + ?.evolve({ + context: { + window: this._timeline.getWindow(), + }, + }) + .dispatchChangeEvent(this); } } /** * Called when an object on the timeline is selected. - * @param _data The data about the selection. + * @param data The data about the selection. * @returns */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - protected _timelineSelectHandler(_data: { items: string[]; event: Event }): void { - this._updateThumbnails(); - dispatchFrigateCardEvent(this, 'thumbnails:open'); + protected _timelineSelectHandler(data: { items: string[]; event: Event }): void { + if (!this._thumbnails || !this._thumbnails.children || data.items.length <= 0) { + return; + } + const childIndex = this._findThumbnailIndex(data.items[0]); + if (childIndex >= 0) { + this.view + ?.evolve({ + target: this._thumbnails, + childIndex: childIndex, + }) + .dispatchChangeEvent(this); + dispatchFrigateCardEvent(this, 'thumbnails:open'); + } } - protected _updateThumbnails(): void { + /** + * Find the index of the given item in the thumbnails. + * @param id + * @returns The index of the item, or -1 if not found. + */ + public _findThumbnailIndex(id: IdType | IdType[]): number { + if (!this._thumbnails || !this._thumbnails.children) { + return -1; + } + id = Array.isArray(id) ? id[0] : id; + return this._thumbnails.children.findIndex((child) => child.media_content_id === id); + } + + /** + * Regenerate the thumbnails from the timeline events. + * @returns + */ + protected _generateThumbnails(): void { if (!this._timeline) { return; } - const selected = this._timeline?.getSelection(); - - const timelineWindow = this._timeline.getWindow(); - const start = timelineWindow.start.getTime(); - const end = timelineWindow.end.getTime(); - - const children: FrigateBrowseMediaSource[] = []; - let childIndex: number | null = null; - - // Fetch all the events that match the extent of the visible window (cannot - // use getVisibleItems() since it does not return clustered items). - this._events.dataset - .get({ - filter: (item) => - // Start within the window. - (item.start >= start && item.start <= end) || - // End within the window. - (!!item.end && item.end >= start && item.end <= end) || - // Item lifetime extends past the window - (item.start <= start && !!item.end && item.end >= end), - order: 'start', - }) - .forEach((item) => { - if (item.source.can_play) { - if (childIndex === null && selected.includes(item.id)) { - childIndex = children.length; - } - children.push(item.source); - } - }); - + const children: FrigateBrowseMediaSource[] = this._events.dataset + .get() + .filter((item) => BrowseMediaUtil.isTrueMedia(item.source)) + .map((item) => item.source); if (!children.length) { return; } const target = { - title: `Timeline ${start} - ${end}`, + title: `Timeline events`, media_class: MEDIA_CLASS_PLAYLIST, media_content_type: MEDIA_TYPE_VIDEO, media_content_id: '', @@ -329,10 +421,16 @@ export class FrigateCardTimelineCore extends LitElement { children: children, }; - dispatchFrigateCardEvent(this, 'thumbnails:set', { - target: target, - childIndex: childIndex ?? undefined, - }); + this._thumbnails = target; + const childIndex = this._findThumbnailIndex(this._timeline.getSelection()); + + // Update the thumbnail carousel with the regenerated thumbnails. + this.view + ?.evolve({ + target: this._thumbnails, + childIndex: childIndex < 0 ? undefined : childIndex, + }) + .dispatchChangeEvent(this); } /** @@ -350,39 +448,97 @@ export class FrigateCardTimelineCore extends LitElement { return new DataSet(groups); } + /** + * Given an event get an appropriate start/end time window around the event. + * @param event The FrigateEvent to consider. + * @returns A tuple of start/end date. + */ + protected _getStartEndFromEvent(event: FrigateEvent): [Date, Date] { + const one_hour = { hours: 1 }; + const start = sub(fromUnixTime(event.start_time), one_hour); + let end: Date; + + if (event.end_time) { + end = add(fromUnixTime(event.end_time), one_hour); + } else { + end = add(start, one_hour); + } + return [start, end]; + } + + /** + * Get desired timeline start/end time. + * @returns A tuple of start/end date. + */ + protected _getStartEnd(): [Date, Date] { + const event = this.view?.target?.frigate?.event; + if (event) { + return this._getStartEndFromEvent(event); + } + const one_hour = { hours: 1 }; + const end = new Date(); + const start = sub(end, one_hour); + return [start, end]; + } + + /** + * Determine if the timeline should use clustering. + * @returns `true` if the timeline should cluster, `false` otherwise. + */ + protected _isClustering(): boolean { + return ( + !!this.timelineConfig?.clustering_threshold && + this.timelineConfig.clustering_threshold > 0 + ); + } + /** * Handle timeline resize. */ - protected _setOptions(): void { - if (!this._timelineConfig) { + protected _getOptions(): TimelineOptions | void { + if (!this.timelineConfig) { return; } + const [start, end] = this._getStartEnd(); + // Configuration for the Timeline, see: // https://visjs.github.io/vis-timeline/docs/timeline/#Configuration_Options - this._timelineOptions = { - cluster: - this._timelineConfig.clustering_threshold > 0 - ? { - showStipes: true, - // It would be better to automatically calculate `maxItems` from the - // rendered height of the timeline (or group within the timeline) so - // as to not waste vertical space (e.g. after the user changes to - // fullscreen mode). Unfortunately this is not easy to do, as we - // don't know the height of the timeline until after it renders -- - // and if we adjust `maxItems` then we can get into an infinite - // resize loop. Adjusting the `maxItems` of a timeline, after it's - // created, also does not appear to work as expected. - maxItems: this._timelineConfig.clustering_threshold, - } - : (false as TimelineOptionsCluster), + return { + cluster: this._isClustering() + ? { + showStipes: true, + // It would be better to automatically calculate `maxItems` from the + // rendered height of the timeline (or group within the timeline) so + // as to not waste vertical space (e.g. after the user changes to + // fullscreen mode). Unfortunately this is not easy to do, as we + // don't know the height of the timeline until after it renders -- + // and if we adjust `maxItems` then we can get into an infinite + // resize loop. Adjusting the `maxItems` of a timeline, after it's + // created, also does not appear to work as expected. + maxItems: this.timelineConfig.clustering_threshold, + + clusterCriteria: (first: TimelineItem, second: TimelineItem): boolean => { + // Never include the target media in a cluster, and never group + // different object types together (e.g. person and car). + return ( + !!first.id && + first.id !== this.view?.media?.media_content_id && + !!second.id && + second.id != this.view?.media?.media_content_id && + (first).source.frigate?.event.label === + (second).source.frigate?.event.label + ); + }, + } + : (false as TimelineOptionsCluster), minHeight: '100%', maxHeight: '100%', zoomMax: 31 * 24 * 60 * 60 * 1000, zoomMin: 1 * 1000, selectable: true, - start: this._getYesterday(), - end: this._getToday(), + start: start, + end: end, groupHeightMode: 'fixed', xss: { disabled: false, @@ -402,24 +558,6 @@ export class FrigateCardTimelineCore extends LitElement { }; } - /** - * Get today date object. - * @returns A date object for today. - */ - protected _getToday(): Date { - return new Date(); - } - - /** - * Get yesterday date object. - * @returns A date object for yesterday. - */ - protected _getYesterday(): Date { - const yesterday = new Date(); - yesterday.setDate(this._getToday().getDate() - 1); - return yesterday; - } - /** * Determine if the component should be updated. * @param _changedProps The changed properties. @@ -431,25 +569,63 @@ export class FrigateCardTimelineCore extends LitElement { } /** - * Called on the first update. - * @param changedProps The changed properties. + * Update the timeline from the view object. */ - protected firstUpdated(changedProps: PropertyValues): void { - super.firstUpdated(changedProps); + protected async _updateTimelineFromView(): Promise { + const event = this.view?.media?.frigate?.event; + const id = this.view?.media?.media_content_id; - if (changedProps.has('cameras')) { - this._events.clear(); + if (!this.hass || !this.cameras || !this.view || !event || !id || !this._timeline) { + return; } - if (this._events.isEmpty() && this.hass && this.cameras) { - // Fetch an initial 1-day worth of events. - this._events.fetchEvents( - this, - this.hass, - this.cameras, - this._getToday(), - this._getYesterday(), + const [eventWindowStart, eventWindowEnd] = this._getStartEndFromEvent(event); + await this._events.fetchEventsIfNecessary( + this, + this.hass, + this.cameras, + eventWindowStart, + eventWindowEnd, + ); + + const eventStart = new Date(event.start_time * 1000); + const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0; + + this._timeline.setSelection([id], { + focus: false, + animation: { + animation: false, + zoom: false, + }, + }); + + const timelineWindow = this._timeline.getWindow(); + const context = this.view.context + ? (this.view.context as TimelineViewContext) + : undefined; + + if (context && !isEqual(context.window, timelineWindow)) { + console.info( + `Setting window from context (${context.window.start} -> ${context.window.end}`, ); + this._timeline.setWindow(context.window.start, context.window.end); + } else if ( + eventStart < timelineWindow.start || + eventStart > timelineWindow.end || + (eventEnd && (eventEnd < timelineWindow.start || eventEnd > timelineWindow.end)) + ) { + console.info(`Setting window from event ${eventWindowStart} -> ${eventWindowEnd}`); + this._timeline.setWindow(eventWindowStart, eventWindowEnd); + } + + if (this._isClustering()) { + // Hack: Clustering may not update unless the dataset changes, artifically + // update the dataset to ensure the newly selected item cannot be included + // in a cluster. + const item = this._events.dataset.get(id); + if (item) { + this._events.dataset.updateOnly(item); + } } } @@ -460,20 +636,30 @@ export class FrigateCardTimelineCore extends LitElement { protected updated(changedProperties: PropertyValues): void { super.updated(changedProperties); - if (this._timelineRef.value) { - if (this._timeline) { - this._timeline.destroy(); - this._timeline = undefined; - } + if (changedProperties.has('cameras')) { + this._events.clear(); + this._timeline?.destroy(); + this._timeline = undefined; + } - this._timeline = new Timeline( - this._timelineRef.value, - this._events.dataset, - this._getGroups(), - this._timelineOptions, - ); - this._timeline.on('select', this._timelineSelectHandler.bind(this)); - this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this)); + const options = this._getOptions(); + if (changedProperties.has('timelineConfig') && this._refTimeline.value && options) { + if (this._timeline) { + // TODO this._timeline.setOptions(options); + } else { + this._timeline = new Timeline( + this._refTimeline.value, + this._events.dataset, + this._getGroups(), + options, + ); + this._timeline.on('select', this._timelineSelectHandler.bind(this)); + this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this)); + } + } + + if (changedProperties.has('view')) { + this._updateTimelineFromView(); } } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index f781ebb2..75d5f888 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -95,6 +95,7 @@ export class FrigateCardViewer extends LitElement { .hass=${this.hass} .view=${this.view} .config=${this.viewerConfig.controls.thumbnails} + .browseMediaParams=${browseMediaQueryParameters} > ) => { - // When a slide is selected in the viewer carousel, send a new event - // from the same source asking for the thumbnails to be updated. - dispatchFrigateCardEvent(ev.composedPath()[0], 'thumbnails:set', { - childIndex: ev.detail.index, - }); - }} > `; diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index d10f5c85..cc5092c9 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -213,8 +213,11 @@ "event": { "start": "Start", "duration": "Duration", - "in_progress": "In Progress", - "retain_indefinitely": "Event will be indefinitely retained" + "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", diff --git a/src/scss/favorite.scss b/src/scss/favorite.scss index c89adc2b..993753c4 100644 --- a/src/scss/favorite.scss +++ b/src/scss/favorite.scss @@ -2,4 +2,11 @@ ha-icon.favorite { position: absolute; color: var(--primary-color); padding: 2px; -} \ No newline at end of file +} + +ha-icon.timeline { + position: absolute; + color: var(--primary-color); + padding: 2px; + right: 0px; +} diff --git a/src/types.ts b/src/types.ts index 3cd65d15..7d41e039 100644 --- a/src/types.ts +++ b/src/types.ts @@ -335,8 +335,8 @@ export type MenuStateIcon = z.infer; 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; diff --git a/src/view.ts b/src/view.ts index f16c9341..e4030e18 100644 --- a/src/view.ts +++ b/src/view.ts @@ -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; + context?: ViewContext; } export interface ViewParameters extends ViewEvolveParameters { @@ -20,6 +24,7 @@ export class View { target?: FrigateBrowseMediaSource; childIndex?: number; previous?: View; + context?: ViewContext; constructor(params: ViewParameters) { this.view = params?.view; @@ -27,6 +32,7 @@ export class View { this.target = params?.target; this.childIndex = params?.childIndex; this.previous = params?.previous; + this.context = params?.context; } /** @@ -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, }); } @@ -54,7 +61,8 @@ export class View { target: params.target ?? this.target, childIndex: params.childIndex ?? this.childIndex, previous: params.previous ?? this.previous, - }) + context: params.context ?? this.context, + }); } /** @@ -82,16 +90,14 @@ export class View { * Determine if a view is for the media viewer. */ public isViewerView(): boolean { - return ['clip', 'snapshot'].includes( - this.view, - ); + return ['clip', 'snapshot'].includes(this.view); } /** * Determine if a view is related to a clip or clips. */ public isClipRelatedView(): boolean { - // TODO HACK HACK HACK + // TODO HACK HACK HACK return ['clip', 'clips', 'timeline'].includes(this.view); } From 5b66c6cad66db88b9484616b1324eda8cd999da5 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 2 Apr 2022 10:58:02 -0700 Subject: [PATCH 036/345] Fix timeline recentering after manual scroll --- src/components/thumbnail.ts | 1 + src/components/timeline.ts | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index b802d673..b2813089 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -91,6 +91,7 @@ export class FrigateCardThumbnail extends LitElement { view: 'timeline', target: this.target, childIndex: this.childIndex, + context: {}, }) .dispatchChangeEvent(this); }} diff --git a/src/components/timeline.ts b/src/components/timeline.ts index f13bfedb..db9971db 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -604,11 +604,13 @@ export class FrigateCardTimelineCore extends LitElement { ? (this.view.context as TimelineViewContext) : undefined; - if (context && !isEqual(context.window, timelineWindow)) { + if (context?.window) { console.info( `Setting window from context (${context.window.start} -> ${context.window.end}`, ); - this._timeline.setWindow(context.window.start, context.window.end); + if (!isEqual(context.window, timelineWindow)) { + this._timeline.setWindow(context.window.start, context.window.end); + } } else if ( eventStart < timelineWindow.start || eventStart > timelineWindow.end || From f98d749d07a84614a79b0fdaf473b9216e209bea Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 2 Apr 2022 14:28:18 -0700 Subject: [PATCH 037/345] Fix issue causing first thumbnail to be empty. --- src/components/surround-thumbnails.ts | 18 +++++++++--------- src/components/thumbnail-carousel.ts | 2 ++ src/components/thumbnail.ts | 2 +- src/components/timeline.ts | 26 +++++++++++++++----------- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 3f213bd1..53e3ff44 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -101,15 +101,6 @@ export class FrigateCardSurround extends LitElement { }); } }} - @frigate-card:change-view=${(ev) => { - // Close the drawer if the carousel or thumbnail requests a view change - // (e.g. playing the clip, or viewing something on the timeline). - if (this.config && ['left', 'right'].includes(this.config.mode)) { - dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:close', { - drawer: this.config.mode, - }); - } - }} > ${this.config?.mode !== 'none' ? html` { + // Close the drawer if the carousel or thumbnail requests a view change + // (e.g. playing the clip, or viewing something on the timeline). + if (this.config && ['left', 'right'].includes(this.config.mode)) { + dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:close', { + drawer: this.config.mode, + }); + } + }} @frigate-card:carousel:tap=${(ev: CustomEvent) => { this.view ?.evolve({ diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 9be59991..556d56a8 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -102,6 +102,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { return { containScroll: 'keepSnaps', dragFree: true, + startIndex: this._selected ?? 0, }; } @@ -138,6 +139,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { this.updateComplete.then(() => { if (this._carousel) { if (this._selected !== undefined && this._selected !== null) { + console.info('thumbnail carousel scroll to') this.carouselScrollTo(this._selected); } } diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index b2813089..2f870ffe 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -37,7 +37,7 @@ export class FrigateCardThumbnail extends LitElement { * @returns A template to display to the user. */ protected render(): TemplateResult | void { - if (!this.target || !this.target.children || !this.childIndex) { + if (!this.target || !this.target.children || this.childIndex === undefined) { return; } const media = this.target.children[this.childIndex]; diff --git a/src/components/timeline.ts b/src/components/timeline.ts index db9971db..34e586d4 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -164,11 +164,11 @@ class TimelineEventManager { start: Date, end: Date, ): Promise { - if (!this.hasCoverage(start, end)) { - await this._fetchEvents(element, hass, cameras, start, end); - return true; + if (this.hasCoverage(start, end)) { + return false; } - return false; + await this._fetchEvents(element, hass, cameras, start, end); + return true; } /** @@ -580,13 +580,17 @@ export class FrigateCardTimelineCore extends LitElement { } const [eventWindowStart, eventWindowEnd] = this._getStartEndFromEvent(event); - await this._events.fetchEventsIfNecessary( - this, - this.hass, - this.cameras, - eventWindowStart, - eventWindowEnd, - ); + if ( + await this._events.fetchEventsIfNecessary( + this, + this.hass, + this.cameras, + eventWindowStart, + eventWindowEnd, + ) + ) { + this._generateThumbnails(); + } const eventStart = new Date(event.start_time * 1000); const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0; From 9c372815f25ec789f63ea72741c0c6feb2cfc01a Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Mon, 4 Apr 2022 21:25:12 -0700 Subject: [PATCH 038/345] Add support for both snapshots/clips in timeline. --- src/browse-media-util.ts | 49 +++++---- src/card.ts | 6 +- src/components/gallery.ts | 55 +++++----- src/components/live.ts | 22 ++-- src/components/surround-thumbnails.ts | 7 +- src/components/timeline.ts | 146 ++++++++++++++++---------- src/components/viewer.ts | 51 +++++---- src/scss/live.scss | 4 - src/types.ts | 22 +++- src/view.ts | 7 +- 10 files changed, 212 insertions(+), 157 deletions(-) diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index 1fbfe1d6..a3d647d2 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 | null { + ): BrowseMediaQueryParametersBase | null { if (!cameraConfig || !cameraConfig.camera_name) { return null; } return { - mediaType: mediaType, clientId: cameraConfig.client_id, cameraName: cameraConfig.camera_name, label: cameraConfig.label, @@ -129,20 +128,37 @@ 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 | null { - if (!view.isClipRelatedView() && !view.isSnapshotRelatedView()) { - return null; - } - - // 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( @@ -152,10 +168,7 @@ export class BrowseMediaUtil { return null; } - return BrowseMediaUtil.getBrowseMediaQueryParameters( - view.isClipRelatedView() ? 'clips' : 'snapshots', - cameraConfig, - ); + return BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig); } /** diff --git a/src/card.ts b/src/card.ts index 0b3f8cae..5d72ba87 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1089,13 +1089,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 ); } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 5f4d8137..c2df5dac 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -38,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; } @@ -203,27 +204,29 @@ export class FrigateCardGalleryCore extends LitElement { ` : child.thumbnail ? html` { - 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`` : ``}` + 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, + previous: this.view, + }) + .dispatchChangeEvent(this); + } + stopEventFromActivatingCardWideActions(ev); + }} + />${child.frigate?.event?.retain_indefinitely + ? html`` + : ``}` : ``} `, diff --git a/src/components/live.ts b/src/components/live.ts index 62be17ff..7285fbd2 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -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. @@ -135,13 +127,16 @@ export class FrigateCardLive extends LitElement { this.conditionState, ) as LiveConfig; - const browseMediaParams = BrowseMediaUtil.getBrowseMediaQueryParameters( - config.controls.thumbnails.media, + const browseMediaParamsBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase( this.cameras.get(this.view.camera), ); - if (!browseMediaParams) { + 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 @@ -150,7 +145,6 @@ export class FrigateCardLive extends LitElement { .hass=${this.hass} .view=${this.view} .config=${config.controls.thumbnails} - .targetView=${config.controls.thumbnails.media == 'clips' ? 'clip' : 'snapshot'} .browseMediaParams=${browseMediaParams} > ) => { this.view ?.evolve({ - ...(this.targetView && { view: this.targetView }), + view: this.targetView || 'event', target: ev.detail.target, childIndex: ev.detail.childIndex, }) diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 34e586d4..c4f0aa6d 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,5 +1,4 @@ // TODO: Clips vs snapshots: Should be able to navigate from snapshots view and it should just work. -// TODO: Remove HACK in view.ts on clips // TODO: Hover over an event should show something useful. // TODO: Periodically refetch events. // TODO: Search for TODOs and logging statements. @@ -16,7 +15,6 @@ import { import { DataSet } from 'vis-data/esnext'; import { DataGroupCollectionType, - IdType, Timeline, TimelineItem, TimelineOptions, @@ -36,8 +34,7 @@ import { ExtendedHomeAssistant, FrigateBrowseMediaSource, MEDIA_CLASS_PLAYLIST, - MEDIA_TYPE_VIDEO, - MEDIA_CLASS_VIDEO, + MEDIA_TYPE_PLAYLIST, TimelineConfig, FrigateEvent, } from '../types'; @@ -58,7 +55,9 @@ interface FrigateCardGroupData { content: string; } interface FrigateCardTimelineItem extends TimelineItem { - source: FrigateBrowseMediaSource; + event: FrigateEvent; + clip?: FrigateBrowseMediaSource; + snapshot?: FrigateBrowseMediaSource; } interface TimelineViewContext extends ViewContext { @@ -114,20 +113,29 @@ class TimelineEventManager { protected _addMediaSource(camera: string, target: FrigateBrowseMediaSource): void { const items: FrigateCardTimelineItem[] = []; target.children?.forEach((child) => { - if (child.frigate) { - const item = { - id: child.media_content_id, - group: camera, - content: this._contentCallback?.(child) ?? '', - start: child.frigate.event.start_time * 1000, - source: child, - }; - if (child.frigate.event.end_time) { - item['end'] = child.frigate.event.end_time * 1000; + const event = child.frigate?.event; + if (event && ['video', 'image'].includes(child.media_content_type)) { + let item = this._dataset.get(event.id); + if (!item) { + item = { + id: event.id, + group: camera, + content: this._contentCallback?.(child) ?? '', + start: event.start_time * 1000, + event: event, + }; + } + if (event.end_time) { + item['end'] = event.end_time * 1000; item['type'] = 'range'; } else { item['type'] = 'point'; } + if (child.media_content_type === 'video') { + item['clip'] = child; + } else if (child.media_content_type === 'image') { + item['snapshot'] = child; + } items.push(item); } }); @@ -161,13 +169,14 @@ class TimelineEventManager { element: HTMLElement, hass: HomeAssistant & ExtendedHomeAssistant, cameras: Map, + media: 'all' | 'clips' | 'snapshots', start: Date, end: Date, ): Promise { if (this.hasCoverage(start, end)) { return false; } - await this._fetchEvents(element, hass, cameras, start, end); + await this._fetchEvents(element, hass, cameras, media, start, end); return true; } @@ -183,6 +192,7 @@ class TimelineEventManager { element: HTMLElement, hass: HomeAssistant & ExtendedHomeAssistant, cameras: Map, + media: 'all' | 'clips' | 'snapshots', start: Date, end: Date, ): Promise { @@ -193,24 +203,25 @@ class TimelineEventManager { this._dateEnd = end; } - const fetchCameraEvents = async (camera: string): Promise => { + const fetchCameraEvents = async ( + camera: string, + mediaType: 'clips' | 'snapshots', + ): Promise => { const cameraConfig = cameras.get(camera); if (!cameraConfig || !this._dateStart || !this._dateEnd) { return; } - const browseMediaQueryParameters = BrowseMediaUtil.getBrowseMediaQueryParameters( - 'clips', + const browseMediaQueryParametersBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase( cameraConfig, ); - if (!browseMediaQueryParameters) { + if (!browseMediaQueryParametersBase) { return; } - try { this._addMediaSource( camera, await BrowseMediaUtil.browseMediaQuery(hass, { - ...browseMediaQueryParameters, + ...browseMediaQueryParametersBase, // Events are always fetched for the maximum extent of the managed // range. This is because events may change at any point in time @@ -218,6 +229,7 @@ class TimelineEventManager { before: this._dateEnd.getTime() / 1000, after: this._dateStart.getTime() / 1000, unlimited: true, + mediaType: mediaType, }), ); } catch (e) { @@ -225,7 +237,15 @@ class TimelineEventManager { } }; - await Promise.all(Array.from(cameras.keys()).map(fetchCameraEvents.bind(this))); + const promises: Promise[] = []; + (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => + promises.push( + ...Array.from(cameras.keys()).map((camera) => + fetchCameraEvents(camera, mediaType as 'clips' | 'snapshots'), + ), + ), + ); + await Promise.all(promises); } } @@ -256,7 +276,6 @@ export class FrigateCardTimeline extends LitElement { .hass=${this.hass} .view=${this.view} .config=${this.timelineConfig.controls.thumbnails} - .targetView=${'clip'} > ${properties.end} [${this._events.dataset.length}]`, ); - if (this.hass && this.cameras && this._timeline) { + if (this.hass && this.cameras && this._timeline && this.timelineConfig) { this._events .fetchEventsIfNecessary( this, this.hass, this.cameras, + this.timelineConfig.media, properties.start, properties.end, ) @@ -367,7 +387,9 @@ export class FrigateCardTimelineCore extends LitElement { if (!this._thumbnails || !this._thumbnails.children || data.items.length <= 0) { return; } - const childIndex = this._findThumbnailIndex(data.items[0]); + const childIndex = this._thumbnails.children.findIndex( + (child) => child.frigate?.event.id === data.items[0], + ); if (childIndex >= 0) { this.view ?.evolve({ @@ -379,19 +401,6 @@ export class FrigateCardTimelineCore extends LitElement { } } - /** - * Find the index of the given item in the thumbnails. - * @param id - * @returns The index of the item, or -1 if not found. - */ - public _findThumbnailIndex(id: IdType | IdType[]): number { - if (!this._thumbnails || !this._thumbnails.children) { - return -1; - } - id = Array.isArray(id) ? id[0] : id; - return this._thumbnails.children.findIndex((child) => child.media_content_id === id); - } - /** * Regenerate the thumbnails from the timeline events. * @returns @@ -401,10 +410,33 @@ export class FrigateCardTimelineCore extends LitElement { return; } - const children: FrigateBrowseMediaSource[] = this._events.dataset - .get() - .filter((item) => BrowseMediaUtil.isTrueMedia(item.source)) - .map((item) => item.source); + const selected = this._timeline.getSelection(); + let childIndex = -1; + const children: FrigateBrowseMediaSource[] = []; + this._events.dataset.get().forEach((item) => { + if (this.timelineConfig) { + let added = false; + if ( + item.clip && + ['all', 'clips'].includes(this.timelineConfig.media) && + BrowseMediaUtil.isTrueMedia(item.clip) + ) { + added = true; + children.push(item.clip); + } else if ( + item.snapshot && + ['all', 'snapshots'].includes(this.timelineConfig.media) && + BrowseMediaUtil.isTrueMedia(item.snapshot) + ) { + added = true + children.push(item.snapshot); + } + + if (added && selected.includes(item.event.id)) { + childIndex = children.length-1; + } + } + }); if (!children.length) { return; } @@ -412,17 +444,16 @@ export class FrigateCardTimelineCore extends LitElement { const target = { title: `Timeline events`, media_class: MEDIA_CLASS_PLAYLIST, - media_content_type: MEDIA_TYPE_VIDEO, + media_content_type: MEDIA_TYPE_PLAYLIST, media_content_id: '', can_play: false, can_expand: true, - children_media_class: MEDIA_CLASS_VIDEO, + children_media_class: MEDIA_CLASS_PLAYLIST, thumbnail: null, children: children, }; this._thumbnails = target; - const childIndex = this._findThumbnailIndex(this._timeline.getSelection()); // Update the thumbnail carousel with the regenerated thumbnails. this.view @@ -523,11 +554,11 @@ export class FrigateCardTimelineCore extends LitElement { // different object types together (e.g. person and car). return ( !!first.id && - first.id !== this.view?.media?.media_content_id && + first.id !== this.view?.media?.frigate?.event?.id && !!second.id && - second.id != this.view?.media?.media_content_id && - (first).source.frigate?.event.label === - (second).source.frigate?.event.label + second.id != this.view?.media?.frigate?.event?.id && + (first).event.label === + (second).event.label ); }, } @@ -573,9 +604,15 @@ export class FrigateCardTimelineCore extends LitElement { */ protected async _updateTimelineFromView(): Promise { const event = this.view?.media?.frigate?.event; - const id = this.view?.media?.media_content_id; - if (!this.hass || !this.cameras || !this.view || !event || !id || !this._timeline) { + if ( + !this.hass || + !this.cameras || + !this.view || + !event || + !this._timeline || + !this.timelineConfig + ) { return; } @@ -585,6 +622,7 @@ export class FrigateCardTimelineCore extends LitElement { this, this.hass, this.cameras, + this.timelineConfig.media, eventWindowStart, eventWindowEnd, ) @@ -595,7 +633,7 @@ export class FrigateCardTimelineCore extends LitElement { const eventStart = new Date(event.start_time * 1000); const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0; - this._timeline.setSelection([id], { + this._timeline.setSelection([event.id], { focus: false, animation: { animation: false, @@ -628,7 +666,7 @@ export class FrigateCardTimelineCore extends LitElement { // Hack: Clustering may not update unless the dataset changes, artifically // update the dataset to ensure the newly selected item cannot be included // in a cluster. - const item = this._events.dataset.get(id); + const item = this._events.dataset.get(event.id); if (item) { this._events.dataset.updateOnly(item); } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 75d5f888..90de4107 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -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, @@ -25,7 +25,6 @@ import type { TransitionEffect, ViewerConfig, } from '../types.js'; -import { CarouselSelect } from './carousel.js'; import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; @@ -35,7 +34,6 @@ import { contentsChanged, createMediaShowInfo, dispatchErrorMessageEvent, - dispatchFrigateCardEvent, stopEventFromActivatingCardWideActions, } from '../common.js'; import { renderProgressIndicator } from '../components/message.js'; @@ -71,17 +69,21 @@ export class FrigateCardViewer extends LitElement { 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, @@ -95,13 +97,12 @@ export class FrigateCardViewer extends LitElement { .hass=${this.hass} .view=${this.view} .config=${this.viewerConfig.controls.thumbnails} - .browseMediaParams=${browseMediaQueryParameters} > @@ -133,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; @@ -267,16 +268,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { ? 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, + }), ]; } @@ -338,7 +334,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { !this.view.target || !this.view.target.children || !this.view.target.children.length || - !this.browseMediaQueryParameters + !this.browseMediaQueryParametersBase ) { return null; } @@ -381,7 +377,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { try { clips = await BrowseMediaUtil.browseMediaQuery(this.hass, { - ...this.browseMediaQueryParameters, + ...this.browseMediaQueryParametersBase, mediaType: 'clips', before: latest, after: earliest, @@ -628,7 +624,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; } @@ -641,7 +638,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { return html`
- ${this.view.isClipRelatedView() + ${mediaToRender.media_content_type === 'video' ? html` Date: Fri, 8 Apr 2022 19:54:58 -0700 Subject: [PATCH 039/345] Change view to null for unspecified attributes. --- src/card.ts | 8 +++-- src/components/gallery.ts | 1 - src/components/live.ts | 5 ++- src/components/surround-thumbnails.ts | 51 +++++++++++++------------- src/components/thumbnail-carousel.ts | 2 +- src/components/thumbnail.ts | 2 +- src/components/timeline.ts | 46 ++++++++++++------------ src/components/viewer.ts | 36 ++++++++++--------- src/view.ts | 52 ++++++++++++++------------- 9 files changed, 109 insertions(+), 94 deletions(-) diff --git a/src/card.ts b/src/card.ts index 5d72ba87..c5e41d50 100644 --- a/src/card.ts +++ b/src/card.ts @@ -381,7 +381,11 @@ 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 +747,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; } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index c2df5dac..69a5d871 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -214,7 +214,6 @@ export class FrigateCardGalleryCore extends LitElement { .evolve({ view: this.view.is('clips') ? 'clip' : 'snapshot', childIndex: index, - previous: this.view, }) .dispatchChangeEvent(this); } diff --git a/src/components/live.ts b/src/components/live.ts index 7285fbd2..f4612078 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -357,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); } diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 466e256d..835bde6d 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -60,7 +60,7 @@ export class FrigateCardSurround extends LitElement { view = view as Readonly; browseMediaParams = browseMediaParams as BrowseMediaQueryParameters; - if (!hass || !view || !browseMediaParams) { + if (!hass || !view || view.target || !browseMediaParams) { return; } let parent: FrigateBrowseMediaSource | null; @@ -74,12 +74,20 @@ export class FrigateCardSurround extends LitElement { ?.evolve({ ...(this.targetView && { view: this.targetView }), target: parent, - childIndex: undefined, + 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. @@ -89,19 +97,22 @@ export class FrigateCardSurround extends LitElement { 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 + // . + if (this.config && this._hasDrawer()) { + dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:' + action, { + drawer: this.config.mode, + }); + } + }; + return html` { - if (this.config && ['left', 'right'].includes(this.config.mode)) { - // Protects 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 - // . - dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:open', { - drawer: this.config.mode, - }); - } - }} + @frigate-card:thumbnails:open=${(ev: CustomEvent) => changeDrawer(ev, 'open')} + @frigate-card:thumbnails:close=${(ev: CustomEvent) => changeDrawer(ev, 'close')} > ${this.config?.mode !== 'none' ? html` { - // Close the drawer if the carousel or thumbnail requests a view change - // (e.g. playing the clip, or viewing something on the timeline). - if (this.config && ['left', 'right'].includes(this.config.mode)) { - dispatchFrigateCardEvent(ev.composedPath()[0], 'drawer:close', { - drawer: this.config.mode, - }); - } - }} + .selected=${this.view.childIndex} + @frigate-card:change-view=${(ev: CustomEvent) => changeDrawer(ev, 'close')} @frigate-card:carousel:tap=${(ev: CustomEvent) => { this.view ?.evolve({ diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 556d56a8..28fedd9f 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -36,7 +36,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { // 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; + 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 diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 2f870ffe..0ba65552 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -90,7 +90,7 @@ export class FrigateCardThumbnail extends LitElement { ?.evolve({ view: 'timeline', target: this.target, - childIndex: this.childIndex, + childIndex: this.childIndex ?? null, context: {}, }) .dispatchChangeEvent(this); diff --git a/src/components/timeline.ts b/src/components/timeline.ts index c4f0aa6d..dba50bf1 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,8 +1,6 @@ -// TODO: Clips vs snapshots: Should be able to navigate from snapshots view and it should just work. // TODO: Hover over an event should show something useful. // TODO: Periodically refetch events. // TODO: Search for TODOs and logging statements. -// TODO: Allow download of selected event in timeline. import { CSSResultGroup, @@ -211,9 +209,8 @@ class TimelineEventManager { if (!cameraConfig || !this._dateStart || !this._dateEnd) { return; } - const browseMediaQueryParametersBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase( - cameraConfig, - ); + const browseMediaQueryParametersBase = + BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig); if (!browseMediaQueryParametersBase) { return; } @@ -384,20 +381,27 @@ export class FrigateCardTimelineCore extends LitElement { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars protected _timelineSelectHandler(data: { items: string[]; event: Event }): void { - if (!this._thumbnails || !this._thumbnails.children || data.items.length <= 0) { + if (!this._thumbnails || !this._thumbnails.children) { return; } - const childIndex = this._thumbnails.children.findIndex( - (child) => child.frigate?.event.id === data.items[0], - ); - if (childIndex >= 0) { - this.view - ?.evolve({ - target: this._thumbnails, - childIndex: childIndex, - }) - .dispatchChangeEvent(this); + + const childIndex = data.items.length + ? this._thumbnails.children.findIndex( + (child) => child.frigate?.event.id === data.items[0], + ) + : null; + + this.view + ?.evolve({ + target: this._thumbnails, + childIndex: childIndex, + }) + .dispatchChangeEvent(this); + + if (childIndex !== null && childIndex >= 0) { dispatchFrigateCardEvent(this, 'thumbnails:open'); + } else { + dispatchFrigateCardEvent(this, 'thumbnails:close'); } } @@ -428,12 +432,12 @@ export class FrigateCardTimelineCore extends LitElement { ['all', 'snapshots'].includes(this.timelineConfig.media) && BrowseMediaUtil.isTrueMedia(item.snapshot) ) { - added = true + added = true; children.push(item.snapshot); } if (added && selected.includes(item.event.id)) { - childIndex = children.length-1; + childIndex = children.length - 1; } } }); @@ -459,7 +463,7 @@ export class FrigateCardTimelineCore extends LitElement { this.view ?.evolve({ target: this._thumbnails, - childIndex: childIndex < 0 ? undefined : childIndex, + childIndex: childIndex < 0 ? null : childIndex, }) .dispatchChangeEvent(this); } @@ -642,9 +646,7 @@ export class FrigateCardTimelineCore extends LitElement { }); const timelineWindow = this._timeline.getWindow(); - const context = this.view.context - ? (this.view.context as TimelineViewContext) - : undefined; + const context = this.view.context as TimelineViewContext | null; if (context?.window) { console.info( diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 90de4107..d01f544c 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -145,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 => { + async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise => { for ( let i = 0; !this.viewerConfig?.lazy_load && @@ -183,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); @@ -226,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; } /** @@ -251,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) ?? undefined, draggable: this.viewerConfig?.draggable, }; } @@ -286,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; } @@ -398,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, }); } } @@ -426,7 +429,6 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { this.view .evolve({ childIndex: childIndex, - previous: this.view, }) .dispatchChangeEvent(this); } @@ -443,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 || diff --git a/src/view.ts b/src/view.ts index 8edbba54..42ad6300 100644 --- a/src/view.ts +++ b/src/view.ts @@ -7,10 +7,10 @@ export interface ViewContext {} export interface ViewEvolveParameters { view?: FrigateCardView; camera?: string; - target?: FrigateBrowseMediaSource; - childIndex?: number; - previous?: View; - context?: ViewContext; + target?: FrigateBrowseMediaSource | null; + childIndex?: number | null; + previous?: View | null; + context?: ViewContext | null; } export interface ViewParameters extends ViewEvolveParameters { @@ -21,18 +21,18 @@ export interface ViewParameters extends ViewEvolveParameters { export class View { view: FrigateCardView; camera: string; - target?: FrigateBrowseMediaSource; - childIndex?: number; - previous?: View; - context?: ViewContext; + 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.context = params?.context; + 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; } /** @@ -56,12 +56,15 @@ 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, - context: params.context ?? this.context, + 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, }); } @@ -110,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; } /** From 79bf66d961184ca247a52f7c8f1b8884c87cfddd Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 9 Apr 2022 07:57:30 -0700 Subject: [PATCH 040/345] Always rescroll after the carousel is re-initalized. --- package.json | 2 +- src/components/thumbnail-carousel.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 7079863d..91ddb3fd 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "crypto": "^1.0.1", "custom-card-helpers": "^1.9.0", "date-fns": "^2.28.0", - "embla-carousel": "^6.1.1", + "embla-carousel": "^6.2.0", "embla-carousel-wheel-gestures": "^2.1.1", "home-assistant-js-websocket": "^6.1.1", "keycharm": "^0.4.0", diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 28fedd9f..a0b0b449 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -75,6 +75,11 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { 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); + } } } @@ -102,7 +107,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { return { containScroll: 'keepSnaps', dragFree: true, - startIndex: this._selected ?? 0, + startIndex: this._selected ?? undefined, }; } @@ -139,7 +144,6 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { this.updateComplete.then(() => { if (this._carousel) { if (this._selected !== undefined && this._selected !== null) { - console.info('thumbnail carousel scroll to') this.carouselScrollTo(this._selected); } } From 616c24c94b23127c899f700daf09038fb4d7c589 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 9 Apr 2022 09:39:34 -0700 Subject: [PATCH 041/345] Show thumbnails as tooltips. --- src/components/thumbnail-carousel.ts | 1 + src/components/thumbnail.ts | 100 ++++++++++++++++++--------- src/components/timeline.ts | 47 ++++++++++++- src/scss/thumbnail.scss | 4 ++ src/scss/timeline-core.scss | 3 + 5 files changed, 120 insertions(+), 35 deletions(-) diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index a0b0b449..9d2f01c1 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -179,6 +179,7 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { .target=${parent} .childIndex=${childIndex} ?details=${this._config?.show_details} + ?controls=${true} thumbnail_size=${ifDefined(this._config?.size)} class="${classMap(classes)}" @click=${(ev) => { diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 0ba65552..c4b14dab 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -2,7 +2,7 @@ import { CSSResult, TemplateResult, html, unsafeCSS, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { format, fromUnixTime } from 'date-fns'; -import type { FrigateBrowseMediaSource } from '../types.js'; +import type { FrigateBrowseMediaSource, FrigateEvent } from '../types.js'; import { View } from '../view.js'; import { getEventDurationString, @@ -15,6 +15,20 @@ 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: false }) + set thumbnail_size(size: number) { + this.style.setProperty('--frigate-card-thumbnail-size', String(size)); + } + + // ============================ + // Data-binding based interface + // ============================ @property({ attribute: false }) protected view?: Readonly; @@ -24,34 +38,54 @@ export class FrigateCardThumbnail extends LitElement { @property({ attribute: false }) public childIndex?: number; - @property({ attribute: true, type: Boolean, reflect: true }) - public details = false; + // ============================================================= + // Overrides that can be used if data bindings are not available + // ============================================================= + @property({ attribute: true }) + public thumbnail?: string; - @property({ attribute: false }) - set thumbnail_size(size: number) { - this.style.setProperty('--frigate-card-thumbnail-size', String(size)); - } + @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 { - if (!this.target || !this.target.children || this.childIndex === undefined) { - return; + 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; } - const media = this.target.children[this.childIndex]; - if (!media.thumbnail) { + + // 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; } - const event = media.frigate?.event; + console.info; + return html` - ${event?.retain_indefinitely + ${this.controls && event?.retain_indefinitely ? html`
` : html``} - { - stopEventFromActivatingCardWideActions(ev); - this.view - ?.evolve({ - view: 'timeline', - target: this.target, - childIndex: this.childIndex ?? null, - context: {}, - }) - .dispatchChangeEvent(this); - }} - >`; + ${this.controls + ? html` { + stopEventFromActivatingCardWideActions(ev); + this.view + ?.evolve({ + view: 'timeline', + target: this.target, + childIndex: this.childIndex ?? null, + context: {}, + }) + .dispatchChangeEvent(this); + }} + >` + : ''}`; } /** diff --git a/src/components/timeline.ts b/src/components/timeline.ts index dba50bf1..f25471b6 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,5 +1,7 @@ // TODO: Hover over an event should show something useful. // TODO: Periodically refetch events. +// TODO: Editor support for timeline (incl. in views). +// TODO: Make thumbnail controls optional (in all places that use thumbnails). // TODO: Search for TODOs and logging statements. import { @@ -74,11 +76,14 @@ class TimelineEventManager { // The latest date managed. protected _dateEnd?: Date; protected _contentCallback?: (source: FrigateBrowseMediaSource) => string; + protected _tooltipCallback?: (source: FrigateBrowseMediaSource) => string; constructor(params?: { contentCallback?: (source: FrigateBrowseMediaSource) => string; + tooltipCallback?: (source: FrigateBrowseMediaSource) => string; }) { this._contentCallback = params?.contentCallback; + this._tooltipCallback = params?.tooltipCallback; } /** @@ -119,6 +124,7 @@ class TimelineEventManager { id: event.id, group: camera, content: this._contentCallback?.(child) ?? '', + title: this._tooltipCallback?.(child) ?? '', start: event.start_time * 1000, event: event, }; @@ -306,11 +312,41 @@ export class FrigateCardTimelineCore extends LitElement { @property({ attribute: false }) protected timelineConfig?: TimelineConfig; - protected _events = new TimelineEventManager(); + protected _events = new TimelineEventManager({ + tooltipCallback: this._getTooltip.bind(this), + }); protected _refTimeline: Ref = createRef(); protected _thumbnails?: FrigateBrowseMediaSource; protected _timeline?: Timeline; + /** + * Get a tooltip for a given timeline event. + * @param source The FrigateBrowseMediaSource in question. + * @returns The tooltip as a string to render. + */ + protected _getTooltip(source: FrigateBrowseMediaSource): string { + const thumbnailSizeAttr = this.timelineConfig + ? `thumbnail_size="${this.timelineConfig.controls.thumbnails.size}"` + : ''; + const eventAttr = source.frigate?.event + ? `event='${JSON.stringify(source.frigate.event)}'` + : ''; + console.info(eventAttr); + + // Cannot use Lit data-bindings as visjs requires a string for tooltips. + // Note that changes to attributes here must be mirrored in the xss + // whitelist in `_getOptions()` . + return ` + + `; + } + /** * Master render method. * @returns A rendered template. @@ -575,14 +611,19 @@ export class FrigateCardTimelineCore extends LitElement { start: start, end: end, groupHeightMode: 'fixed', + tooltip: { + followMouse: true, + overflowMethod: 'cap', + }, xss: { disabled: false, filterOptions: { whiteList: { - 'frigate-card-timeline-event': [ + 'frigate-card-thumbnail': [ + 'details', 'thumbnail', 'label', - 'media_id', + 'event', 'thumbnail_size', ], div: ['title'], diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index d48e60e8..2aa3ec48 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -16,6 +16,10 @@ 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 { diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index 4d90158a..479c7175 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -85,4 +85,7 @@ div.vis-tooltip { padding: 0px; background-color: unset; border: none; + + // Use browser default font-family for tooltips. + font-family: unset; } From 90b06ef22f5e2237b622d8086f2995fee15139f7 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 9 Apr 2022 09:59:11 -0700 Subject: [PATCH 042/345] Only show gallery back button in certain cases. --- src/browse-media-util.ts | 1 - src/components/gallery.ts | 14 +++++++++++++- src/components/timeline.ts | 1 - 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/browse-media-util.ts b/src/browse-media-util.ts index a3d647d2..381053a8 100644 --- a/src/browse-media-util.ts +++ b/src/browse-media-util.ts @@ -239,7 +239,6 @@ export class BrowseMediaUtil { view .evolve({ target: parent, - previous: view, }) .dispatchChangeEvent(node); } diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 69a5d871..b83c8382 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -129,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. @@ -158,7 +170,7 @@ export class FrigateCardGalleryCore extends LitElement { }; return html`