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;