Initial draft of recordings support.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
-1
@@ -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.
|
||||
|
||||
@@ -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<Message>(element, 'message', {
|
||||
message: error.message,
|
||||
type: 'error',
|
||||
context: error.context || ''
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
+127
-31
@@ -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`<img src="${this.thumbnail}" />`
|
||||
: html`<ha-icon
|
||||
icon="mdi:image-off"
|
||||
title=${localize('thumbnail.no_thumbnail')}
|
||||
></ha-icon> `}
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="title">${format(this.date, 'HH:mm')}</div>
|
||||
<div class="subtitle">${format(this.date, 'MMM do')}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`<div class="left">
|
||||
<div class="larger">${prettifyTitle(this.recording.camera) || ''}</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<span class="larger">${this.recording.events}</span>
|
||||
<span>${localize('recording.events')}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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` <img
|
||||
aria-label="${label ?? ''}"
|
||||
src="${thumbnail}"
|
||||
title="${label ?? ''}"
|
||||
/>
|
||||
${this.controls && event?.retain_indefinitely
|
||||
? html` <ha-icon
|
||||
return html` ${event
|
||||
? html`<frigate-card-thumbnail-feature-event
|
||||
aria-label="${label ?? ''}"
|
||||
title="${label ?? ''}"
|
||||
.thumbnail=${thumbnail ?? undefined}
|
||||
.label=${label ?? undefined}
|
||||
></frigate-card-thumbnail-feature-event>`
|
||||
: html`<frigate-card-thumbnail-feature-recording
|
||||
aria-label="${label ?? ''}"
|
||||
title="${label ?? ''}"
|
||||
.date=${recording ? fromUnixTime(recording.start_time) : undefined}
|
||||
></frigate-card-thumbnail-feature-recording>`}
|
||||
${this.controls && event?.retain_indefinitely
|
||||
? html` <ha-icon
|
||||
class="favorite"
|
||||
icon="mdi:star"
|
||||
title=${localize('thumbnail.retain_indefinitely')}
|
||||
/>`
|
||||
: ``}
|
||||
${this.details && event
|
||||
? html`<frigate-card-thumbnail-details
|
||||
.event=${event}
|
||||
></frigate-card-thumbnail-details>`
|
||||
: html``}
|
||||
${this.controls
|
||||
? html`<ha-icon
|
||||
class="timeline"
|
||||
icon="mdi:target"
|
||||
title=${localize('thumbnail.timeline')}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
/></ha-icon>`
|
||||
: ``}
|
||||
${this.details && event
|
||||
? html`<frigate-card-thumbnail-details-event
|
||||
.event=${event ?? undefined}
|
||||
></frigate-card-thumbnail-details-event>`
|
||||
: this.details && recording
|
||||
? html`<frigate-card-thumbnail-details-recording
|
||||
.recording=${recording ?? undefined}
|
||||
></frigate-card-thumbnail-details-recording>`
|
||||
: html``}
|
||||
${this.controls
|
||||
? html`<ha-icon
|
||||
class="timeline"
|
||||
icon="mdi:target"
|
||||
title=${localize('thumbnail.timeline')}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (event) {
|
||||
this.view
|
||||
?.evolve({
|
||||
view: 'timeline',
|
||||
@@ -144,9 +225,24 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
context: {},
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}}
|
||||
></ha-icon>`
|
||||
: ''}`;
|
||||
} 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);
|
||||
}
|
||||
}}
|
||||
></ha-icon>`
|
||||
: ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+365
-71
@@ -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<FrigateCardTimelineItem>();
|
||||
|
||||
// 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<string, CameraConfig>,
|
||||
media: TimelineMediaType,
|
||||
eventMedia: TimelineMediaType,
|
||||
start: Date,
|
||||
end: Date,
|
||||
recordings?: boolean,
|
||||
): Promise<boolean> {
|
||||
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<string, CameraConfig>,
|
||||
): Promise<void> {
|
||||
const items: FrigateCardTimelineItem[] = [];
|
||||
const now = new Date();
|
||||
|
||||
const storeRecordings = async (
|
||||
camera: string,
|
||||
config: CameraConfig,
|
||||
): Promise<void> => {
|
||||
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<string, CameraConfig>,
|
||||
media: TimelineMediaType,
|
||||
start?: Date,
|
||||
end?: Date,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<void> {
|
||||
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<View>;
|
||||
@@ -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<View>;
|
||||
@@ -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<HTMLElement> = 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 {
|
||||
></div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, CameraRecordings>,
|
||||
time: Date,
|
||||
onlyMatchingHour: boolean,
|
||||
): FrigateBrowseMediaSource[] {
|
||||
const children: FrigateBrowseMediaSource[] = [];
|
||||
const processedCameras: Set<string> = 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<void> {
|
||||
if (!this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const before = endOfHour(time);
|
||||
const after = startOfHour(time);
|
||||
const results: Map<string, CameraRecordings> = new Map();
|
||||
|
||||
const fetch = async (camera: string, config?: CameraConfig): Promise<void> => {
|
||||
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 &&
|
||||
(<FrigateCardTimelineItem>first).event.label ===
|
||||
(<FrigateCardTimelineItem>second).event.label
|
||||
(<FrigateCardTimelineItem>first).event?.label ===
|
||||
(<FrigateCardTimelineItem>second).event?.label
|
||||
);
|
||||
},
|
||||
}
|
||||
@@ -750,13 +1045,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
||||
* Update the timeline from the view object.
|
||||
*/
|
||||
protected async _updateTimelineFromView(): Promise<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+44
-10
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user