diff --git a/README.md b/README.md index 9a9900a9..1383d5e9 100644 --- a/README.md +++ b/README.md @@ -483,6 +483,7 @@ See the [fully expanded timeline configuration example](#config-expanded-timelin | `window_seconds` | `3600` | :heavy_multiplication_x: | The length of the default timeline in seconds. By default, 1 hour (`3600` seconds) is shown in the timeline. | | `clustering_threshold` | `3` | :heavy_multiplication_x: | The number of overlapping events to allow prior to clustering/grouping them. Higher numbers cause clustering to happen less frequently. `0` disables clustering entirely.| | `media` | `all` | :heavy_multiplication_x: | Whether to show only events with `clips`, events with `snapshots` or `all` events. When `all` is used, `clips` are favored for events that have both a clip and a snapshot.| +| `show_recordings` | `true` | :heavy_multiplication_x: | Whether to show recordings on the timeline (specifically: which hours have any recorded content).| | `controls` | | :heavy_multiplication_x: | Configuration for the timeline controls. See below.| | `actions` | | :heavy_multiplication_x: | Actions to use for the `timeline` views. See [actions](#actions) below.| @@ -1617,6 +1618,7 @@ Reference: [Timeline Options](#timeline-options). timeline: clustering_threshold: 3 media: all + show_recordings: true window_seconds: 3600 controls: thumbnails: diff --git a/src/card.ts b/src/card.ts index e0a2f98c..3253d076 100644 --- a/src/card.ts +++ b/src/card.ts @@ -975,7 +975,7 @@ export class FrigateCard extends LitElement { * @returns A boolean indicating whether the camera was changed. */ protected _updateTriggeredCameras(oldHass: HomeAssistant): boolean { - if (!this._view) { + if (!this._view || !this._isAutomatedViewUpdateAllowed(true)) { return false; } @@ -1001,7 +1001,7 @@ export class FrigateCard extends LitElement { } } - if (triggerChanges && this._isAutomatedViewUpdateAllowed(true)) { + if (triggerChanges) { if (!this._triggers.size) { this._changeView(); changedCamera = true; diff --git a/src/components/elements.ts b/src/components/elements.ts index e815a5fe..10960763 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -12,6 +12,7 @@ import { ConditionState, fetchStateAndEvaluateCondition } from '../card-conditio import { localize } from '../localize/localize.js'; import elementsStyle from '../scss/elements.scss'; import { + FrigateCardError, FrigateConditional, MenuButton, MenuIcon, @@ -21,7 +22,7 @@ import { PictureElements } from '../types.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; -import { dispatchErrorMessageEvent } from './message.js'; +import { dispatchFrigateCardErrorEvent } from './message.js'; /* A note on picture element rendering: * @@ -108,7 +109,7 @@ export class FrigateCardElementsCore extends LitElement { element.setConfig(config); } catch (e) { console.error(e, (e as Error).stack); - throw new Error(localize('error.invalid_elements_config')); + throw new FrigateCardError(localize('error.invalid_elements_config')); } return element; } @@ -125,7 +126,7 @@ export class FrigateCardElementsCore extends LitElement { this._root = this._createRoot(); } } catch (e) { - return dispatchErrorMessageEvent(this, (e as Error).message); + return dispatchFrigateCardErrorEvent(this, e as FrigateCardError); } } diff --git a/src/components/live.ts b/src/components/live.ts index 8cf3c6f0..9b408375 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -612,6 +612,13 @@ export class FrigateCardLiveProvider extends LitElement { this._providerRef.value?.unmute(); } + /** + * Seek the video. + */ + public seek(seconds: number): void { + this._providerRef.value?.seek(seconds); + } + protected _getResolvedProvider(): LiveProvider { if (this.cameraConfig?.live_provider === 'auto') { if ( @@ -711,6 +718,13 @@ export class FrigateCardLiveFrigate extends LitElement { this._playerRef.value?.unmute(); } + /** + * Seek the video. + */ + public seek(seconds: number): void { + this._playerRef.value?.seek(seconds); + } + /** * Master render method. * @returns A rendered template. @@ -810,6 +824,16 @@ export class FrigateCardLiveWebRTCCard extends LitElement { } } + /** + * Seek the video. + */ + public seek(seconds: number): void { + const player = this._getPlayer(); + if (player) { + player.currentTime = seconds; + } + } + /** * Get the underlying video player. * @returns The player or `null` if not found. @@ -867,8 +891,9 @@ export class FrigateCardLiveWebRTCCard extends LitElement { return dispatchErrorMessageEvent( this, e instanceof FrigateCardError - ? (e as FrigateCardError).message + ? e.message : localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message, + (e as FrigateCardError).context ); } return html`${webrtcElement}`; @@ -964,6 +989,14 @@ export class FrigateCardLiveJSMPEG extends LitElement { } } + /** + * Seek the video (unsupported). + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public seek(_seconds: number): void { + // JSMPEG does not support seeking. + } + /** * Get a signed player URL. * @returns A URL or null. diff --git a/src/components/message.ts b/src/components/message.ts index 12547788..74156297 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -3,7 +3,7 @@ import { customElement, property } from 'lit/decorators.js'; import { TROUBLESHOOTING_URL } from '../const.js'; import { localize } from '../localize/localize.js'; import messageStyle from '../scss/message.scss'; -import { Message } from '../types.js'; +import { FrigateCardError, Message } from '../types.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; @customElement('frigate-card-message') @@ -140,3 +140,19 @@ export function dispatchErrorMessageEvent( context: context, }); } + +/** + * Dispatch an event with an error message to show to the user. + * @param element The element to send the event. + * @param message The message to show. + */ +export function dispatchFrigateCardErrorEvent( + element: HTMLElement, + error: FrigateCardError +): void { + dispatchFrigateCardEvent(element, 'message', { + message: error.message, + type: 'error', + context: error.context || '' + }); +} diff --git a/src/components/surround-thumbnails.ts b/src/components/surround-thumbnails.ts index 1f995202..8089a37d 100644 --- a/src/components/surround-thumbnails.ts +++ b/src/components/surround-thumbnails.ts @@ -12,6 +12,7 @@ import surroundThumbnailsStyle from '../scss/surround.scss'; import { BrowseMediaQueryParameters, FrigateBrowseMediaSource, + FrigateCardError, FrigateCardView, ThumbnailsControlConfig } from '../types.js'; @@ -21,7 +22,7 @@ import { multipleBrowseMediaQueryMerged } from '../utils/ha/browse-media'; import { View } from '../view.js'; -import { dispatchErrorMessageEvent } from './message.js'; +import { dispatchFrigateCardErrorEvent } from './message.js'; import './surround.js'; import { ThumbnailCarouselTap } from './thumbnail-carousel.js'; @@ -69,7 +70,7 @@ export class FrigateCardSurround extends LitElement { try { parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams); } catch (e) { - return dispatchErrorMessageEvent(this, (e as Error).message); + return dispatchFrigateCardErrorEvent(this, e as FrigateCardError); } if (getFirstTrueMediaChildIndex(parent) !== null) { this.view @@ -149,6 +150,7 @@ export class FrigateCardSurround extends LitElement { view: this.targetView || 'event', target: ev.detail.target, childIndex: ev.detail.childIndex, + context: null, }) .dispatchChangeEvent(this); }} diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index d5315c9c..1043dfbe 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -3,8 +3,14 @@ import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { localize } from '../localize/localize.js'; import thumbnailDetailsStyle from '../scss/thumbnail-details.scss'; +import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss'; +import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss'; import thumbnailStyle from '../scss/thumbnail.scss'; -import type { FrigateBrowseMediaSource, FrigateEvent } from '../types.js'; +import type { + FrigateBrowseMediaSource, + FrigateEvent, + FrigateRecording, +} from '../types.js'; import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; import { prettifyTitle } from '../utils/basic.js'; import { getEventDurationString } from '../utils/ha/browse-media.js'; @@ -13,8 +19,49 @@ import { View } from '../view.js'; // The minimum width of a thumbnail with details enabled. export const THUMBNAIL_DETAILS_WIDTH_MIN = 300; -@customElement('frigate-card-thumbnail-details') -export class FrigateCardThumbnailDetails extends LitElement { +@customElement('frigate-card-thumbnail-feature-event') +export class FrigateCardThumbnailFeatureEvent extends LitElement { + @property({ attribute: false }) + public thumbnail?: string; + + protected render(): TemplateResult | void { + return html` + ${this.thumbnail + ? html`` + : html` `} + `; + } + + static get styles(): CSSResult { + return unsafeCSS(thumbnailFeatureEventStyle); + } +} + +@customElement('frigate-card-thumbnail-feature-recording') +export class FrigateCardThumbnailFeatureRecording extends LitElement { + @property({ attribute: false }) + public date?: Date; + + protected render(): TemplateResult | void { + if (!this.date) { + return; + } + return html` +
${format(this.date, 'HH:mm')}
+
${format(this.date, 'MMM do')}
+ `; + } + + static get styles(): CSSResult { + return unsafeCSS(thumbnailFeatureRecordingStyle); + } +} + +@customElement('frigate-card-thumbnail-details-event') +export class FrigateCardThumbnailDetailsEvent extends LitElement { @property({ attribute: false }) public event?: FrigateEvent; @@ -39,9 +86,29 @@ export class FrigateCardThumbnailDetails extends LitElement { `; } - /** - * Get element styles. - */ + static get styles(): CSSResult { + return unsafeCSS(thumbnailDetailsStyle); + } +} + +@customElement('frigate-card-thumbnail-details-recording') +export class FrigateCardThumbnailDetailsRecording extends LitElement { + @property({ attribute: false }) + public recording?: FrigateRecording; + + protected render(): TemplateResult | void { + if (!this.recording) { + return; + } + return html`
+
${prettifyTitle(this.recording.camera) || ''}
+
+
+ ${this.recording.events} + ${localize('recording.events')} +
`; + } + static get styles(): CSSResult { return unsafeCSS(thumbnailDetailsStyle); } @@ -90,6 +157,7 @@ export class FrigateCardThumbnail extends LitElement { */ protected render(): TemplateResult | void { let event: FrigateEvent | null = null; + let recording: FrigateRecording | null = null; let thumbnail: string | null = null; let label: string | null = null; @@ -97,6 +165,7 @@ export class FrigateCardThumbnail extends LitElement { if (this.target && this.target.children && this.childIndex !== undefined) { const media = this.target.children[this.childIndex]; event = media.frigate?.event ?? null; + recording = media.frigate?.recording ?? null; thumbnail = media.thumbnail; label = media.title; } @@ -108,34 +177,46 @@ export class FrigateCardThumbnail extends LitElement { thumbnail = this.thumbnail ? this.thumbnail : thumbnail; label = this.label ? this.label : label; - if (!thumbnail) { + if (!event && !recording) { return; } - return html` - ${this.controls && event?.retain_indefinitely - ? html` ` + : html``} + ${this.controls && event?.retain_indefinitely + ? html` ` - : ``} - ${this.details && event - ? html`` - : html``} - ${this.controls - ? html` { - stopEventFromActivatingCardWideActions(ev); + />` + : ``} + ${this.details && event + ? html`` + : this.details && recording + ? html`` + : html``} + ${this.controls + ? html` { + stopEventFromActivatingCardWideActions(ev); + if (event) { this.view ?.evolve({ view: 'timeline', @@ -144,9 +225,24 @@ export class FrigateCardThumbnail extends LitElement { context: {}, }) .dispatchChangeEvent(this); - }} - >` - : ''}`; + } else if (recording) { + this.view + ?.evolve({ + view: 'timeline', + target: null, + childIndex: null, + context: { + window: { + start: fromUnixTime(recording.start_time), + end: fromUnixTime(recording.end_time), + }, + }, + }) + .dispatchChangeEvent(this); + } + }} + >` + : ''}`; } /** diff --git a/src/components/timeline.ts b/src/components/timeline.ts index 3b8ba9bf..00d1c8e4 100644 --- a/src/components/timeline.ts +++ b/src/components/timeline.ts @@ -1,5 +1,15 @@ +// TODO: In viewer, the seek is being applied to the 2nd media. Change away from play_time? + import { HomeAssistant } from 'custom-card-helpers'; -import { add, fromUnixTime, sub } from 'date-fns'; +import { + add, + endOfHour, + format, + fromUnixTime, + getUnixTime, + startOfHour, + sub +} from 'date-fns'; import { CSSResultGroup, html, @@ -29,32 +39,44 @@ import timelineStyle from '../scss/timeline.scss'; import { BrowseMediaQueryParameters, CameraConfig, + ExtendedHomeAssistant, FrigateBrowseMediaSource, frigateCardConfigDefaults, + FrigateCardError, FrigateEvent, TimelineConfig } from '../types'; -import { stopEventFromActivatingCardWideActions } from '../utils/action.js'; -import { dispatchFrigateCardEvent } from '../utils/basic.js'; +import { stopEventFromActivatingCardWideActions } from '../utils/action'; +import { dispatchFrigateCardEvent, prettifyTitle } from '../utils/basic'; import { getCameraTitle } from '../utils/camera.js'; +import { + getRecordingSegments, + getRecordingsSummary, + RecordingSegments, + RecordingSummary +} from '../utils/frigate'; import { createEventParentForChildren, + createVideoChild, + generateRecordingIdentifier, getBrowseMediaQueryParameters, isTrueMedia, multipleBrowseMediaQuery } from '../utils/ha/browse-media'; import { View, ViewContext } from '../view'; -import { dispatchErrorMessageEvent, dispatchMessageEvent } from './message.js'; +import { dispatchFrigateCardErrorEvent, dispatchMessageEvent } from './message.js'; import './surround-thumbnails.js'; -const TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS = 10; +const TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS = 10; interface FrigateCardGroupData { id: string; content: string; } interface FrigateCardTimelineItem extends TimelineItem { - event: FrigateEvent; + start: number; + end?: number; + event?: FrigateEvent; source?: FrigateBrowseMediaSource; } @@ -68,12 +90,17 @@ interface TimelineViewContext extends ViewContext { type TimelineMediaType = 'all' | 'clips' | 'snapshots'; +interface CameraRecordings { + segments: RecordingSegments; + summary: RecordingSummary; +} + const isHoverableDevice = window.matchMedia('(hover: hover) and (pointer: fine)'); /** * A manager to maintain/fetch timeline events. */ -class TimelineEventManager { +class TimelineDataManager { protected _dataset = new DataSet(); // The earliest date managed. @@ -87,7 +114,7 @@ class TimelineEventManager { // The maximum allowable age of fetch data (will not fetch more frequently // than this). - protected _maxAgeSeconds: number = TIMELINE_EVENT_MANAGER_MAX_AGE_SECONDS; + protected _maxAgeSeconds: number = TIMELINE_DATA_MANAGER_MAX_AGE_SECONDS; protected _contentCallback?: (source: FrigateBrowseMediaSource) => string; protected _tooltipCallback?: (source: FrigateBrowseMediaSource) => string; @@ -183,9 +210,7 @@ class TimelineEventManager { * @param end An optional end of the date range. * @returns */ - public hasCoverage(start: Date, end?: Date): boolean { - const now = new Date().getTime(); - + public hasCoverage(now: Date, start: Date, end?: Date): boolean { // Never fetched: no coverage. if (!this._dateFetch || !this._dateStart || !this._dateEnd) { return false; @@ -194,7 +219,7 @@ class TimelineEventManager { // If the most recent fetch is older than maxAgeSeconds: no coverage. if ( this._maxAgeSeconds && - now - this._dateFetch.getTime() > this._maxAgeSeconds * 1000 + now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000 ) { return false; } @@ -219,7 +244,7 @@ class TimelineEventManager { return false; } // If the requested end time is beyond `_maxAgeSeconds` of now: no coverage. - if (now - end.getTime() > this._maxAgeSeconds * 1000) { + if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) { return false; } @@ -237,21 +262,108 @@ class TimelineEventManager { * @param end Fetch events that start earlier than this date. * @returns `true` if events were fetched, `false` otherwise. */ - public async fetchEventsIfNecessary( + public async fetchIfNecessary( element: HTMLElement, - hass: HomeAssistant, + hass: ExtendedHomeAssistant, cameras: Map, - media: TimelineMediaType, + eventMedia: TimelineMediaType, start: Date, end: Date, + recordings?: boolean, ): Promise { - if (this.hasCoverage(start, end)) { + const now = new Date(); + if (this.hasCoverage(now, start, end)) { return false; } - await this._fetchEvents(element, hass, cameras, media, start, end); + + // Cannot fetch the future. + end = end > now ? now : end; + + if (!this._dateStart || start < this._dateStart) { + this._dateStart = start; + } + if (!this._dateEnd || end > this._dateEnd) { + this._dateEnd = end; + } + this._dateFetch = new Date(); + + await Promise.all([ + // 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). + this._fetchEvents( + element, + hass, + cameras, + eventMedia, + this._dateStart, + this._dateEnd, + ), + ...(recordings ? [this._fetchRecordings(element, hass, cameras)] : []), + ]); + return true; } + /** + * Fetch recording hours 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 _fetchRecordings( + element: HTMLElement, + hass: ExtendedHomeAssistant, + cameras: Map, + ): Promise { + const items: FrigateCardTimelineItem[] = []; + const now = new Date(); + + const storeRecordings = async ( + camera: string, + config: CameraConfig, + ): Promise => { + if (!config.camera_name) { + return; + } + let summary: RecordingSummary; + try { + summary = await getRecordingsSummary(hass, config.client_id, config.camera_name); + } catch (e) { + return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); + } + + for (const dayData of summary) { + for (const hourData of dayData.hours) { + const hour = add(dayData.day, { hours: hourData.hour }); + const endHour = endOfHour(hour); + items.push({ + id: `recording-${camera}-${format(hour, 'yyyy-MM-dd-HH')}`, + group: camera, + start: getUnixTime(startOfHour(hour)) * 1000, + + // Don't let the recordings show off into the future (even though it + // is intended to be indicative of any recordings within that hour + // -- it still looks strange!) + end: (endHour > now ? getUnixTime(now) : getUnixTime(endHour)) * 1000, + type: 'background', + content: '', + }); + } + } + }; + + await Promise.all( + Array.from(cameras.entries()).map(([camera, config]: [string, CameraConfig]) => + storeRecordings(camera, config), + ), + ); + + this._dataset.update(items); + } + /** * Fetch events for the timeline. * @param element The element to send error events from. @@ -265,34 +377,16 @@ class TimelineEventManager { hass: HomeAssistant, cameras: Map, media: TimelineMediaType, - start?: Date, - end?: Date, + start: Date, + end: Date, ): Promise { - if (!this._dateStart || (start && start < this._dateStart)) { - this._dateStart = start; - } - if (!this._dateEnd || (end && end > this._dateEnd)) { - this._dateEnd = end; - } - if (!this._dateStart || !this._dateEnd) { - return; - } - this._dateFetch = new Date(); - const params: BrowseMediaQueryParameters[] = []; cameras.forEach((cameraConfig, cameraID) => { (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => { - if ( - this._dateEnd && - this._dateStart && - cameraConfig.camera_name !== CAMERA_BIRDSEYE - ) { + if (cameraConfig.camera_name !== CAMERA_BIRDSEYE) { const param = getBrowseMediaQueryParameters(hass, cameraID, cameraConfig, { - // 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, + before: end.getTime() / 1000, + after: start.getTime() / 1000, unlimited: true, mediaType: mediaType as 'clips' | 'snapshots', }); @@ -311,7 +405,7 @@ class TimelineEventManager { try { results = await multipleBrowseMediaQuery(hass, params); } catch (e) { - return dispatchErrorMessageEvent(element, (e as Error).message); + return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); } for (const [query, result] of results.entries()) { @@ -325,7 +419,7 @@ class TimelineEventManager { @customElement('frigate-card-timeline') export class FrigateCardTimeline extends LitElement { @property({ attribute: false }) - protected hass?: HomeAssistant; + protected hass?: ExtendedHomeAssistant; @property({ attribute: false }) protected view?: Readonly; @@ -371,7 +465,7 @@ export class FrigateCardTimeline extends LitElement { @customElement('frigate-card-timeline-core') export class FrigateCardTimelineCore extends LitElement { @property({ attribute: false }) - protected hass?: HomeAssistant; + protected hass?: ExtendedHomeAssistant; @property({ attribute: false }) protected view?: Readonly; @@ -382,12 +476,17 @@ export class FrigateCardTimelineCore extends LitElement { @property({ attribute: false }) protected timelineConfig?: TimelineConfig; - protected _events = new TimelineEventManager({ + protected _data = new TimelineDataManager({ tooltipCallback: this._getTooltip.bind(this), }); protected _refTimeline: Ref = createRef(); protected _timeline?: Timeline; + // Need a way to separate when a user clicks (to pan the timeline) vs when a + // user clicks (to choose a recording (non-event) to play). On pan, + // _wasDragged will be set to true, and the click subsequently ignored. + protected _wasDragged = false; + /** * Get a tooltip for a given timeline event. * @param source The FrigateBrowseMediaSource in question. @@ -446,12 +545,206 @@ export class FrigateCardTimelineCore extends LitElement { >`; } + /** + * Get the number of seconds to seek into a video stream consisting of the + * provided segments to reach the target time provided. + * @param time Target time. + * @param segments A RecordingSegments object. + * @returns + */ + protected _getSeekTime(time: Date, segments: RecordingSegments): number | null { + if (!segments.length) { + return null; + } + const target = getUnixTime(time); + const hourStart = getUnixTime(startOfHour(time)); + let seekSeconds = 0; + + // Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27 + for (const segment of segments) { + if (segment.start_time > target) { + break; + } + const start = segment.start_time < hourStart ? hourStart : segment.start_time; + const end = segment.end_time > target ? target : segment.end_time; + seekSeconds += end - start; + } + return seekSeconds; + } + + /** + * Create recording objects. + * @param results A map of camera ID to a CameraRecordings object. + * @param time The target time for the recordings. + * @param onlyMatchingHour If `true` only shows the hour matching the target + * for the provided cameras, otherwise shows all hours. + * @returns + */ + protected _createRecordingChildren( + results: Map, + time: Date, + onlyMatchingHour: boolean, + ): FrigateBrowseMediaSource[] { + const children: FrigateBrowseMediaSource[] = []; + const processedCameras: Set = new Set(); + + for (const [camera, recording] of results.entries()) { + const config = this.cameras?.get(camera); + if (!config?.camera_name) { + continue; + } + + // There is a single set of recordings for a given Frigate camera name. + // Zones on that same camera do not get separate recordings. The card may + // have multiple instances of the same camera for different zoness, so + // need to enforce uniqueness here. + const uniqueID = `${config.client_id}/${config.camera_name}`; + if (processedCameras.has(uniqueID)) { + continue; + } + processedCameras.add(uniqueID); + + const seekSeconds = this._getSeekTime(time, recording.segments); + if (seekSeconds === null) { + continue; + } + + for (const dayData of recording.summary) { + for (const hourData of dayData.hours) { + const hour = add(dayData.day, { hours: hourData.hour }); + const startHour = startOfHour(hour); + const endHour = endOfHour(hour); + const isMatchingHour = time >= startHour && time <= endHour; + + if (!onlyMatchingHour || isMatchingHour) { + children.push( + createVideoChild( + `${prettifyTitle(config.camera_name)} ${format( + hour, + 'yyyy-MM-dd HH:mm', + )}`, + generateRecordingIdentifier({ + clientId: config.client_id, + year: dayData.day.getFullYear(), + month: dayData.day.getMonth() + 1, + day: dayData.day.getDate(), + hour: hourData.hour, + cameraName: config.camera_name, + }), + { + recording: { + camera: config.camera_name, + start_time: getUnixTime(startHour), + end_time: getUnixTime(endHour), + events: hourData.events, + ...(isMatchingHour && { play_time: seekSeconds }), + }, + }, + ), + ); + } + } + } + } + return children; + } + + /** + * Change the view to a recording. + * @param time The time of the recording to show. + * @param camera An optional camera to show a recording of, otherwise all + * cameras are shown at the given time. + */ + protected async _changeViewToRecording(time: Date, camera?: string): Promise { + if (!this.hass) { + return; + } + + const before = endOfHour(time); + const after = startOfHour(time); + const results: Map = new Map(); + + const fetch = async (camera: string, config?: CameraConfig): Promise => { + if (!config || !config.camera_name || !this.hass) { + return; + } + + try { + const cameraResults = await Promise.all([ + getRecordingSegments( + this.hass, + config.client_id, + config.camera_name, + before, + after, + ), + getRecordingsSummary(this.hass, config.client_id, config.camera_name), + ]); + results.set(camera, { segments: cameraResults[0], summary: cameraResults[1] }); + } catch (e) {} + }; + const cameras = camera ? [camera] : [...(this.cameras?.keys() ?? [])]; + await Promise.all(cameras.map((camera) => fetch(camera, this.cameras?.get(camera)))); + + const children = this._createRecordingChildren(results, time, !camera); + if (!children.length) { + return; + } + + let childIndex = 0; + if (camera) { + childIndex = children.findIndex( + (child) => + child.frigate?.recording && + child.frigate.recording.start_time * 1000 === after.getTime(), + ); + if (childIndex < 0) { + return; + } + } + + this.view + ?.evolve({ + view: 'event', + target: createEventParentForChildren(localize('common.recordings'), children), + childIndex: childIndex, + }) + .dispatchChangeEvent(this); + } + + /** + * Called whenever the range is in the process of being changed. + * @param properties + */ + protected _timelineRangeChangeHandler( + properties: TimelineEventPropertiesResult, + ): void { + if (properties.event) { + // When a human changes the range, an event will be set. + this._wasDragged = true; + } + } + + /** + * Called whenever the timeline is clicked. + * @param properties The properties of the timeline click event. + */ protected _timelineClickHandler(properties: TimelineEventPropertiesResult): void { - if (properties.what === 'item') { + if (properties.what && ['item', 'background'].includes(properties.what)) { // Prevent interaction with items on the timeline from activating card // wide actions. stopEventFromActivatingCardWideActions(properties.event); } + + if (!this._wasDragged && properties.what) { + if (['background', 'group-label'].includes(properties.what)) { + this._changeViewToRecording(properties.time, String(properties.group)); + } else if (properties.what === 'axis') { + this._changeViewToRecording(properties.time); + } + } + + this._wasDragged = false; } /** @@ -468,14 +761,15 @@ export class FrigateCardTimelineCore extends LitElement { return; } if (this.hass && this.cameras && this._timeline && this.timelineConfig) { - this._events - .fetchEventsIfNecessary( + this._data + .fetchIfNecessary( this, this.hass, this.cameras, this.timelineConfig.media, properties.start, properties.end, + this.timelineConfig.show_recordings, ) .then(() => { if (this._timeline) { @@ -507,7 +801,7 @@ export class FrigateCardTimelineCore extends LitElement { const childIndex = data.items.length ? this.view.target.children.findIndex( - (child) => child.frigate?.event.id === data.items[0], + (child) => child.frigate?.event?.id === data.items[0], ) : null; @@ -560,8 +854,8 @@ export class FrigateCardTimelineCore extends LitElement { const selected = this._timeline.getSelection(); let childIndex = -1; const children: FrigateBrowseMediaSource[] = []; - this._events.dataset.get({ order: sortEvent }).forEach((item) => { - if (item.source) { + this._data.dataset.get({ order: sortEvent }).forEach((item) => { + if (item.event && item.source) { children.push(item.source); if (selected.includes(item.event.id)) { childIndex = children.length - 1; @@ -572,9 +866,8 @@ export class FrigateCardTimelineCore extends LitElement { return null; } - const target = createEventParentForChildren('Timeline events', children); return { - target: target, + target: createEventParentForChildren('Timeline events', children), childIndex: childIndex < 0 ? null : childIndex, }; } @@ -695,12 +988,14 @@ export class FrigateCardTimelineCore extends LitElement { // Never include the target media in a cluster, and never group // different object types together (e.g. person and car). return ( + [first.type, second.type].every((type) => type !== 'background') && + first.type === second.type && !!first.id && first.id !== this.view?.media?.frigate?.event?.id && !!second.id && second.id != this.view?.media?.frigate?.event?.id && - (first).event.label === - (second).event.label + (first).event?.label === + (second).event?.label ); }, } @@ -750,13 +1045,7 @@ export class FrigateCardTimelineCore extends LitElement { * Update the timeline from the view object. */ protected async _updateTimelineFromView(): Promise { - if ( - !this.hass || - !this.cameras || - !this.view || - !this._timeline || - !this.timelineConfig - ) { + if (!this.hass || !this.cameras || !this.view || !this.timelineConfig) { return; } @@ -765,15 +1054,20 @@ export class FrigateCardTimelineCore extends LitElement { ? this._getStartEndFromEvent(event) : this._getStartEnd(); - await this._events.fetchEventsIfNecessary( + await this._data.fetchIfNecessary( this, this.hass, this.cameras, this.timelineConfig.media, windowStart, windowEnd, + this.timelineConfig.show_recordings, ); + if (!this._timeline) { + return; + } + this._timeline.setSelection(event ? [event.id] : [], { focus: false, animation: { @@ -807,9 +1101,9 @@ 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(event.id); + const item = this._data.dataset.get(event.id); if (item) { - this._events.dataset.updateOnly(item); + this._data.dataset.updateOnly(item); } } } else { @@ -825,7 +1119,7 @@ export class FrigateCardTimelineCore extends LitElement { // -> New view dispatched (to load thumbnails into outer carousel). // -> New view received ... [loop] const currentContext = this.view.context as TimelineViewContext | null; - if (currentContext?.dateFetch !== this._events.lastFetchDate) { + if (currentContext?.dateFetch !== this._data.lastFetchDate) { const thumbnails = this._generateThumbnails(); this.view ?.evolve({ @@ -851,8 +1145,8 @@ export class FrigateCardTimelineCore extends LitElement { } else if (currentContext?.window) { newContext.window = currentContext.window; } - if (this._events.lastFetchDate) { - newContext.dateFetch = this._events.lastFetchDate; + if (this._data.lastFetchDate) { + newContext.dateFetch = this._data.lastFetchDate; } return newContext || null; } @@ -865,7 +1159,7 @@ export class FrigateCardTimelineCore extends LitElement { super.updated(changedProperties); if (changedProperties.has('cameras')) { - this._events.clear(); + this._data.clear(); this._timeline?.destroy(); this._timeline = undefined; } @@ -888,14 +1182,14 @@ export class FrigateCardTimelineCore extends LitElement { this._timeline = new Timeline( this._refTimeline.value, - this._events.dataset, + this._data.dataset, groups, options, ); this._timeline.on('select', this._timelineSelectHandler.bind(this)); this._timeline.on('rangechanged', this._timelineRangeHandler.bind(this)); this._timeline.on('click', this._timelineClickHandler.bind(this)); - this._timeline.on('doubleclick', this._timelineClickHandler.bind(this)); + this._timeline.on('rangechange', this._timelineRangeChangeHandler.bind(this)); } } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index f48975c6..a62623cf 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -22,6 +22,7 @@ import type { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource, + FrigateCardMediaPlayer, MediaShowInfo, TransitionEffect, ViewerConfig @@ -36,8 +37,8 @@ import { multipleBrowseMediaQueryMerged, overrideMultiBrowseMediaQueryParameters } from '../utils/ha/browse-media.js'; -import { createMediaShowInfo } from '../utils/media-info.js'; import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; +import { createMediaShowInfo } from '../utils/media-info.js'; import { View } from '../view.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; @@ -125,6 +126,8 @@ export class FrigateCardViewer extends LitElement { } } +const FRIGATE_CARD_HLS_SELECTOR = 'frigate-card-ha-hls-player'; + @customElement('frigate-card-viewer-carousel') export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { @property({ attribute: false }) @@ -168,11 +171,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { ++i ) { if (isTrueMedia(target.children[i])) { - await resolveMedia( - this.hass, - target.children[i], - this.resolvedMediaCache, - ); + await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache); } } }, @@ -278,6 +277,23 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { }; } + /** + * The the HLS player on a slide (or current slide if not provided.) + * @param slide An optional slide. + * @returns The FrigateCardMediaPlayer or null if not found. + */ + protected _getPlayer(slide?: HTMLElement): FrigateCardMediaPlayer | null { + if (this._carousel) { + if (!slide) { + slide = this._carousel.slideNodes()[this._carousel.selectedScrollSnap()]; + } + return slide?.querySelector( + FRIGATE_CARD_HLS_SELECTOR, + ) as FrigateCardMediaPlayer | null; + } + return null; + } + /** * Get the Embla plugins to use. * @returns An EmblaOptionsType object or undefined for no options. @@ -291,7 +307,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { }), }), AutoMediaPlugin({ - playerSelector: 'frigate-card-ha-hls-player', + playerSelector: FRIGATE_CARD_HLS_SELECTOR, ...(this.viewerConfig?.auto_play && { autoPlayCondition: this.viewerConfig.auto_play, }), @@ -511,9 +527,9 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { const img = slide.querySelector('img') as HTMLImageElement; // Frigate >= 0.9.0+ clips. - const hls_player = slide.querySelector( - 'frigate-card-ha-hls-player', - ) as HTMLElement & { url: string }; + const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & { + url: string; + }; if (img) { img.src = this._canonicalizeHAURL(resolvedMedia.url) || ''; @@ -661,6 +677,24 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel { : ``} `; } + /** + * Fire a media show event when a slide is selected. + */ + protected _selectSlideMediaShowHandler(): void { + super._selectSlideMediaShowHandler(); + + // If this is a recording and play is desired to be started from a + // particular point, seek to that point. + if (this.view?.media?.frigate?.recording?.play_time) { + const player = this._getPlayer(); + if (player) { + player.seek(this.view.media.frigate.recording.play_time); + // TODO: Fix this bug. + console.info(`Seeking on ${this.view.media.media_content_id}`); + } + } + } + protected _renderMediaItem( mediaToRender: FrigateBrowseMediaSource, slideIndex: number, diff --git a/src/const.ts b/src/const.ts index 717e82f4..d88d566a 100644 --- a/src/const.ts +++ b/src/const.ts @@ -118,6 +118,7 @@ export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as export const CONF_TIMELINE_CLUSTERING_THRESHOLD = `${CONF_TIMELINE}.clustering_threshold` as const; export const CONF_TIMELINE_MEDIA = `${CONF_TIMELINE}.media` as const; +export const CONF_TIMELINE_SHOW_RECORDINGS = `${CONF_TIMELINE}.show_recordings` as const; export const CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE = `${CONF_TIMELINE}.controls.thumbnails.mode` as const; export const CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE = diff --git a/src/editor.ts b/src/editor.ts index f0bf24b7..b5b32017 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -80,6 +80,7 @@ import { CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS, CONF_TIMELINE_CONTROLS_THUMBNAILS_SIZE, CONF_TIMELINE_MEDIA, + CONF_TIMELINE_SHOW_RECORDINGS, CONF_TIMELINE_WINDOW_SECONDS, CONF_VIEW_CAMERA_SELECT, CONF_VIEW_DARK_MODE, @@ -1223,6 +1224,10 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_TIMELINE_MEDIA, this._timelineMediaTypes, )} + ${this._renderSwitch( + CONF_TIMELINE_SHOW_RECORDINGS, + defaults.timeline.show_recordings, + )} ${this._renderOptionSelector( CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE, this._thumbnailModes, diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 4f7dd1a5..0984b2a8 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -7,7 +7,8 @@ "no_clips": "No clips", "no_snapshot": "No recent snapshot", "no_clip": "No recent clip", - "live": "Live" + "live": "Live", + "recordings": "Recordings" }, "config": { "cameras": { @@ -109,8 +110,8 @@ "thumbnails": { "mode": "Event Viewer thumbnails mode", "size": "Event Viewer thumbnails size in pixels", - "show_details": "Show event details with thumbnails", - "show_controls": "Show event controls with thumbnails", + "show_details": "Show details with thumbnails", + "show_controls": "Show controls with thumbnails", "modes": { "below": "Thumbnails below the media", "above": "Thumbnails above the media", @@ -229,6 +230,7 @@ "window_seconds": "The default length of the timeline view in seconds", "clustering_threshold": "The count of events at which they are clustered (0=no clustering)", "media": "The media the timeline displays", + "show_recordings": "Show recordings", "medias": { "all": "All media types", "clips": "Clips", @@ -292,13 +294,20 @@ "in_progress": "In Progress", "score": "Score" }, + "recording": { + "events": "Events" + }, "thumbnail": { "retain_indefinitely": "Event will be indefinitely retained", - "timeline": "See event in timeline" + "timeline": "See event in timeline", + "no_thumbnail": "No thumbnail available" }, "error": { + "undecodable_response": "Could not decode response from Home Assistant for request", "empty_response": "Received empty response from Home Assistant for request", "invalid_response": "Received invalid response from Home Assistant for request", + "failed_response": "Failed to receive response from Home Assistant for request", + "failed_sign": "Could not sign Home Assistant URL", "invalid_keys": "Invalid keys", "unknown": "Unknown error", "troubleshooting": "Check troubleshooting", diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 0d41744f..516f4a2c 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -77,6 +77,13 @@ customElements.whenDefined('ha-camera-stream').then(() => { this._player?.unmute(); } + /** + * Seek the video (unsupported). + */ + public seek(seconds: number): void { + this._player?.seek(seconds); + } + /** * Master render method. * @returns A rendered template. diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 693acfb3..92b24c1e 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -59,6 +59,15 @@ customElements.whenDefined('ha-hls-player').then(() => { } } + /** + * Seek the video. + */ + public seek(seconds: number): void { + if (this._video) { + this._video.currentTime = seconds; + } + } + // ===================================================================================== // Minor modifications from: // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index ecafbc30..74283264 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -59,6 +59,15 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { } } + /** + * Seek the video. + */ + public seek(seconds: number): void { + if (this._video) { + this._video.currentTime = seconds; + } + } + // ===================================================================================== // Minor modifications from: // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts diff --git a/src/scss/message.scss b/src/scss/message.scss index 384525e5..696f2507 100644 --- a/src/scss/message.scss +++ b/src/scss/message.scss @@ -2,6 +2,11 @@ height: 100%; width: 100%; display: block; + + // Ensure error messages are selectable. + user-select: text; + // Safari only has prefixed support. + -webkit-user-select: text; } div.wrapper { @@ -20,6 +25,7 @@ div.message { div.message div.contents { padding: 10px; height: 100%; + max-width: 100%; } div.message div.icon { @@ -37,4 +43,6 @@ div.message div.icon { .message pre { margin-top: 20px; + white-space: pre-wrap; + word-break: break-all; } diff --git a/src/scss/next-previous-control.scss b/src/scss/next-previous-control.scss index c031ffbb..d72d61bd 100644 --- a/src/scss/next-previous-control.scss +++ b/src/scss/next-previous-control.scss @@ -32,6 +32,7 @@ box-shadow: 0px 0px 20px 5px black; transition: all 0.2s ease-out; opacity: 0.8; + aspect-ratio: 1 / 1; } .controls.thumbnails:hover { opacity: 1 !important; diff --git a/src/scss/thumbnail-details.scss b/src/scss/thumbnail-details.scss index df77a6c2..f8ea64e7 100644 --- a/src/scss/thumbnail-details.scss +++ b/src/scss/thumbnail-details.scss @@ -8,11 +8,15 @@ column-gap: 5%; } -div.right, div.left { +div.right, +div.left { display: flex; flex-direction: column; justify-content: center; } +div.right { + align-items: center; +} div.left { flex: 1; @@ -36,6 +40,7 @@ span.heading { font-weight: bold; } -.larger { +div.larger, +span.larger { font-size: 1.5rem; } diff --git a/src/scss/thumbnail-feature-event.scss b/src/scss/thumbnail-feature-event.scss new file mode 100644 index 00000000..49100d67 --- /dev/null +++ b/src/scss/thumbnail-feature-event.scss @@ -0,0 +1,37 @@ +:host { + display: block; + height: 100%; + overflow: hidden; +} + +img, ha-icon { + border-radius: var(--ha-card-border-radius, 4px); + + max-width: var(--frigate-card-thumbnail-size-max); + max-height: var(--frigate-card-thumbnail-size-max); + + // 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. + aspect-ratio: 1 / 1; + height: 100%; + + transition: transform 0.2s linear; +} + +ha-icon { + --mdc-icon-size: 50%; + + display: flex; + justify-content: center; + align-items: center; + border: 1px solid rgba(255,255,255,0.3); + box-sizing: border-box; +} + +img:hover { + transform: scale(1.04); +} \ No newline at end of file diff --git a/src/scss/thumbnail-feature-recording.scss b/src/scss/thumbnail-feature-recording.scss new file mode 100644 index 00000000..e1a1b535 --- /dev/null +++ b/src/scss/thumbnail-feature-recording.scss @@ -0,0 +1,29 @@ +:host { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + height: 100%; + aspect-ratio: 1 / 1; + overflow: hidden; + + max-width: var(--frigate-card-thumbnail-size-max); + max-height: var(--frigate-card-thumbnail-size-max); + + border: 1px solid rgba(255,255,255,0.3); + border-radius: var(--ha-card-border-radius, 4px); + box-sizing: border-box; + + transition: transform 0.2s linear; + + padding: 10px; +} + +:host(:hover) { + transform: scale(1.04); +} + +div.title { + font-size: 1.5rem; +} \ No newline at end of file diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index 5eaf92c4..500620f4 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -19,39 +19,22 @@ background-color: var(--primary-background-color, black); } -img { - 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 - // aspect-ratio. - object-fit: cover; - - // Restrict images to a maximum of thumbnail size. - aspect-ratio: 1 / 1; - height: 100%; - - max-width: var(--frigate-card-thumbnail-size-max); - max-height: var(--frigate-card-thumbnail-size-max); - - transition: transform 0.2s linear; -} -img:hover { - transform: scale(1.04); +ha-icon { + position: absolute; + background: rgba(0, 0, 0, 0.2); + border-radius: 50%; } ha-icon.favorite { - position: absolute; color: gold; - background: rgba(0, 0, 0, 0.2); - border-radius: 50%; opacity: 0.8; } ha-icon.timeline { - position: absolute; color: var(--primary-color); right: 0px; - background: rgba(0, 0, 0, 0.2); - border-radius: 50%; } + +frigate-card-thumbnail-details-event, frigate-card-thumbnail-details-recording { + flex: 1; +} \ No newline at end of file diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index 479c7175..5942ff7d 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -46,10 +46,18 @@ div.timeline.right-margin { color: var(--primary-text-color); background-color: var(--primary-color); } +.vis-item.vis-background { + background-color: rgba(255, 255, 255, 0.1); +} -.vis-item:hover { - // Float icons upwards when the user hovers over them. - z-index: 2; +.vis-item:not(.vis-background) { + cursor: pointer; +} +.vis-item.vis-background, .vis-labelset, .vis-time-axis { + cursor: crosshair; +} +.vis-item:active { + cursor: unset; } .vis-item.vis-box { diff --git a/src/types.ts b/src/types.ts index d01a00e1..a7c6eb65 100644 --- a/src/types.ts +++ b/src/types.ts @@ -83,7 +83,14 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [ export type AutoUnmuteCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number]; export type AutoPlayCondition = typeof MEDIA_ACTION_POSITIVE_CONDITIONS[number]; -export class FrigateCardError extends Error {} +export class FrigateCardError extends Error { + context?: unknown; + + constructor(message: string, context?: unknown) { + super(message); + this.context = context; + } +} /** * Action Types (for "Picture Elements" / Menu) @@ -400,7 +407,10 @@ const cameraConfigSchema = z trigger_by_motion: z.boolean().default(cameraConfigDefault.trigger_by_motion), trigger_by_occupancy: z.boolean().default(cameraConfigDefault.trigger_by_occupancy), - trigger_by_entities: z.string().array().default(cameraConfigDefault.trigger_by_entities), + trigger_by_entities: z + .string() + .array() + .default(cameraConfigDefault.trigger_by_entities), }) .default(cameraConfigDefault); export type CameraConfig = z.infer; @@ -508,7 +518,7 @@ const viewConfigDefault = { scan: { enabled: false, show_trigger_status: true, - } + }, }; const viewConfigSchema = z .object({ @@ -525,10 +535,14 @@ const viewConfigSchema = z update_entities: z.string().array().optional(), render_entities: z.string().array().optional(), dark_mode: z.enum(['on', 'off', 'auto']).optional(), - scan: z.object({ - enabled: z.boolean().default(viewConfigDefault.scan.enabled), - show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status), - }).default(viewConfigDefault.scan) + scan: z + .object({ + enabled: z.boolean().default(viewConfigDefault.scan.enabled), + show_trigger_status: z + .boolean() + .default(viewConfigDefault.scan.show_trigger_status), + }) + .default(viewConfigDefault.scan), }) .merge(actionsSchema) .default(viewConfigDefault); @@ -970,6 +984,7 @@ const timelineConfigDefault = { clustering_threshold: 3, media: 'all' as const, window_seconds: 60 * 60, + show_recordings: true, controls: { thumbnails: { mode: 'left' as const, @@ -995,6 +1010,7 @@ const timelineConfigSchema = z .max(24 * 60 * 60) .optional() .default(timelineConfigDefault.window_seconds), + show_recordings: z.boolean().optional().default(timelineConfigDefault.show_recordings), controls: z .object({ thumbnails: thumbnailsControlSchema @@ -1130,6 +1146,15 @@ export interface BrowseMediaQueryParameters { cameraID?: string; } +export interface BrowseRecordingQueryParameters { + clientId: string; + cameraName: string; + year: number; + month: number; + day: number; + hour: number; +} + export interface BrowseMediaNeighbors { previous: FrigateBrowseMediaSource | null; previousIndex: number | null; @@ -1165,6 +1190,7 @@ export interface FrigateCardMediaPlayer { pause(): void; mute(): void; unmute(): void; + seek(seconds: number): void; } export interface CardHelpers { @@ -1180,7 +1206,9 @@ export interface CardHelpers { */ 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: // See: https://github.com/colinhacks/zod#recursive-types @@ -1212,10 +1240,22 @@ export interface FrigateEvent { retain_indefinitely?: boolean; } +export interface FrigateRecording { + camera: string; + start_time: number; + end_time: number; + events: number; + + // The number of seconds at which this recording should be initially played + // from. + play_time?: number; +} + export interface FrigateBrowseMediaSource extends BrowseMediaSource { children?: FrigateBrowseMediaSource[] | null; frigate?: { - event: FrigateEvent; + event?: FrigateEvent; + recording?: FrigateRecording; }; } @@ -1274,7 +1314,7 @@ export type Entity = z.infer; export const extendedEntitySchema = entitySchema.extend({ // Extended entity results. unique_id: z.string().optional(), -}) +}); export type ExtendedEntity = z.infer; export const entityListSchema = entitySchema.array(); diff --git a/src/utils/frigate.ts b/src/utils/frigate.ts new file mode 100644 index 00000000..8054605f --- /dev/null +++ b/src/utils/frigate.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import { ExtendedHomeAssistant } from '../types'; +import { homeAssistantHTTPRequest } from './ha'; + +const recordingSummaryHourSchema = z.object({ + hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)), + duration: z.number().min(0), + events: z.number().min(0), +}); + +const recordingSummarySchema = z + .object({ + day: z.preprocess((arg) => { + // Must provide the hour:minute:second on parsing or Javascript will + // assume UTC midnight. + return typeof arg === 'string' ? new Date(`${arg} 00:00:00`) : arg; + }, z.date()), + events: z.number(), + hours: recordingSummaryHourSchema.array(), + }) + .array(); +export type RecordingSummary = z.infer; + +const recordingSegmentSchema = z.object({ + start_time: z.number(), + end_time: z.number(), + id: z.string(), +}); +const recordingSegmentsSchema = recordingSegmentSchema.array(); +export type RecordingSegments = z.infer; + +/** + * Get the recordings summary. + * @param hass The Home Assistant object. + * @param client_id The Frigate client_id. + * @param camera_name The Frigate camera name. + * @returns A RecordingSummary object. + */ +export const getRecordingsSummary = async ( + hass: ExtendedHomeAssistant, + client_id: string, + camera_name: string, +): Promise => { + return await homeAssistantHTTPRequest( + hass, + recordingSummarySchema, + `/api/frigate/${client_id}/${camera_name}/recordings/summary`, + ); +}; + +/** + * Get the recording segments.. + * @param hass The Home Assistant object. + * @param client_id The Frigate client_id. + * @param camera_name The Frigate camera name. + * @param before The segment low watermark. + * @param after The segment high watermark. + * @returns A RecordingSegments object. + */ +export const getRecordingSegments = async ( + hass: ExtendedHomeAssistant, + client_id: string, + camera_name: string, + before: Date, + after: Date, +): Promise => { + return await homeAssistantHTTPRequest( + hass, + recordingSegmentsSchema, + `/api/frigate/${client_id}/${camera_name}/recordings`, + new URLSearchParams({ + before: String(before.getTime() / 1000), + after: String(after.getTime() / 1000), + }), + ); +}; diff --git a/src/utils/ha/browse-media.ts b/src/utils/ha/browse-media.ts index 743fe125..5bb03d4f 100644 --- a/src/utils/ha/browse-media.ts +++ b/src/utils/ha/browse-media.ts @@ -8,15 +8,23 @@ import { import { homeAssistantWSRequest } from '.'; import { dispatchErrorMessageEvent, + dispatchFrigateCardErrorEvent, dispatchMessageEvent } from '../../components/message.js'; import { localize } from '../../localize/localize.js'; import { BrowseMediaQueryParameters, + BrowseRecordingQueryParameters, CameraConfig, FrigateBrowseMediaSource, - frigateBrowseMediaSourceSchema, FrigateEvent, MEDIA_CLASS_PLAYLIST, - MEDIA_TYPE_PLAYLIST + frigateBrowseMediaSourceSchema, + FrigateCardError, + FrigateEvent, + FrigateRecording, + MEDIA_CLASS_PLAYLIST, + MEDIA_CLASS_VIDEO, + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_VIDEO } from '../../types.js'; import { View } from '../../view.js'; import { getCameraTitle } from '../camera.js'; @@ -27,7 +35,7 @@ import { getCameraTitle } from '../camera.js'; * @returns The `event_id` or `null` if not successfully parsed. */ export const getEventID = (media: FrigateBrowseMediaSource): string | null => { - return media.frigate?.event.id ?? null; + return media.frigate?.event?.id ?? null; }; /** @@ -36,7 +44,7 @@ export const getEventID = (media: FrigateBrowseMediaSource): string | null => { * @returns The start time in unix/epoch time, or null if it cannot be determined. */ export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => { - return media.frigate?.event.start_time ?? null; + return media.frigate?.event?.start_time ?? null; }; /** @@ -336,7 +344,7 @@ export const fetchLatestMediaAndDispatchViewChange = async ( try { parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters); } catch (e) { - return dispatchErrorMessageEvent(element, (e as Error).message); + return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); } const childIndex = getFirstTrueMediaChildIndex(parent); if (!parent || !parent.children || childIndex == null) { @@ -376,7 +384,7 @@ export const fetchChildMediaAndDispatchViewChange = async ( try { parent = await browseMedia(hass, child.media_content_id); } catch (e) { - return dispatchErrorMessageEvent(element, (e as Error).message); + return dispatchFrigateCardErrorEvent(element, e as FrigateCardError); } view @@ -409,6 +417,38 @@ export const createEventParentForChildren = ( }; }; +/** + * Given a media video child with a given media_content_id. + * @param title The title to use for the child. + * @param media_con + * @param children The children media items. + * @returns A single parent containing the children. + */ +export const createVideoChild = ( + title: string, + mediaContentID: string, + options?: { + thumbnail?: string; + recording?: FrigateRecording; + }, +): FrigateBrowseMediaSource => { + return { + title: title, + media_class: MEDIA_CLASS_VIDEO, + media_content_type: MEDIA_TYPE_VIDEO, + media_content_id: mediaContentID, + can_play: true, + can_expand: false, + thumbnail: options?.thumbnail ?? null, + children: null, + ...(options?.recording && { + frigate: { + recording: options.recording, + }, + }), + }; +}; + /** * Convenience function to convert a timestamp to hours, minutes and seconds * string. Heavily inspired by, and returning the same format as, the Frigate @@ -436,3 +476,23 @@ export function getEventDurationString(event: FrigateEvent): string { duration += `${seconds}s`; return duration; } + +/** + * Generate a recording identifier. + * @param hass The HomeAssistant object. + * @param params The recording parameters to use in the identifer. + * @returns A recording identifier. + */ +export const generateRecordingIdentifier = ( + params: BrowseRecordingQueryParameters, +): string => { + return [ + 'media-source://frigate', + params.clientId, + 'recordings', + `${params.year}-${String(params.month).padStart(2, '0')}`, + String(params.day).padStart(2, '0'), + String(params.hour).padStart(2, '0'), + params.cameraName, + ].join('/'); +}; diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index db7b3448..187aed60 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -4,7 +4,9 @@ import { StyleInfo } from 'lit/directives/style-map.js'; import { ZodSchema } from 'zod'; import { localize } from '../../localize/localize.js'; import { - CardHelpers, ExtendedHomeAssistant, + CardHelpers, + ExtendedHomeAssistant, + FrigateCardError, SignedPath, signedPathSchema, StateParameters @@ -36,12 +38,16 @@ export async function homeAssistantWSRequest( const parseResult = schema.safeParse(response); if (!parseResult.success) { const keys = getParseErrorKeys(parseResult.error); - const error_message = - `${localize('error.invalid_response')}: ${JSON.stringify(request)}. ` + - localize('error.invalid_keys') + - `: '${keys}'`; - console.warn(error_message); - throw new Error(error_message); + const error_message = localize('error.invalid_response'); + console.warn( + `${error_message}: ${JSON.stringify(request)}. ${localize( + 'error.invalid_keys', + )}: ${keys}`, + ); + throw new FrigateCardError(error_message, { + request: request, + invalid_keys: keys, + }); } return parseResult.data; } @@ -74,6 +80,70 @@ export async function homeAssistantSignPath( return hass.hassUrl(response.path); } +/** + * Make a HomeAssistant HTTP request. May throw. + * @param hass The HomeAssistant object to send the request with. + * @param schema The expected Zod schema of the response. + * @param request The request to make. + * @returns The parsed valid response or null on malformed. + */ +export async function homeAssistantHTTPRequest( + hass: ExtendedHomeAssistant, + schema: ZodSchema, + url: string, + params?: URLSearchParams, +): Promise { + let signResponse: string | null | undefined; + try { + signResponse = await homeAssistantSignPath(hass, url); + } catch (e) { + console.warn(e); + } + + if (!signResponse) { + throw new FrigateCardError(localize('error.failed_sign'), { + url: url.toString(), + }); + } + + const signedURL = new URL(signResponse); + + if (params) { + for (const [key, value] of params.entries()) { + signedURL.searchParams.append(key, value); + } + } + + const response = await fetch(signedURL.toString()); + if (!response.ok) { + throw new FrigateCardError(localize('error.failed_response'), { + url: signedURL.toString(), + status: response.status, + statusText: response.statusText, + }); + } + + let raw_json; + try { + raw_json = await response.json(); + } catch (e) { + console.warn(e); + throw new FrigateCardError(localize('error.undecodable_response'), { + url: signedURL.toString(), + }); + } + + try { + return schema.parse(raw_json); + } catch (e) { + console.warn(e); + throw new FrigateCardError(localize('error.invalid_response'), { + url: signedURL.toString(), + response: raw_json, + }); + } +} + interface HassStateDifference { entity: string; oldState?: HassEntity; @@ -308,9 +378,9 @@ export const isTriggeredState = (state?: HassEntity): boolean => { /** * Get entities from the HASS object. - * @param hass - * @param domain - * @returns + * @param hass + * @param domain + * @returns A list of entities ids. */ export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => { if (!hass) { @@ -321,4 +391,4 @@ export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): strin ); entities.sort(); return entities; -} +};