diff --git a/src/card.ts b/src/card.ts index ba9fe988..0fa946f0 100644 --- a/src/card.ts +++ b/src/card.ts @@ -41,10 +41,7 @@ import type { import { CARD_VERSION, REPO_URL } from './const.js'; import { FrigateCardElements } from './components/elements.js'; -import { - FRIGATE_BUTTON_MENU_ICON, - FrigateCardMenu, -} from './components/menu.js'; +import { FRIGATE_BUTTON_MENU_ICON, FrigateCardMenu } from './components/menu.js'; import { View } from './view.js'; import { convertActionToFrigateCardCustomAction, @@ -66,6 +63,7 @@ import './components/live.js'; import './components/menu.js'; import './components/message.js'; import './components/viewer.js'; +import './components/thumbnail-carousel.js'; import './patches/ha-camera-stream.js'; import './patches/ha-hls-player.js'; @@ -736,15 +734,21 @@ export class FrigateCard extends LitElement { * Get the parameters to search for media related to the current view. * @returns A BrowseMediaQueryParameters object. */ - protected _getBrowseMediaQueryParameters(): BrowseMediaQueryParameters | undefined { + protected _getBrowseMediaQueryParameters( + mediaType?: 'clips' | 'snapshots', + ): BrowseMediaQueryParameters | undefined { if ( !this._frigateCameraName || - !(this._view.isClipRelatedView() || this._view.isSnapshotRelatedView()) + !( + this._view.isClipRelatedView() || + this._view.isSnapshotRelatedView() || + mediaType + ) ) { return undefined; } return { - mediaType: this._view.isClipRelatedView() ? 'clips' : 'snapshots', + mediaType: mediaType || (this._view.isClipRelatedView() ? 'clips' : 'snapshots'), clientId: this.config.frigate.client_id, cameraName: this._frigateCameraName, label: this.config.frigate.label, @@ -968,7 +972,6 @@ export class FrigateCard extends LitElement { true, ); } - const mediaQueryParameters = this._getBrowseMediaQueryParameters(); const pictureElementsClasses = { 'picture-elements': true, @@ -1003,7 +1006,7 @@ export class FrigateCard extends LitElement { ? html` { - conditionStateRequestHandler(ev, this._conditionState) + conditionStateRequestHandler(ev, this._conditionState); }} > diff --git a/src/components/carousel.ts b/src/components/carousel.ts index 00ff0b1b..cd8da910 100644 --- a/src/components/carousel.ts +++ b/src/components/carousel.ts @@ -10,9 +10,6 @@ import { dispatchFrigateCardEvent } from '../common'; import carouselStyle from '../scss/carousel.scss'; -export interface CarouselTap { - index: number; -} export interface CarouselSelect { index: number; } diff --git a/src/components/live.ts b/src/components/live.ts index 264a3418..90b7b537 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -1,5 +1,6 @@ import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import type { + BrowseMediaQueryParameters, ExtendedHomeAssistant, FrigateCardConfig, JSMPEGConfig, @@ -25,6 +26,12 @@ import { renderProgressIndicator } from '../components/message.js'; import JSMpeg from '@cycjimmy/jsmpeg-player'; import liveStyle from '../scss/live.scss'; +import liveFrigateStyle from '../scss/live-frigate.scss'; +import liveJSMPEGStyle from '../scss/live-jsmpeg.scss'; +import liveWebRTCStyle from '../scss/live-webrtc.scss'; + +import { View } from '../view.js'; +import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; // Number of seconds a signed URL is valid for. const URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; @@ -41,7 +48,7 @@ export class FrigateCardLive extends LitElement { protected config?: FrigateCardConfig; @property({ attribute: false }) - protected frigateCameraName?: string; + protected browseMediaQueryParameters?: BrowseMediaQueryParameters; @property({ attribute: false }) set preload(preload: boolean) { @@ -72,6 +79,33 @@ export class FrigateCardLive extends LitElement { } } + /** + * Render thumbnails carousel. + * @returns A rendered template or void. + */ + protected renderThumbnails(): TemplateResult | void { + if (!this.config) { + return; + } + return html` ) => { + const mediaType = this.browseMediaQueryParameters?.mediaType; + if (mediaType && ['snapshots', 'clips'].includes(mediaType)) { + new View({ + view: mediaType === 'clips' ? 'clip-specific' : 'snapshot-specific', + target: ev.detail.target, + childIndex: ev.detail.childIndex, + }).dispatchChangeEvent(this); + } + }} + > + `; + } + /** * Master render method. * @returns A rendered template. @@ -81,28 +115,36 @@ export class FrigateCardLive extends LitElement { return; } - return html` ${this.config.live.provider == 'frigate' - ? html` - ` - : this.config.live.provider == 'webrtc' - ? html` - ` - : html` - `}`; + return html` + ${this.config.live.controls.thumbnails.mode === 'above' + ? this.renderThumbnails() + : ''} + ${this.config.live.provider == 'frigate' + ? html` + ` + : this.config.live.provider == 'webrtc' + ? html` + ` + : html` + `} + ${this.config.live.controls.thumbnails.mode === 'below' + ? this.renderThumbnails() + : ''} + `; } /** @@ -150,7 +192,7 @@ export class FrigateCardLiveFrigate extends LitElement { * Get styles. */ static get styles(): CSSResultGroup { - return unsafeCSS(liveStyle); + return unsafeCSS(liveFrigateStyle); } } @@ -237,7 +279,7 @@ export class FrigateCardLiveWebRTC extends LitElement { * Get styles. */ static get styles(): CSSResultGroup { - return unsafeCSS(liveStyle); + return unsafeCSS(liveWebRTCStyle); } } @@ -415,6 +457,6 @@ export class FrigateCardLiveJSMPEG extends LitElement { * Get styles. */ static get styles(): CSSResultGroup { - return unsafeCSS(liveStyle); + return unsafeCSS(liveJSMPEGStyle); } } diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index c7f43e08..a50c6f27 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -1,30 +1,102 @@ import { BrowseMediaUtil } from '../browse-media-util.js'; -import { CSSResultGroup, TemplateResult, html, unsafeCSS } from 'lit'; +import { CSSResultGroup, TemplateResult, html, unsafeCSS, LitElement } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import { until } from 'lit/directives/until'; -import type { BrowseMediaSource, ThumbnailsControlConfig } from '../types.js'; -import { CarouselTap, FrigateCardCarousel } from './carousel.js'; +import type { BrowseMediaQueryParameters, BrowseMediaSource, ExtendedHomeAssistant, ThumbnailsControlConfig } from '../types.js'; +import { FrigateCardCarousel } from './carousel.js'; +import { HomeAssistant } from 'custom-card-helpers'; import { actionHandler } from '../action-handler-directive.js'; -import { dispatchFrigateCardEvent } from '../common.js'; +import { dispatchErrorMessageEvent, dispatchFrigateCardEvent } from '../common.js'; +import { renderProgressIndicator } from './message.js'; import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss'; +export interface ThumbnailCarouselTap { + slideIndex: number; + target: BrowseMediaSource; + childIndex: number; +} + @customElement('frigate-card-thumbnail-carousel') -export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { +export class FrigateCardThumbnailCarousel extends LitElement { + @property({ attribute: false }) + protected hass?: HomeAssistant & ExtendedHomeAssistant; + + @property({ attribute: false }) + protected browseMediaQueryParameters?: BrowseMediaQueryParameters; + + @property({ attribute: false }) + protected config?: ThumbnailsControlConfig; + + @property({ attribute: false }) + protected highlightSelected = true; + + /** + * Master render method. + * @returns A rendered template. + */ + protected render(): TemplateResult | void { + return html`${until(this._render(), renderProgressIndicator())}`; + } + + /** + * Asyncronously render the element. + * @returns A rendered template. + */ + protected async _render(): Promise { + if (!this.hass || !this.browseMediaQueryParameters) { + return html``; + } + + let parent: BrowseMediaSource | null = null; + try { + parent = await BrowseMediaUtil.browseMediaQuery( + this.hass, + this.browseMediaQueryParameters, + ); + } catch (e) { + return dispatchErrorMessageEvent(this, (e as Error).message); + } + return html` + `; + } + + /** + * Get element styles. + */ + // static get styles(): CSSResultGroup { + // return unsafeCSS(viewerStyle); + // } +} + +@customElement('frigate-card-thumbnail-carousel-core') +export class FrigateCardThumbnailCarouselCore extends FrigateCardCarousel { @property({ attribute: false }) protected target?: BrowseMediaSource; protected _tapSelected?; @property({ attribute: false }) - set config(config: ThumbnailsControlConfig) { - this._config = config; + set config(config: ThumbnailsControlConfig | undefined) { if (config) { - this.style.setProperty('--frigate-card-viewer-thumbnail-size', config.size); + if (config && (config.size !== undefined && config.size != null)) { + this.style.setProperty('--frigate-card-carousel-thumbnail-size', config.size); + } + this._config = config; } } protected _config?: ThumbnailsControlConfig; + @property({ attribute: false }) + set highlightSelected(value: boolean) { + this.style.setProperty('--frigate-card-carousel-thumbnail-opacity', value ? '0.6' : '1.0'); + } + constructor() { super(); this._options = { @@ -63,7 +135,10 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { const slides: TemplateResult[] = []; for (let i = 0; i < this.target.children.length; ++i) { - const thumbnail = this._renderThumbnail(this.target.children[i], slides.length); + const thumbnail = this._renderThumbnail( + this.target, + i, + slides.length); if (thumbnail) { slides.push(thumbnail); } @@ -77,9 +152,15 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { * @returns A template or void if the item could not be rendered. */ protected _renderThumbnail( - mediaToRender: BrowseMediaSource, + parent: BrowseMediaSource, + childIndex: number, slideIndex: number, ): TemplateResult | void { + if (!parent.children || !parent.children.length) { + return; + } + + const mediaToRender = parent.children[childIndex]; if (!BrowseMediaUtil.isTrueMedia(mediaToRender) || !mediaToRender.thumbnail) { return; } @@ -92,8 +173,10 @@ export class FrigateCardThumbnailCarousel extends FrigateCardCarousel { })} @action=${() => { if (this._carousel && this._carousel.clickAllowed()) { - dispatchFrigateCardEvent(this, 'carousel:tap', { - index: slideIndex, + dispatchFrigateCardEvent(this, 'carousel:tap', { + slideIndex: slideIndex, + target: parent, + childIndex: childIndex, }); } }} diff --git a/src/components/viewer.ts b/src/components/viewer.ts index cd2fa7c8..89e2aa19 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -15,8 +15,8 @@ import type { MediaShowInfo, ViewerConfig, } from '../types.js'; -import { CarouselTap, FrigateCardCarousel } from './carousel.js'; -import { FrigateCardThumbnailCarousel } from './thumbnail-carousel.js'; +import { FrigateCardCarousel } from './carousel.js'; +import { FrigateCardThumbnailCarouselCore, ThumbnailCarouselTap } from './thumbnail-carousel.js'; import { ResolvedMediaCache, ResolvedMediaUtil } from '../resolved-media.js'; import { View } from '../view.js'; import { actionHandler } from '../action-handler-directive.js'; @@ -33,7 +33,6 @@ import { localize } from '../localize/localize.js'; import { renderProgressIndicator } from '../components/message.js'; import './next-prev-control.js'; -import './thumbnail-carousel.js'; import viewerStyle from '../scss/viewer.scss'; import viewerCoreStyle from '../scss/viewer-core.scss'; @@ -166,7 +165,7 @@ export class FrigateCardViewerCore extends LitElement { protected resolvedMediaCache?: ResolvedMediaCache; protected _mediaCarouselRef: Ref = createRef(); - protected _thumbnailCarouselRef: Ref = createRef(); + protected _thumbnailCarouselRef: Ref = createRef(); protected _syncThumbnailCarousel(): void { const mediaSelected = this._mediaCarouselRef.value?.carouselSelected(); @@ -180,16 +179,16 @@ export class FrigateCardViewerCore extends LitElement { return html``; } - return html` ) => { - this._mediaCarouselRef.value?.carouselScrollTo(ev.detail.index); + @frigate-card:carousel:tap=${(ev: CustomEvent) => { + this._mediaCarouselRef.value?.carouselScrollTo(ev.detail.slideIndex); }} @frigate-card:carousel:init=${this._syncThumbnailCarousel.bind(this)} > - `; + `; } protected render(): TemplateResult | void { diff --git a/src/scss/live-frigate.scss b/src/scss/live-frigate.scss new file mode 100644 index 00000000..a9b9a68b --- /dev/null +++ b/src/scss/live-frigate.scss @@ -0,0 +1,7 @@ +:host { + width: 100%; + height: 100%; + display: block; + + --video-max-height: none; +} \ No newline at end of file diff --git a/src/scss/live-jsmpeg.scss b/src/scss/live-jsmpeg.scss new file mode 100644 index 00000000..9f7cbf2c --- /dev/null +++ b/src/scss/live-jsmpeg.scss @@ -0,0 +1,12 @@ +:host { + width: 100%; + height: 100%; + display: block; +} + +canvas { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} \ No newline at end of file diff --git a/src/scss/live-webrtc.scss b/src/scss/live-webrtc.scss new file mode 100644 index 00000000..8297c2d8 --- /dev/null +++ b/src/scss/live-webrtc.scss @@ -0,0 +1,12 @@ +:host { + width: 100%; + height: 100%; + display: block; +} + +/* Don't drop shadow or have radius for nested webrtc card */ +webrtc-camera ha-card { + box-shadow: none; + border-radius: 0px; + background-color: var(--secondary-background-color, black); +} \ No newline at end of file diff --git a/src/scss/live.scss b/src/scss/live.scss index e1b6f47e..850a3197 100644 --- a/src/scss/live.scss +++ b/src/scss/live.scss @@ -1,21 +1,16 @@ :host { width: 100%; height: 100%; - display: block; - - --video-max-height: none; + display: flex; + flex-direction: column; + gap: 5px; } -canvas { - display: block; - width: 100%; - height: 100%; - object-fit: contain; +frigate-card-live { + flex: 1; + min-height: 0; } -/* Don't drop shadow or have radius for nested webrtc card */ -webrtc-camera ha-card { - box-shadow: none; - border-radius: 0px; - background-color: var(--secondary-background-color, black); +frigate-card-thumbnail-carousel { + flex: 0 0 var(--frigate-card-carousel-thumbnail-size); } \ No newline at end of file diff --git a/src/scss/thumbnail-carousel.scss b/src/scss/thumbnail-carousel.scss index 262b9c1a..1a1a417d 100644 --- a/src/scss/thumbnail-carousel.scss +++ b/src/scss/thumbnail-carousel.scss @@ -1,10 +1,11 @@ :host { - --frigate-card-viewer-thumbnail-size: 100px; + --frigate-card-carousel-thumbnail-size: 100px; + --frigate-card-carousel-thumbnail-opacity: 0.6; } .embla__slide { - flex: 0 0 var(--frigate-card-viewer-thumbnail-size); - opacity: 0.4; + flex: 0 0 var(--frigate-card-carousel-thumbnail-size); + opacity: var(--frigate-card-carousel-thumbnail-opacity); transition: opacity 0.6s ease, transform 0.3s ease; } .embla__slide.slide-selected { @@ -15,4 +16,9 @@ } .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; } \ No newline at end of file diff --git a/src/scss/viewer-core.scss b/src/scss/viewer-core.scss index b3539631..7dcf4a98 100644 --- a/src/scss/viewer-core.scss +++ b/src/scss/viewer-core.scss @@ -1,6 +1,7 @@ :host { display: flex; flex-direction: column; + gap: 5px; height: 100%; width: 100%; @@ -11,7 +12,6 @@ frigate-card-media-carousel { min-height: 0; } -frigate-card-thumbnail-carousel { - flex: 0 0 var(--frigate-card-viewer-thumbnail-size); - margin-top: 5px; +frigate-card-thumbnail-carousel-core { + flex: 0 0 var(--frigate-card-carousel-thumbnail-size); } \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index f94e79c2..73abcb01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -355,12 +355,29 @@ const imageConfigSchema = z .optional(); export type ImageViewConfig = z.infer; +/** + * Thumbnail controls configuration section. + */ + +// This type is not inferred from a schema since different (compatible) schemas +// have different defaults. +export type ThumbnailsControlConfig = { + mode: 'above' | 'below' | 'none'; + size?: string; +} + /** * Live view configuration section. */ const liveConfigDefault = { provider: 'frigate' as const, preload: false, + controls: { + thumbnails: { + mode: 'none' as const, + media: 'clips' as const, + }, + }, }; const webrtcConfigSchema = z .object({ @@ -400,6 +417,19 @@ const liveConfigSchema = z preload: z.boolean().default(liveConfigDefault.preload), webrtc: webrtcConfigSchema, jsmpeg: jsmpegConfigSchema, + controls: z + .object({ + thumbnails: z + .object({ + media: z + .enum(['clips', 'snapshots']) + .default(liveConfigDefault.controls.thumbnails.media), + mode: z.enum(['none', 'above', 'below']).default(liveConfigDefault.controls.thumbnails.mode), + size: z.string().optional(), + }) + .default(liveConfigDefault.controls.thumbnails), + }) + .default(liveConfigDefault.controls), }) .merge(actionsSchema) .default(liveConfigDefault); @@ -455,7 +485,6 @@ const viewerConfigDefault = { style: 'thumbnails' as const, }, thumbnails: { - size: '100px', mode: 'below' as const, }, }, @@ -470,16 +499,6 @@ const nextPreviousControlConfigSchema = z .default(viewerConfigDefault.controls.next_previous); export type NextPreviousControlConfig = z.infer; -const thumbnailsControlConfigSchema = z - .object({ - mode: z - .enum(['none', 'above', 'below']) - .default(viewerConfigDefault.controls.thumbnails.mode), - size: z.string().default(viewerConfigDefault.controls.thumbnails.size), - }) - .default(viewerConfigDefault.controls.thumbnails); -export type ThumbnailsControlConfig = z.infer; - const viewerConfigSchema = z .object({ autoplay_clip: z.boolean().default(viewerConfigDefault.autoplay_clip), @@ -488,7 +507,10 @@ const viewerConfigSchema = z controls: z .object({ next_previous: nextPreviousControlConfigSchema, - thumbnails: thumbnailsControlConfigSchema, + thumbnails: z.object({ + mode: z.enum(['none', 'above', 'below']).default(viewerConfigDefault.controls.thumbnails.mode), + size: z.string().optional(), + }).default(viewerConfigDefault.controls.thumbnails), }) .default(viewerConfigDefault.controls), })