Move seeking support into the engine.
This commit is contained in:
@@ -77,4 +77,11 @@ export interface CameraManagerEngine {
|
|||||||
queries: MediaQueries,
|
queries: MediaQueries,
|
||||||
results: MediaQueriesResults,
|
results: MediaQueriesResults,
|
||||||
): boolean;
|
): boolean;
|
||||||
|
|
||||||
|
getMediaSeekTime(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
media: ViewMedia,
|
||||||
|
target: Date,
|
||||||
|
): Promise<number | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,6 +392,32 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getMediaSeekTime(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: Map<string, CameraConfig>,
|
||||||
|
media: ViewMedia,
|
||||||
|
target: Date,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const start = media.getStartTime();
|
||||||
|
const end = media.getEndTime();
|
||||||
|
if (!start || !end || target < start || target > end) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query: RecordingSegmentsQuery = {
|
||||||
|
cameraID: media.getCameraID(),
|
||||||
|
start: start,
|
||||||
|
end: end,
|
||||||
|
type: QueryType.RecordingSegments,
|
||||||
|
};
|
||||||
|
|
||||||
|
const segments = await this.getRecordingSegments(hass, cameras, query);
|
||||||
|
const out = segments
|
||||||
|
? this._getSeekTimeInSegments(start, target, segments.segments)
|
||||||
|
: null;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
protected _getQueryableCameraConfig(
|
protected _getQueryableCameraConfig(
|
||||||
cameras: Map<string, CameraConfig>,
|
cameras: Map<string, CameraConfig>,
|
||||||
cameraID: string,
|
cameraID: string,
|
||||||
@@ -466,4 +492,36 @@ export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
|||||||
`Released ${segmentsStart - countSegments()} segment(s)`,
|
`Released ${segmentsStart - countSegments()} segment(s)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of seconds to seek into a video stream consisting of the
|
||||||
|
* provided segments to reach the target time provided.
|
||||||
|
* @param startTime The earliest allowable time to seek from.
|
||||||
|
* @param targetTime Target time.
|
||||||
|
* @param segments An array of segments dataset items. Must be sorted from oldest to youngest.
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
protected _getSeekTimeInSegments(
|
||||||
|
startTime: Date,
|
||||||
|
targetTime: Date,
|
||||||
|
segments: RecordingSegment[],
|
||||||
|
): number | null {
|
||||||
|
if (!segments.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let seekMilliseconds = 0;
|
||||||
|
|
||||||
|
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
||||||
|
for (const segment of segments) {
|
||||||
|
const segmentStart = fromUnixTime(segment.start_time);
|
||||||
|
if (segmentStart > targetTime) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const segmentEnd = fromUnixTime(segment.end_time);
|
||||||
|
const start = segmentStart < startTime ? startTime : segmentStart;
|
||||||
|
const end = segmentEnd > targetTime ? targetTime : segmentEnd;
|
||||||
|
seekMilliseconds += end.getTime() - start.getTime();
|
||||||
|
}
|
||||||
|
return seekMilliseconds / 1000;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const getRecordingsSummary = async (
|
|||||||
type: 'frigate/recordings/summary',
|
type: 'frigate/recordings/summary',
|
||||||
instance_id: client_id,
|
instance_id: client_id,
|
||||||
camera: camera_name,
|
camera: camera_name,
|
||||||
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -278,6 +278,28 @@ export class CameraManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getMediaSeekTime(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
media: ViewMedia,
|
||||||
|
target: Date,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const startTime = media.getStartTime();
|
||||||
|
const endTime = media.getEndTime();
|
||||||
|
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||||
|
if (
|
||||||
|
!cameraConfig ||
|
||||||
|
!startTime ||
|
||||||
|
!endTime ||
|
||||||
|
target < startTime ||
|
||||||
|
target > endTime
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||||
|
return (await engine?.getMediaSeekTime(hass, this._cameras, media, target)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
protected async _handleQuery<QT extends DataQuery>(
|
protected async _handleQuery<QT extends DataQuery>(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
query: QT | QT[],
|
query: QT | QT[],
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
'slide-selected': this.selected === index,
|
'slide-selected': this.selected === index,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const seekTarget = this.view?.context?.mediaViewer?.seek;
|
||||||
return html` <frigate-card-thumbnail
|
return html` <frigate-card-thumbnail
|
||||||
class="${classMap(classes)}"
|
class="${classMap(classes)}"
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
@@ -167,7 +168,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
|||||||
.media=${media}
|
.media=${media}
|
||||||
.cameraConfig=${cameraConfig}
|
.cameraConfig=${cameraConfig}
|
||||||
.view=${this.view}
|
.view=${this.view}
|
||||||
.mediaSeek=${this.view?.context?.mediaViewer?.seek.get(index)}
|
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
|
||||||
?details=${!!this.config?.show_details}
|
?details=${!!this.config?.show_details}
|
||||||
?show_favorite_control=${this.config?.show_favorite_control}
|
?show_favorite_control=${this.config?.show_favorite_control}
|
||||||
?show_timeline_control=${this.config?.show_timeline_control}
|
?show_timeline_control=${this.config?.show_timeline_control}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { getCameraTitle } from '../utils/camera.js';
|
|||||||
import { renderTask } from '../utils/task.js';
|
import { renderTask } from '../utils/task.js';
|
||||||
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
|
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
|
||||||
import { View } from '../view/view.js';
|
import { View } from '../view/view.js';
|
||||||
import type { MediaSeek } from './viewer.js';
|
|
||||||
import { TaskStatus } from '@lit-labs/task';
|
import { TaskStatus } from '@lit-labs/task';
|
||||||
|
|
||||||
import type { CameraConfig, ExtendedHomeAssistant } from '../types.js';
|
import type { CameraConfig, ExtendedHomeAssistant } from '../types.js';
|
||||||
@@ -131,7 +130,7 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
|||||||
public media?: EventViewMedia;
|
public media?: EventViewMedia;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public mediaSeek?: MediaSeek;
|
public seek?: Date;
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.media) {
|
if (!this.media) {
|
||||||
@@ -158,10 +157,10 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
|||||||
>
|
>
|
||||||
</div>`
|
</div>`
|
||||||
: ``}
|
: ``}
|
||||||
${this.mediaSeek
|
${this.seek
|
||||||
? html` <div>
|
? html` <div>
|
||||||
<span class="heading">${localize('event.seek')}</span>
|
<span class="heading">${localize('event.seek')}</span>
|
||||||
<span>${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')}</span>
|
<span>${format(this.seek, 'HH:mm:ss')}</span>
|
||||||
</div>`
|
</div>`
|
||||||
: html``}
|
: html``}
|
||||||
</div>
|
</div>
|
||||||
@@ -183,7 +182,7 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
|||||||
public media?: RecordingViewMedia;
|
public media?: RecordingViewMedia;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public mediaSeek?: MediaSeek;
|
public seek?: Date;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cameraTitle?: string;
|
public cameraTitle?: string;
|
||||||
@@ -195,10 +194,10 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
|||||||
const eventCount = this.media.getEventCount();
|
const eventCount = this.media.getEventCount();
|
||||||
return html`<div class="left">
|
return html`<div class="left">
|
||||||
<div class="larger">${this.cameraTitle ?? ''}</div>
|
<div class="larger">${this.cameraTitle ?? ''}</div>
|
||||||
${this.mediaSeek
|
${this.seek
|
||||||
? html` <div>
|
? html` <div>
|
||||||
<span class="heading">${localize('recording.seek')}</span>
|
<span class="heading">${localize('recording.seek')}</span>
|
||||||
<span>${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')}</span>
|
<span>${format(this.seek, 'HH:mm:ss')}</span>
|
||||||
</div>`
|
</div>`
|
||||||
: html``}
|
: html``}
|
||||||
</div>
|
</div>
|
||||||
@@ -242,7 +241,7 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
public show_timeline_control = false;
|
public show_timeline_control = false;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public mediaSeek?: MediaSeek;
|
public seek?: Date;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public view?: Readonly<View>;
|
public view?: Readonly<View>;
|
||||||
@@ -314,13 +313,13 @@ export class FrigateCardThumbnail extends LitElement {
|
|||||||
${this.details && ViewMediaClassifier.isEvent(this.media)
|
${this.details && ViewMediaClassifier.isEvent(this.media)
|
||||||
? html`<frigate-card-thumbnail-details-event
|
? html`<frigate-card-thumbnail-details-event
|
||||||
.media=${this.media ?? undefined}
|
.media=${this.media ?? undefined}
|
||||||
.mediaSeek=${this.mediaSeek}
|
.seek=${this.seek}
|
||||||
></frigate-card-thumbnail-details-event>`
|
></frigate-card-thumbnail-details-event>`
|
||||||
: this.details && ViewMediaClassifier.isRecording(this.media)
|
: this.details && ViewMediaClassifier.isRecording(this.media)
|
||||||
? html`<frigate-card-thumbnail-details-recording
|
? html`<frigate-card-thumbnail-details-recording
|
||||||
.media=${this.media ?? undefined}
|
.media=${this.media ?? undefined}
|
||||||
.cameraTitle=${getCameraTitle(this.hass, this.cameraConfig)}
|
.cameraTitle=${getCameraTitle(this.hass, this.cameraConfig)}
|
||||||
.mediaSeek=${this.mediaSeek}
|
.seek=${this.seek}
|
||||||
></frigate-card-thumbnail-details-recording>`
|
></frigate-card-thumbnail-details-recording>`
|
||||||
: html``}
|
: html``}
|
||||||
${shouldShowTimelineControl
|
${shouldShowTimelineControl
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import {
|
|||||||
createViewForEvents,
|
createViewForEvents,
|
||||||
createViewForRecordings,
|
createViewForRecordings,
|
||||||
findClosestMediaIndex,
|
findClosestMediaIndex,
|
||||||
generateMediaViewerContext,
|
|
||||||
} from '../utils/media-to-view';
|
} from '../utils/media-to-view';
|
||||||
import { CameraManager } from '../camera/manager';
|
import { CameraManager } from '../camera/manager';
|
||||||
import { EventMediaQueries, MediaQueries } from '../view/media-queries';
|
import { EventMediaQueries, MediaQueries } from '../view/media-queries';
|
||||||
@@ -407,16 +406,6 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canSeek = !!this.view?.isViewerView();
|
const canSeek = !!this.view?.isViewerView();
|
||||||
|
|
||||||
const context = canSeek
|
|
||||||
? await generateMediaViewerContext(
|
|
||||||
this.hass,
|
|
||||||
this.cameraManager,
|
|
||||||
media,
|
|
||||||
targetTime,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const newResults = this._locked
|
const newResults = this._locked
|
||||||
? null
|
? null
|
||||||
: results
|
: results
|
||||||
@@ -444,7 +433,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
}) // Whether or not to set the timeline window.
|
}) // Whether or not to set the timeline window.
|
||||||
.mergeInContext({
|
.mergeInContext({
|
||||||
...this._generateTimelineContext({ noSetWindow: true }),
|
...this._generateTimelineContext({ noSetWindow: true }),
|
||||||
...context,
|
...(canSeek && { mediaViewer: { seek: targetTime }})
|
||||||
})
|
})
|
||||||
.dispatchChangeEvent(this);
|
.dispatchChangeEvent(this);
|
||||||
}
|
}
|
||||||
@@ -533,12 +522,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
// view change.
|
// view change.
|
||||||
if (eventView && results && results.length) {
|
if (eventView && results && results.length) {
|
||||||
eventView.mergeInContext(
|
eventView.mergeInContext(
|
||||||
await generateMediaViewerContext(
|
{mediaViewer: {seek: properties.time}}
|
||||||
this.hass,
|
|
||||||
this.cameraManager,
|
|
||||||
results,
|
|
||||||
properties.time,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
view = eventView;
|
view = eventView;
|
||||||
}
|
}
|
||||||
@@ -608,7 +592,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prefetchedWindow = this._getPrefetchWindow(properties);
|
const prefetchedWindow = this._getPrefetchWindow(properties);
|
||||||
await this._timelineSource?.refresh(this.hass, this.cameras, prefetchedWindow);
|
await this._timelineSource?.refresh(this.hass, prefetchedWindow);
|
||||||
|
|
||||||
// Don't show event thumbnails if the user is looking at recordings,
|
// Don't show event thumbnails if the user is looking at recordings,
|
||||||
// as the recording "hours" are the media, not the event
|
// as the recording "hours" are the media, not the event
|
||||||
@@ -918,7 +902,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
// (via fetchIfNecessary) may update the timeline contents which causes
|
// (via fetchIfNecessary) may update the timeline contents which causes
|
||||||
// the visjs timeline to stop dragging/panning operations which is very
|
// the visjs timeline to stop dragging/panning operations which is very
|
||||||
// disruptive to the user.
|
// disruptive to the user.
|
||||||
await this._timelineSource?.refresh(this.hass, this.cameras, prefetchedWindow);
|
await this._timelineSource?.refresh(this.hass, prefetchedWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaID = media?.getID();
|
const mediaID = media?.getID();
|
||||||
|
|||||||
+26
-22
@@ -58,17 +58,8 @@ import { guard } from 'lit/directives/guard.js';
|
|||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
||||||
|
|
||||||
export interface MediaSeek {
|
|
||||||
// Specifies the point at which this recording should be played, the
|
|
||||||
// seek_time is the date of the desired play point (for display purposes
|
|
||||||
// usually), and seek_seconds is the number of seconds to seek into the video
|
|
||||||
// stream to reach that point.
|
|
||||||
seekTime: number;
|
|
||||||
seekSeconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MediaViewerViewContext {
|
export interface MediaViewerViewContext {
|
||||||
seek: Map<number, MediaSeek>;
|
seek?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'view' {
|
declare module 'view' {
|
||||||
@@ -260,6 +251,8 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* @param changedProperties The properties that were changed in this render.
|
* @param changedProperties The properties that were changed in this render.
|
||||||
*/
|
*/
|
||||||
updated(changedProperties: PropertyValues): void {
|
updated(changedProperties: PropertyValues): void {
|
||||||
|
super.updated(changedProperties);
|
||||||
|
|
||||||
if (changedProperties.has('view')) {
|
if (changedProperties.has('view')) {
|
||||||
const oldView = changedProperties.get('view') as View | undefined;
|
const oldView = changedProperties.get('view') as View | undefined;
|
||||||
// Seek into the video if the seek time has changed (this is also called
|
// Seek into the video if the seek time has changed (this is also called
|
||||||
@@ -269,7 +262,6 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
this._seekHandler();
|
this._seekHandler();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
super.updated(changedProperties);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -418,17 +410,26 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
* Handle the user selecting a new slide in the carousel.
|
* Handle the user selecting a new slide in the carousel.
|
||||||
*/
|
*/
|
||||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
||||||
|
// The slide may already be selected on load, so don't dispatch a new view
|
||||||
|
// unless necessary.
|
||||||
if (ev.detail.index !== this.view?.queryResults?.getSelectedIndex()) {
|
if (ev.detail.index !== this.view?.queryResults?.getSelectedIndex()) {
|
||||||
this._setViewSelectedIndex(ev.detail.index);
|
this._setViewSelectedIndex(ev.detail.index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _setViewSelectedIndex(index: number): void {
|
protected _setViewSelectedIndex(index: number): void {
|
||||||
// The slide may already be selected on load, so don't dispatch a new view
|
const newResults = this.view?.queryResults?.clone().selectResult(index);
|
||||||
// unless necessary.
|
if (!newResults) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cameraID = newResults.getSelectedResult()?.getCameraID();
|
||||||
|
|
||||||
this.view
|
this.view
|
||||||
?.evolve({
|
?.evolve({
|
||||||
queryResults: this.view.queryResults?.clone().selectResult(index),
|
queryResults: newResults,
|
||||||
|
|
||||||
|
// Always change the camera to the owner of the selected media.
|
||||||
|
...(cameraID && { camera: cameraID }),
|
||||||
})
|
})
|
||||||
// Ensure the timeline is able to update its position.
|
// Ensure the timeline is able to update its position.
|
||||||
.mergeInContext({ timeline: { noSetWindow: false } })
|
.mergeInContext({ timeline: { noSetWindow: false } })
|
||||||
@@ -626,15 +627,18 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
/**
|
/**
|
||||||
* Fire a media show event when a slide is selected.
|
* Fire a media show event when a slide is selected.
|
||||||
*/
|
*/
|
||||||
protected _seekHandler(): void {
|
protected async _seekHandler(): Promise<void> {
|
||||||
const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
|
const seek = this.view?.context?.mediaViewer?.seek;
|
||||||
const seek =
|
const media = this.view?.queryResults?.getSelectedResult();
|
||||||
selectedIndex !== null
|
if (!this.hass || !media || !seek) {
|
||||||
? this.view?.context?.mediaViewer?.seek.get(selectedIndex)
|
return;
|
||||||
: null;
|
}
|
||||||
|
|
||||||
|
const seekTime =
|
||||||
|
(await this.cameraManager?.getMediaSeekTime(this.hass, media, seek)) ?? null;
|
||||||
const player = this._getPlayer();
|
const player = this._getPlayer();
|
||||||
if (player && seek) {
|
if (player && seekTime !== null) {
|
||||||
player.seek(seek.seekSeconds);
|
player.seek(seekTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
-117
@@ -1,25 +1,16 @@
|
|||||||
import add from 'date-fns/add';
|
import add from 'date-fns/add';
|
||||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
|
||||||
import startOfHour from 'date-fns/startOfHour';
|
|
||||||
import sub from 'date-fns/sub';
|
import sub from 'date-fns/sub';
|
||||||
import { ViewContext } from 'view';
|
import { ViewContext } from 'view';
|
||||||
import {
|
import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
|
||||||
CameraConfig,
|
|
||||||
ClipsOrSnapshotsOrAll,
|
|
||||||
FrigateCardView,
|
|
||||||
RecordingSegment,
|
|
||||||
} from '../types';
|
|
||||||
import { View } from '../view/view';
|
import { View } from '../view/view';
|
||||||
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
||||||
import { CameraManager } from '../camera/manager';
|
import { CameraManager } from '../camera/manager';
|
||||||
import { getAllDependentCameras } from './camera.js';
|
import { getAllDependentCameras } from './camera.js';
|
||||||
import { ViewMedia } from '../view/media';
|
import { ViewMedia } from '../view/media';
|
||||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
|
||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { dispatchFrigateCardErrorEvent } from '../components/message';
|
import { dispatchFrigateCardErrorEvent } from '../components/message';
|
||||||
import { MediaQueriesResults } from '../view/media-queries-results';
|
import { MediaQueriesResults } from '../view/media-queries-results';
|
||||||
import { errorToConsole } from './basic';
|
import { errorToConsole } from './basic';
|
||||||
import { RecordingSegmentsQueryResults } from '../camera/types';
|
|
||||||
|
|
||||||
export const changeViewToRecentEventsForCameraAndDependents = async (
|
export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
@@ -168,12 +159,11 @@ export const createViewForRecordings = async (
|
|||||||
queryResults.selectBestResult((media) =>
|
queryResults.selectBestResult((media) =>
|
||||||
findClosestMediaIndex(media, options.targetTime as Date, cameraIDs),
|
findClosestMediaIndex(media, options.targetTime as Date, cameraIDs),
|
||||||
);
|
);
|
||||||
viewerContext = await generateMediaViewerContext(
|
viewerContext = {
|
||||||
hass,
|
mediaViewer: {
|
||||||
cameraManager,
|
seek: options.targetTime,
|
||||||
mediaArray,
|
},
|
||||||
options.targetTime,
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -187,75 +177,6 @@ export const createViewForRecordings = async (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate the media view context for a set of media children (used to set
|
|
||||||
* seek times into each media item).
|
|
||||||
* @param hass The Home Assistant object.
|
|
||||||
* @param cameraManager The datamanager to use for data access.
|
|
||||||
* @param media The media.
|
|
||||||
* @param targetTime The target time.
|
|
||||||
* @returns The ViewContext.
|
|
||||||
*/
|
|
||||||
export const generateMediaViewerContext = async (
|
|
||||||
hass: HomeAssistant,
|
|
||||||
cameraManager: CameraManager,
|
|
||||||
media: ViewMedia[],
|
|
||||||
targetTime: Date,
|
|
||||||
): Promise<ViewContext> => {
|
|
||||||
const seek = new Map();
|
|
||||||
const hourStart = startOfHour(targetTime);
|
|
||||||
|
|
||||||
for (const [index, child] of media.entries()) {
|
|
||||||
const start = child.getStartTime();
|
|
||||||
const end = child.getEndTime();
|
|
||||||
if (!start || !end) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let seekSeconds: number | null = null;
|
|
||||||
|
|
||||||
if (targetTime >= start && targetTime <= end) {
|
|
||||||
const query = cameraManager.generateDefaultRecordingSegmentsQueries(
|
|
||||||
child.getCameraID(),
|
|
||||||
{
|
|
||||||
start: start,
|
|
||||||
end: end,
|
|
||||||
},
|
|
||||||
)[0];
|
|
||||||
let segments: RecordingSegmentsQueryResults | null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
segments = (await cameraManager.getRecordingSegments(hass, query)).get(
|
|
||||||
query,
|
|
||||||
) ?? null;
|
|
||||||
} catch (e) {
|
|
||||||
errorToConsole(e as Error);
|
|
||||||
// View context is never critical. Ignore errors which will at least
|
|
||||||
// allow the video to load even if it doesn't seek to the correct
|
|
||||||
// location.
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (segments) {
|
|
||||||
seekSeconds = getSeekTimeInSegments(
|
|
||||||
// Recordings start from the top of the hour.
|
|
||||||
ViewMediaClassifier.isRecording(child) ? hourStart : start,
|
|
||||||
targetTime,
|
|
||||||
segments.segments,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seekSeconds !== null) {
|
|
||||||
seek.set(index, {
|
|
||||||
seekSeconds: seekSeconds,
|
|
||||||
seekTime: targetTime.getTime() / 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return seek.size > 0 ? { mediaViewer: { seek: seek } } : {};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the closest matching media object.
|
* Find the closest matching media object.
|
||||||
* @param mediaArray The media. Must be sorted most recent first.
|
* @param mediaArray The media. Must be sorted most recent first.
|
||||||
@@ -302,35 +223,3 @@ export const findClosestMediaIndex = (
|
|||||||
}
|
}
|
||||||
return bestMatch ? bestMatch.index : null;
|
return bestMatch ? bestMatch.index : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the number of seconds to seek into a video stream consisting of the
|
|
||||||
* provided segments to reach the target time provided.
|
|
||||||
* @param startTime The earliest allowable time to seek from.
|
|
||||||
* @param targetTime Target time.
|
|
||||||
* @param segments An array of segments dataset items. Must be sorted from oldest to youngest.
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const getSeekTimeInSegments = (
|
|
||||||
startTime: Date,
|
|
||||||
targetTime: Date,
|
|
||||||
segments: RecordingSegment[],
|
|
||||||
): number | null => {
|
|
||||||
if (!segments.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let seekMilliseconds = 0;
|
|
||||||
|
|
||||||
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
|
||||||
for (const segment of segments) {
|
|
||||||
const segmentStart = fromUnixTime(segment.start_time);
|
|
||||||
if (segmentStart > targetTime) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const segmentEnd = fromUnixTime(segment.end_time);
|
|
||||||
const start = segmentStart < startTime ? startTime : segmentStart;
|
|
||||||
const end = segmentEnd > targetTime ? targetTime : segmentEnd;
|
|
||||||
seekMilliseconds += end.getTime() - start.getTime();
|
|
||||||
}
|
|
||||||
return seekMilliseconds / 1000;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -87,12 +87,11 @@ export class TimelineDataSource {
|
|||||||
|
|
||||||
public async refresh(
|
public async refresh(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cameras: Map<string, CameraConfig>,
|
|
||||||
window: TimelineWindow,
|
window: TimelineWindow,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this._refreshEvents(hass, cameras, window),
|
this._refreshEvents(hass, window),
|
||||||
this._refreshRecordings(hass, window),
|
this._refreshRecordings(hass, window),
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -122,7 +121,6 @@ export class TimelineDataSource {
|
|||||||
|
|
||||||
protected async _refreshEvents(
|
protected async _refreshEvents(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cameras: Map<string, CameraConfig>,
|
|
||||||
window: TimelineWindow,
|
window: TimelineWindow,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (
|
if (
|
||||||
@@ -145,7 +143,7 @@ export class TimelineDataSource {
|
|||||||
for (const media of results?.getResults() ?? []) {
|
for (const media of results?.getResults() ?? []) {
|
||||||
const endTime = media.getEndTime();
|
const endTime = media.getEndTime();
|
||||||
const startTime = media.getStartTime();
|
const startTime = media.getStartTime();
|
||||||
const id = media.getID(cameras.get(media.getCameraID()));
|
const id = media.getID();
|
||||||
if (id && startTime) {
|
if (id && startTime) {
|
||||||
this._dataset.update({
|
this._dataset.update({
|
||||||
id: id,
|
id: id,
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ export class ViewMedia {
|
|||||||
public isFavorite(): boolean | null {
|
public isFavorite(): boolean | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
public includesTime(seek: Date): boolean {
|
||||||
|
const startTime = this.getStartTime();
|
||||||
|
const endTime = this.getEndTime();
|
||||||
|
return !!startTime && !!endTime && seek >= startTime && seek <= endTime;
|
||||||
|
}
|
||||||
|
|
||||||
// Sets the favorite attribute (if any). This purely sets the media item as a
|
// Sets the favorite attribute (if any). This purely sets the media item as a
|
||||||
// favorite in JS.
|
// favorite in JS.
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
// Minor / later:
|
// Minor / later:
|
||||||
// - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript
|
// - TODO: ts-prune https://camchenry.com/blog/deleting-dead-code-in-typescript
|
||||||
// - TODO: getRecordingTitle should use getCameraTitle but need hass.
|
// - TODO: getRecordingTitle should use getCameraTitle but need hass.
|
||||||
// - TODO: Pass timezone to recordings & event summary endpoint.
|
|
||||||
|
|
||||||
// Hard:
|
// Hard:
|
||||||
// - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events.
|
// - TODO: Implement dragging the timeline seeking forward in both Frigate recordings & events.
|
||||||
// - TODO: Implement gallery.
|
// - TODO: Implement gallery.
|
||||||
// - TODO: Remove FrigateBrowseMediaSource if not necessary (post-gallery).
|
// - TODO: Remove FrigateBrowseMediaSource if not necessary (post-gallery).
|
||||||
// - TODO: Remove browse-media.ts TODOs.
|
// - TODO: Remove browse-media.ts TODOs.
|
||||||
// - TODO: In generateMediaViewerContext there is an assumption that recordings start/end on the hour, which is true for Frigate but that assumption should be in the engine.
|
|
||||||
// - TODO: What should the timeline do when an event is clicked on that is not in the queryResults (or if queryResults is empty)?
|
// - TODO: What should the timeline do when an event is clicked on that is not in the queryResults (or if queryResults is empty)?
|
||||||
// - TODO: Should the timeline data source clear events (as it currently does) when the query changes?
|
// - TODO: Should the timeline data source clear events (as it currently does) when the query changes?
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user