Move seeking support into the engine.

This commit is contained in:
Dermot Duffy
2023-01-24 19:36:54 -08:00
parent 13816c3025
commit 6b2036676c
12 changed files with 142 additions and 176 deletions
+7
View File
@@ -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>;
} }
+58
View File
@@ -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;
}
} }
+1
View File
@@ -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,
); );
+22
View File
@@ -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[],
+2 -1
View File
@@ -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}
+9 -10
View File
@@ -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
+4 -20
View File
@@ -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
View File
@@ -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
View File
@@ -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;
};
+2 -4
View File
@@ -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,
+5
View File
@@ -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.
-2
View File
@@ -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?