Add support for both snapshots/clips in timeline.

This commit is contained in:
Dermot Duffy
2022-04-04 21:25:12 -07:00
parent f98d749d07
commit 9c372815f2
10 changed files with 212 additions and 157 deletions
+31 -18
View File
@@ -1,6 +1,7 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import type { import type {
BrowseMediaQueryParametersBase,
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
CameraConfig, CameraConfig,
@@ -39,8 +40,8 @@ export class BrowseMediaUtil {
* @param media The media object. * @param media The media object.
* @returns `true` if it's truly a media item, `false` otherwise. * @returns `true` if it's truly a media item, `false` otherwise.
*/ */
static isTrueMedia(media: FrigateBrowseMediaSource): boolean { static isTrueMedia(media?: FrigateBrowseMediaSource): boolean {
return !media.can_expand; return !!media && !media.can_expand;
} }
/** /**
@@ -113,15 +114,13 @@ export class BrowseMediaUtil {
* Get the parameters to search for media. * Get the parameters to search for media.
* @returns A BrowseMediaQueryParameters object. * @returns A BrowseMediaQueryParameters object.
*/ */
static getBrowseMediaQueryParameters( static getBrowseMediaQueryParametersBase(
mediaType: 'clips' | 'snapshots',
cameraConfig?: CameraConfig, cameraConfig?: CameraConfig,
): BrowseMediaQueryParameters | null { ): BrowseMediaQueryParametersBase | null {
if (!cameraConfig || !cameraConfig.camera_name) { if (!cameraConfig || !cameraConfig.camera_name) {
return null; return null;
} }
return { return {
mediaType: mediaType,
clientId: cameraConfig.client_id, clientId: cameraConfig.client_id,
cameraName: cameraConfig.camera_name, cameraName: cameraConfig.camera_name,
label: cameraConfig.label, 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. * Get the parameters to search for media related to the current view.
* @returns A BrowseMediaQueryParameters object. * @returns A BrowseMediaQueryParameters object.
*/ */
static getBrowseMediaQueryParametersOrDispatchError( static getBrowseMediaQueryParametersBaseOrDispatchError(
node: HTMLElement, node: HTMLElement,
view: View,
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
): BrowseMediaQueryParameters | null { ): BrowseMediaQueryParametersBase | null {
if (!view.isClipRelatedView() && !view.isSnapshotRelatedView()) { // Verify there is a camera name, otherwise getBrowseMediaQueryParametersBase()
return null;
}
// Verify there is a camera name, otherwise getBrowseMediaQueryParameters()
// will return undefined. // will return undefined.
if (!cameraConfig.camera_name) { if (!cameraConfig.camera_name) {
dispatchErrorMessageEvent( dispatchErrorMessageEvent(
@@ -152,10 +168,7 @@ export class BrowseMediaUtil {
return null; return null;
} }
return BrowseMediaUtil.getBrowseMediaQueryParameters( return BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig);
view.isClipRelatedView() ? 'clips' : 'snapshots',
cameraConfig,
);
} }
/** /**
+4 -2
View File
@@ -1089,13 +1089,15 @@ export class FrigateCard extends LitElement {
// Do not artifically constrain aspect ratio if: // Do not artifically constrain aspect ratio if:
// - It's fullscreen. // - It's fullscreen.
// - Aspect ratio enforcement is disabled. // - 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. // - There is a message to display to the user.
return !( return !(
(screenfull.isEnabled && screenfull.isFullscreen) || (screenfull.isEnabled && screenfull.isFullscreen) ||
aspectRatioMode == 'unconstrained' || aspectRatioMode == 'unconstrained' ||
(aspectRatioMode == 'dynamic' && this._view?.isMediaView()) || (aspectRatioMode == 'dynamic' &&
(this._view?.isMediaView() || this._view?.is('timeline'))) ||
this._message != null this._message != null
); );
} }
+10 -7
View File
@@ -38,16 +38,17 @@ export class FrigateCardGallery extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this.cameraConfig) { if (!this.hass || !this.view || !this.cameraConfig || !this.view.isGalleryView()) {
return; return;
} }
if (!this.view.target) { if (!this.view.target) {
const browseMediaQueryParameters = const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView(
BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError( BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError(
this, this,
this.view,
this.cameraConfig, this.cameraConfig,
),
this.view,
); );
if (!browseMediaQueryParameters) { if (!browseMediaQueryParameters) {
return; return;
@@ -207,7 +208,7 @@ export class FrigateCardGalleryCore extends LitElement {
class="mdc-image-list__image" class="mdc-image-list__image"
src="${child.thumbnail}" src="${child.thumbnail}"
title="${child.title}" title="${child.title}"
@click=${(ev) => { @click=${(ev: Event) => {
if (this.view) { if (this.view) {
this.view this.view
.evolve({ .evolve({
@@ -219,11 +220,13 @@ export class FrigateCardGalleryCore extends LitElement {
} }
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
}} }}
/>${child.frigate?.event?.retain_indefinitely ? html`<ha-icon />${child.frigate?.event?.retain_indefinitely
? html`<ha-icon
class="favorite" class="favorite"
icon="mdi:star" icon="mdi:star"
title=${localize('thumbnail.retain_indefinitely')} title=${localize('thumbnail.retain_indefinitely')}
/>` : ``}` />`
: ``}`
: ``} : ``}
</div> </div>
</li>`, </li>`,
+8 -14
View File
@@ -7,7 +7,6 @@ import {
PropertyValues, PropertyValues,
} from 'lit'; } from 'lit';
import { import {
FrigateBrowseMediaSource,
ExtendedHomeAssistant, ExtendedHomeAssistant,
CameraConfig, CameraConfig,
JSMPEGConfig, JSMPEGConfig,
@@ -20,25 +19,21 @@ import {
LiveProvider, LiveProvider,
TransitionEffect, TransitionEffect,
frigateCardConfigDefaults, frigateCardConfigDefaults,
BrowseMediaQueryParameters,
} from '../types.js'; } from '../types.js';
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import JSMpeg from '@cycjimmy/jsmpeg-player'; import JSMpeg from '@cycjimmy/jsmpeg-player';
import { Ref, createRef, ref } from 'lit/directives/ref.js'; import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { Task } from '@lit-labs/task'; 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 { until } from 'lit/directives/until.js';
import { styleMap } from 'lit/directives/style-map.js';
import { AutoMediaPlugin, AutoMediaPluginType } from './embla-plugins/automedia.js'; import { AutoMediaPlugin, AutoMediaPluginType } from './embla-plugins/automedia.js';
import { BrowseMediaUtil } from '../browse-media-util.js'; import { BrowseMediaUtil } from '../browse-media-util.js';
import { ConditionState, getOverriddenConfig } from '../card-condition.js'; import { ConditionState, getOverriddenConfig } from '../card-condition.js';
import { FrigateCardMediaCarousel } from './media-carousel.js'; import { FrigateCardMediaCarousel } from './media-carousel.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import {
FrigateCardThumbnailCarousel,
ThumbnailCarouselTap,
} from './thumbnail-carousel.js';
import { Lazyload } from './embla-plugins/lazyload.js'; import { Lazyload } from './embla-plugins/lazyload.js';
import { View } from '../view.js'; import { View } from '../view.js';
import { localize } from '../localize/localize.js'; import { localize } from '../localize/localize.js';
@@ -104,9 +99,6 @@ export class FrigateCardLive extends LitElement {
// pre-loading it may be propagated upwards later. // pre-loading it may be propagated upwards later.
protected _savedMediaShowInfo?: MediaShowInfo; protected _savedMediaShowInfo?: MediaShowInfo;
@query('frigate-card-thumbnail-carousel')
protected _thumbnailCarousel?: FrigateCardThumbnailCarousel;
/** /**
* Handler for media show events that special cases preloaded live views. * Handler for media show events that special cases preloaded live views.
* @param e The media show event. * @param e The media show event.
@@ -135,13 +127,16 @@ export class FrigateCardLive extends LitElement {
this.conditionState, this.conditionState,
) as LiveConfig; ) as LiveConfig;
const browseMediaParams = BrowseMediaUtil.getBrowseMediaQueryParameters( const browseMediaParamsBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase(
config.controls.thumbnails.media,
this.cameras.get(this.view.camera), this.cameras.get(this.view.camera),
); );
if (!browseMediaParams) { if (!browseMediaParamsBase) {
return; return;
} }
const browseMediaParams: BrowseMediaQueryParameters = {
...browseMediaParamsBase,
mediaType: config.controls.thumbnails.media,
}
// Note use of liveConfig and not config below -- the carousel will // Note use of liveConfig and not config below -- the carousel will
// independently override the liveconfig to reflect the camera in the // independently override the liveconfig to reflect the camera in the
@@ -150,7 +145,6 @@ export class FrigateCardLive extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.config=${config.controls.thumbnails} .config=${config.controls.thumbnails}
.targetView=${config.controls.thumbnails.media == 'clips' ? 'clip' : 'snapshot'}
.browseMediaParams=${browseMediaParams} .browseMediaParams=${browseMediaParams}
> >
<frigate-card-live-carousel <frigate-card-live-carousel
+4 -3
View File
@@ -1,7 +1,7 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { Task } from '@lit-labs/task'; import { Task } from '@lit-labs/task';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { BrowseMediaUtil } from '../browse-media-util.js'; import { BrowseMediaUtil } from '../browse-media-util.js';
import { import {
@@ -45,7 +45,8 @@ export class FrigateCardSurround extends LitElement {
]); ]);
/** /**
* Fetch thumbnail media. * Fetch thumbnail media when a target is not specified in the view (e.g. for
* the live view).
* @param param Task parameters. * @param param Task parameters.
* @returns * @returns
*/ */
@@ -121,7 +122,7 @@ export class FrigateCardSurround extends LitElement {
@frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => { @frigate-card:carousel:tap=${(ev: CustomEvent<ThumbnailCarouselTap>) => {
this.view this.view
?.evolve({ ?.evolve({
...(this.targetView && { view: this.targetView }), view: this.targetView || 'event',
target: ev.detail.target, target: ev.detail.target,
childIndex: ev.detail.childIndex, childIndex: ev.detail.childIndex,
}) })
+89 -51
View File
@@ -1,5 +1,4 @@
// TODO: Clips vs snapshots: Should be able to navigate from snapshots view and it should just work. // 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: Hover over an event should show something useful.
// TODO: Periodically refetch events. // TODO: Periodically refetch events.
// TODO: Search for TODOs and logging statements. // TODO: Search for TODOs and logging statements.
@@ -16,7 +15,6 @@ import {
import { DataSet } from 'vis-data/esnext'; import { DataSet } from 'vis-data/esnext';
import { import {
DataGroupCollectionType, DataGroupCollectionType,
IdType,
Timeline, Timeline,
TimelineItem, TimelineItem,
TimelineOptions, TimelineOptions,
@@ -36,8 +34,7 @@ import {
ExtendedHomeAssistant, ExtendedHomeAssistant,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
MEDIA_CLASS_PLAYLIST, MEDIA_CLASS_PLAYLIST,
MEDIA_TYPE_VIDEO, MEDIA_TYPE_PLAYLIST,
MEDIA_CLASS_VIDEO,
TimelineConfig, TimelineConfig,
FrigateEvent, FrigateEvent,
} from '../types'; } from '../types';
@@ -58,7 +55,9 @@ interface FrigateCardGroupData {
content: string; content: string;
} }
interface FrigateCardTimelineItem extends TimelineItem { interface FrigateCardTimelineItem extends TimelineItem {
source: FrigateBrowseMediaSource; event: FrigateEvent;
clip?: FrigateBrowseMediaSource;
snapshot?: FrigateBrowseMediaSource;
} }
interface TimelineViewContext extends ViewContext { interface TimelineViewContext extends ViewContext {
@@ -114,20 +113,29 @@ class TimelineEventManager {
protected _addMediaSource(camera: string, target: FrigateBrowseMediaSource): void { protected _addMediaSource(camera: string, target: FrigateBrowseMediaSource): void {
const items: FrigateCardTimelineItem[] = []; const items: FrigateCardTimelineItem[] = [];
target.children?.forEach((child) => { target.children?.forEach((child) => {
if (child.frigate) { const event = child.frigate?.event;
const item = { if (event && ['video', 'image'].includes(child.media_content_type)) {
id: child.media_content_id, let item = this._dataset.get(event.id);
if (!item) {
item = {
id: event.id,
group: camera, group: camera,
content: this._contentCallback?.(child) ?? '', content: this._contentCallback?.(child) ?? '',
start: child.frigate.event.start_time * 1000, start: event.start_time * 1000,
source: child, event: event,
}; };
if (child.frigate.event.end_time) { }
item['end'] = child.frigate.event.end_time * 1000; if (event.end_time) {
item['end'] = event.end_time * 1000;
item['type'] = 'range'; item['type'] = 'range';
} else { } else {
item['type'] = 'point'; 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); items.push(item);
} }
}); });
@@ -161,13 +169,14 @@ class TimelineEventManager {
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant & ExtendedHomeAssistant, hass: HomeAssistant & ExtendedHomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
media: 'all' | 'clips' | 'snapshots',
start: Date, start: Date,
end: Date, end: Date,
): Promise<boolean> { ): Promise<boolean> {
if (this.hasCoverage(start, end)) { if (this.hasCoverage(start, end)) {
return false; return false;
} }
await this._fetchEvents(element, hass, cameras, start, end); await this._fetchEvents(element, hass, cameras, media, start, end);
return true; return true;
} }
@@ -183,6 +192,7 @@ class TimelineEventManager {
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant & ExtendedHomeAssistant, hass: HomeAssistant & ExtendedHomeAssistant,
cameras: Map<string, CameraConfig>, cameras: Map<string, CameraConfig>,
media: 'all' | 'clips' | 'snapshots',
start: Date, start: Date,
end: Date, end: Date,
): Promise<void> { ): Promise<void> {
@@ -193,24 +203,25 @@ class TimelineEventManager {
this._dateEnd = end; this._dateEnd = end;
} }
const fetchCameraEvents = async (camera: string): Promise<void> => { const fetchCameraEvents = async (
camera: string,
mediaType: 'clips' | 'snapshots',
): Promise<void> => {
const cameraConfig = cameras.get(camera); const cameraConfig = cameras.get(camera);
if (!cameraConfig || !this._dateStart || !this._dateEnd) { if (!cameraConfig || !this._dateStart || !this._dateEnd) {
return; return;
} }
const browseMediaQueryParameters = BrowseMediaUtil.getBrowseMediaQueryParameters( const browseMediaQueryParametersBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase(
'clips',
cameraConfig, cameraConfig,
); );
if (!browseMediaQueryParameters) { if (!browseMediaQueryParametersBase) {
return; return;
} }
try { try {
this._addMediaSource( this._addMediaSource(
camera, camera,
await BrowseMediaUtil.browseMediaQuery(hass, { await BrowseMediaUtil.browseMediaQuery(hass, {
...browseMediaQueryParameters, ...browseMediaQueryParametersBase,
// Events are always fetched for the maximum extent of the managed // Events are always fetched for the maximum extent of the managed
// range. This is because events may change at any point in time // range. This is because events may change at any point in time
@@ -218,6 +229,7 @@ class TimelineEventManager {
before: this._dateEnd.getTime() / 1000, before: this._dateEnd.getTime() / 1000,
after: this._dateStart.getTime() / 1000, after: this._dateStart.getTime() / 1000,
unlimited: true, unlimited: true,
mediaType: mediaType,
}), }),
); );
} catch (e) { } catch (e) {
@@ -225,7 +237,15 @@ class TimelineEventManager {
} }
}; };
await Promise.all(Array.from(cameras.keys()).map(fetchCameraEvents.bind(this))); const promises: Promise<void>[] = [];
(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} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.config=${this.timelineConfig.controls.thumbnails} .config=${this.timelineConfig.controls.thumbnails}
.targetView=${'clip'}
> >
<frigate-card-timeline-core <frigate-card-timeline-core
.hass=${this.hass} .hass=${this.hass}
@@ -330,12 +349,13 @@ export class FrigateCardTimelineCore extends LitElement {
console.info( console.info(
`Range changed: ${properties.start} -> ${properties.end} [${this._events.dataset.length}]`, `Range changed: ${properties.start} -> ${properties.end} [${this._events.dataset.length}]`,
); );
if (this.hass && this.cameras && this._timeline) { if (this.hass && this.cameras && this._timeline && this.timelineConfig) {
this._events this._events
.fetchEventsIfNecessary( .fetchEventsIfNecessary(
this, this,
this.hass, this.hass,
this.cameras, this.cameras,
this.timelineConfig.media,
properties.start, properties.start,
properties.end, properties.end,
) )
@@ -367,7 +387,9 @@ export class FrigateCardTimelineCore extends LitElement {
if (!this._thumbnails || !this._thumbnails.children || data.items.length <= 0) { if (!this._thumbnails || !this._thumbnails.children || data.items.length <= 0) {
return; 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) { if (childIndex >= 0) {
this.view this.view
?.evolve({ ?.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. * Regenerate the thumbnails from the timeline events.
* @returns * @returns
@@ -401,10 +410,33 @@ export class FrigateCardTimelineCore extends LitElement {
return; return;
} }
const children: FrigateBrowseMediaSource[] = this._events.dataset const selected = this._timeline.getSelection();
.get() let childIndex = -1;
.filter((item) => BrowseMediaUtil.isTrueMedia(item.source)) const children: FrigateBrowseMediaSource[] = [];
.map((item) => item.source); 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) { if (!children.length) {
return; return;
} }
@@ -412,17 +444,16 @@ export class FrigateCardTimelineCore extends LitElement {
const target = { const target = {
title: `Timeline events`, title: `Timeline events`,
media_class: MEDIA_CLASS_PLAYLIST, media_class: MEDIA_CLASS_PLAYLIST,
media_content_type: MEDIA_TYPE_VIDEO, media_content_type: MEDIA_TYPE_PLAYLIST,
media_content_id: '', media_content_id: '',
can_play: false, can_play: false,
can_expand: true, can_expand: true,
children_media_class: MEDIA_CLASS_VIDEO, children_media_class: MEDIA_CLASS_PLAYLIST,
thumbnail: null, thumbnail: null,
children: children, children: children,
}; };
this._thumbnails = target; this._thumbnails = target;
const childIndex = this._findThumbnailIndex(this._timeline.getSelection());
// Update the thumbnail carousel with the regenerated thumbnails. // Update the thumbnail carousel with the regenerated thumbnails.
this.view this.view
@@ -523,11 +554,11 @@ export class FrigateCardTimelineCore extends LitElement {
// different object types together (e.g. person and car). // different object types together (e.g. person and car).
return ( return (
!!first.id && !!first.id &&
first.id !== this.view?.media?.media_content_id && first.id !== this.view?.media?.frigate?.event?.id &&
!!second.id && !!second.id &&
second.id != this.view?.media?.media_content_id && second.id != this.view?.media?.frigate?.event?.id &&
(<FrigateCardTimelineItem>first).source.frigate?.event.label === (<FrigateCardTimelineItem>first).event.label ===
(<FrigateCardTimelineItem>second).source.frigate?.event.label (<FrigateCardTimelineItem>second).event.label
); );
}, },
} }
@@ -573,9 +604,15 @@ export class FrigateCardTimelineCore extends LitElement {
*/ */
protected async _updateTimelineFromView(): Promise<void> { protected async _updateTimelineFromView(): Promise<void> {
const event = this.view?.media?.frigate?.event; 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; return;
} }
@@ -585,6 +622,7 @@ export class FrigateCardTimelineCore extends LitElement {
this, this,
this.hass, this.hass,
this.cameras, this.cameras,
this.timelineConfig.media,
eventWindowStart, eventWindowStart,
eventWindowEnd, eventWindowEnd,
) )
@@ -595,7 +633,7 @@ export class FrigateCardTimelineCore extends LitElement {
const eventStart = new Date(event.start_time * 1000); const eventStart = new Date(event.start_time * 1000);
const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0; const eventEnd = event.end_time ? new Date(event.end_time * 1000) : 0;
this._timeline.setSelection([id], { this._timeline.setSelection([event.id], {
focus: false, focus: false,
animation: { animation: {
animation: false, animation: false,
@@ -628,7 +666,7 @@ export class FrigateCardTimelineCore extends LitElement {
// Hack: Clustering may not update unless the dataset changes, artifically // Hack: Clustering may not update unless the dataset changes, artifically
// update the dataset to ensure the newly selected item cannot be included // update the dataset to ensure the newly selected item cannot be included
// in a cluster. // in a cluster.
const item = this._events.dataset.get(id); const item = this._events.dataset.get(event.id);
if (item) { if (item) {
this._events.dataset.updateOnly(item); this._events.dataset.updateOnly(item);
} }
+17 -20
View File
@@ -10,14 +10,14 @@ import { BrowseMediaUtil } from '../browse-media-util.js';
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { Task } from '@lit-labs/task'; import { Task } from '@lit-labs/task';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js'; import { ifDefined } from 'lit/directives/if-defined.js';
import { ref } from 'lit/directives/ref.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import type { import type {
BrowseMediaNeighbors, BrowseMediaNeighbors,
BrowseMediaQueryParameters, BrowseMediaQueryParametersBase,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
CameraConfig, CameraConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
@@ -25,7 +25,6 @@ import type {
TransitionEffect, TransitionEffect,
ViewerConfig, ViewerConfig,
} from '../types.js'; } from '../types.js';
import { CarouselSelect } from './carousel.js';
import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js'; import { FrigateCardMediaCarousel, IMG_EMPTY } from './media-carousel.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js'; import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js';
@@ -35,7 +34,6 @@ import {
contentsChanged, contentsChanged,
createMediaShowInfo, createMediaShowInfo,
dispatchErrorMessageEvent, dispatchErrorMessageEvent,
dispatchFrigateCardEvent,
stopEventFromActivatingCardWideActions, stopEventFromActivatingCardWideActions,
} from '../common.js'; } from '../common.js';
import { renderProgressIndicator } from '../components/message.js'; import { renderProgressIndicator } from '../components/message.js';
@@ -71,17 +69,21 @@ export class FrigateCardViewer extends LitElement {
return; return;
} }
const browseMediaQueryParameters = const browseMediaQueryParametersBase =
BrowseMediaUtil.getBrowseMediaQueryParametersOrDispatchError( BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError(
this, this,
this.view,
this.cameraConfig, this.cameraConfig,
); );
if (!this.view.target) {
const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView(
browseMediaQueryParametersBase,
this.view,
);
if (!browseMediaQueryParameters) { if (!browseMediaQueryParameters) {
return; return;
} }
if (!this.view.target) {
BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange( BrowseMediaUtil.fetchLatestMediaAndDispatchViewChange(
this, this,
this.hass, this.hass,
@@ -95,13 +97,12 @@ export class FrigateCardViewer extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.config=${this.viewerConfig.controls.thumbnails} .config=${this.viewerConfig.controls.thumbnails}
.browseMediaParams=${browseMediaQueryParameters}
> >
<frigate-card-viewer-carousel <frigate-card-viewer-carousel
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.viewerConfig=${this.viewerConfig} .viewerConfig=${this.viewerConfig}
.browseMediaQueryParameters=${browseMediaQueryParameters} .browseMediaQueryParametersBase=${browseMediaQueryParametersBase}
.resolvedMediaCache=${this.resolvedMediaCache} .resolvedMediaCache=${this.resolvedMediaCache}
> >
</frigate-card-viewer-carousel> </frigate-card-viewer-carousel>
@@ -133,7 +134,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
protected viewerConfig?: ViewerConfig; protected viewerConfig?: ViewerConfig;
@property({ attribute: false }) @property({ attribute: false })
protected browseMediaQueryParameters?: BrowseMediaQueryParameters; protected browseMediaQueryParametersBase?: BrowseMediaQueryParametersBase;
@property({ attribute: false }) @property({ attribute: false })
protected resolvedMediaCache?: ResolvedMediaCache; protected resolvedMediaCache?: ResolvedMediaCache;
@@ -267,16 +268,11 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
? this._lazyloadSlide.bind(this) ? this._lazyloadSlide.bind(this)
: undefined, : undefined,
}), }),
// Don't need autoplay/pause for snapshots.
...(this.view?.is('clip')
? [
AutoMediaPlugin({ AutoMediaPlugin({
playerSelector: 'frigate-card-ha-hls-player', playerSelector: 'frigate-card-ha-hls-player',
autoPlayWhenVisible: !!this.viewerConfig?.auto_play, autoPlayWhenVisible: !!this.viewerConfig?.auto_play,
autoUnmuteWhenVisible: !!this.viewerConfig?.auto_unmute, autoUnmuteWhenVisible: !!this.viewerConfig?.auto_unmute,
}), }),
]
: []),
]; ];
} }
@@ -338,7 +334,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
!this.view.target || !this.view.target ||
!this.view.target.children || !this.view.target.children ||
!this.view.target.children.length || !this.view.target.children.length ||
!this.browseMediaQueryParameters !this.browseMediaQueryParametersBase
) { ) {
return null; return null;
} }
@@ -381,7 +377,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
try { try {
clips = await BrowseMediaUtil.browseMediaQuery(this.hass, { clips = await BrowseMediaUtil.browseMediaQuery(this.hass, {
...this.browseMediaQueryParameters, ...this.browseMediaQueryParametersBase,
mediaType: 'clips', mediaType: 'clips',
before: latest, before: latest,
after: earliest, after: earliest,
@@ -628,7 +624,8 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
if ( if (
!this.view || !this.view ||
!this.viewerConfig || !this.viewerConfig ||
!BrowseMediaUtil.isTrueMedia(mediaToRender) !BrowseMediaUtil.isTrueMedia(mediaToRender) ||
!['video', 'image'].includes(mediaToRender.media_content_type)
) { ) {
return; return;
} }
@@ -641,7 +638,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
return html` return html`
<div class="embla__slide"> <div class="embla__slide">
${this.view.isClipRelatedView() ${mediaToRender.media_content_type === 'video'
? html`<frigate-card-ha-hls-player ? html`<frigate-card-ha-hls-player
allow-exoplayer allow-exoplayer
aria-label="${mediaToRender.title}" aria-label="${mediaToRender.title}"
-4
View File
@@ -10,7 +10,3 @@ frigate-card-live-carousel {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
frigate-card-thumbnail-carousel {
flex: 0 0 var(--frigate-card-carousel-thumbnail-size);
}
+17 -5
View File
@@ -35,7 +35,14 @@ const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
'timeline', 'timeline',
] as const; ] as const;
export type FrigateCardView = typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number]; const FRIGATE_CARD_VIEWS = [
...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
// Event: A clip or snapshot (timeline may produce mixed media lists).
'event',
] as const;
export type FrigateCardView = typeof FRIGATE_CARD_VIEWS[number];
const FRIGATE_MENU_MODES = [ const FRIGATE_MENU_MODES = [
'none', 'none',
@@ -755,6 +762,7 @@ const dimensionsConfigSchema = z
*/ */
const timelineConfigDefault = { const timelineConfigDefault = {
clustering_threshold: 3, clustering_threshold: 3,
media: 'all' as const,
controls: { controls: {
thumbnails: { thumbnails: {
mode: 'left' as const, mode: 'left' as const,
@@ -766,6 +774,7 @@ const timelineConfigDefault = {
const timelineConfigSchema = z const timelineConfigSchema = z
.object({ .object({
clustering_threshold: z.number().default(timelineConfigDefault.clustering_threshold), clustering_threshold: z.number().default(timelineConfigDefault.clustering_threshold),
media: z.enum(['all', 'clips', 'snapshots']).default(timelineConfigDefault.media),
controls: z controls: z
.object({ .object({
thumbnails: thumbnailsControlSchema thumbnails: thumbnailsControlSchema
@@ -867,8 +876,8 @@ export interface ExtendedHomeAssistant {
hassUrl(path?): string; hassUrl(path?): string;
} }
export interface BrowseMediaQueryParameters { export interface BrowseMediaQueryParametersBase {
mediaType: 'clips' | 'snapshots'; mediaType?: 'clips' | 'snapshots';
clientId: string; clientId: string;
cameraName: string; cameraName: string;
label?: string; label?: string;
@@ -878,6 +887,10 @@ export interface BrowseMediaQueryParameters {
unlimited?: boolean; unlimited?: boolean;
} }
export interface BrowseMediaQueryParameters extends BrowseMediaQueryParametersBase {
mediaType: 'clips' | 'snapshots';
}
export interface BrowseMediaNeighbors { export interface BrowseMediaNeighbors {
previous: FrigateBrowseMediaSource | null; previous: FrigateBrowseMediaSource | null;
previousIndex: number | null; previousIndex: number | null;
@@ -920,8 +933,7 @@ export interface FrigateCardMediaPlayer {
*/ */
export const MEDIA_CLASS_PLAYLIST = 'playlist' as const; export const MEDIA_CLASS_PLAYLIST = 'playlist' as const;
export const MEDIA_CLASS_VIDEO = 'video' as const; export const MEDIA_TYPE_PLAYLIST = 'playlist' as const;
export const MEDIA_TYPE_VIDEO = 'video' as const;
// Recursive type, cannot use type interference: // Recursive type, cannot use type interference:
// See: https://github.com/colinhacks/zod#recursive-types // See: https://github.com/colinhacks/zod#recursive-types
+3 -4
View File
@@ -83,22 +83,21 @@ export class View {
* Determine if a view is of a piece of media (i.e. not the gallery). * Determine if a view is of a piece of media (i.e. not the gallery).
*/ */
public isMediaView(): boolean { public isMediaView(): boolean {
return !this.isGalleryView(); return this.isViewerView() || this.is('live');
} }
/** /**
* Determine if a view is for the media viewer. * Determine if a view is for the media viewer.
*/ */
public isViewerView(): boolean { public isViewerView(): boolean {
return ['clip', 'snapshot'].includes(this.view); return ['clip', 'snapshot', 'event'].includes(this.view);
} }
/** /**
* Determine if a view is related to a clip or clips. * Determine if a view is related to a clip or clips.
*/ */
public isClipRelatedView(): boolean { public isClipRelatedView(): boolean {
// TODO HACK HACK HACK return ['clip', 'clips'].includes(this.view);
return ['clip', 'clips', 'timeline'].includes(this.view);
} }
/** /**