feat: Add support for Frigate reviews / detections [initial PR] (#2315)
- Add support for Frigate reviews / detections. - Add support for GenAI metadata. - Significant internal refactor to more flexible "UnifiedQuery" to allow mixing cameras with simple metadata and review metadata (e.g. a timeline view of a Frigate camera with reviews, and a Reolink camera with simple metadata). - Add support for folder media as camera media. There are a few more PRs to commit prior to this going live, but commiting this for now due to the scale of the change. BREAKING CHANGE: `media_type` and `events_type` are retired under `live`, `viewer` and `timeline` configuration sections, instead media type is associated (once) with the camera under `media`.
This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||
import { ViewFolder, ViewItem } from '../../view/item';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { View } from '../../view/view';
|
||||
|
||||
export const upFolderClickHandler = (
|
||||
_item: ViewItem,
|
||||
ev: Event,
|
||||
viewManagerEpoch?: ViewManagerEpoch,
|
||||
): void => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const query = viewManagerEpoch?.manager.getView()?.query;
|
||||
if (!query || !QueryClassifier.isFolderQuery(query)) {
|
||||
return;
|
||||
}
|
||||
const rawQuery = query?.getQuery();
|
||||
if (!rawQuery?.path || rawQuery?.path.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = rawQuery.path.slice(0, -1);
|
||||
|
||||
viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: query.clone().setQuery({
|
||||
folder: rawQuery.folder,
|
||||
path: [path[0], ...path.slice(1)],
|
||||
}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpFolderMediaItem = (view?: View | null): ViewFolder | null => {
|
||||
const query = view?.query;
|
||||
if (!query || !QueryClassifier.isFolderQuery(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawQuery = query.getQuery();
|
||||
if (!rawQuery?.folder || !rawQuery?.path || rawQuery.path.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new ViewFolder(rawQuery.folder, {
|
||||
icon: 'mdi:arrow-up-left',
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||
import { AdvancedCameraCardView } from '../../config/schema/common/const';
|
||||
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
|
||||
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { UnifiedQuery } from '../../view/unified-query';
|
||||
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
|
||||
import { View } from '../../view/view';
|
||||
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
|
||||
|
||||
interface GalleryViewContext {
|
||||
// The gallery view type the user navigated from (when in viewer). Used to
|
||||
// determine if query/results should be preserved when returning.
|
||||
originView?: AdvancedCameraCardView;
|
||||
}
|
||||
|
||||
declare module 'view' {
|
||||
interface ViewContext {
|
||||
gallery?: GalleryViewContext;
|
||||
}
|
||||
}
|
||||
|
||||
// The minimum width of a thumbnail with details enabled.
|
||||
const GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||
|
||||
// The minimum width of a folder thumbnail with details enabled.
|
||||
const FOLDER_THUMBNAIL_DETAILS_WIDTH_MIN = 200;
|
||||
|
||||
export class GalleryController {
|
||||
private _host: HTMLElement;
|
||||
private _items: ViewItem[] | null = null;
|
||||
private _foldersOnly = false;
|
||||
|
||||
public constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public getItems(): ViewItem[] | null {
|
||||
return this._items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set items from view query results.
|
||||
* Media is reversed so newest appears first in the gallery.
|
||||
*/
|
||||
public setItemsFromView(newView?: View | null, oldView?: View | null): void {
|
||||
const newResults = newView?.queryResults?.getResults() ?? null;
|
||||
if (newResults === null) {
|
||||
this._items = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._items || oldView?.queryResults?.getResults() !== newResults) {
|
||||
// Gallery places the most recent media at the top (the query results
|
||||
// place the most recent media at the end for use in the viewer).
|
||||
this._items = [...newResults].reverse();
|
||||
}
|
||||
|
||||
this._foldersOnly = this._items?.every((item) => ViewItemClassifier.isFolder(item));
|
||||
}
|
||||
|
||||
public setThumbnailSize(size?: number): void {
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-thumbnail-size',
|
||||
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
|
||||
);
|
||||
}
|
||||
|
||||
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
|
||||
if (!thumbnailConfig) {
|
||||
return THUMBNAIL_WIDTH_DEFAULT;
|
||||
}
|
||||
if (!thumbnailConfig.show_details) {
|
||||
return thumbnailConfig.size;
|
||||
}
|
||||
|
||||
// Use smaller width when all items are folders
|
||||
return this._foldersOnly
|
||||
? FOLDER_THUMBNAIL_DETAILS_WIDTH_MIN
|
||||
: GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN;
|
||||
}
|
||||
|
||||
public getColumnCountRoundMethod(
|
||||
thumbnailConfig?: MediaGalleryThumbnailsConfig,
|
||||
): GalleryColumnCountRoundMethod {
|
||||
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
|
||||
}
|
||||
|
||||
public async extend(
|
||||
runner: UnifiedQueryRunner,
|
||||
viewManagerEpoch: ViewManagerEpoch,
|
||||
direction: 'earlier' | 'later',
|
||||
useCache = true,
|
||||
): Promise<void> {
|
||||
const view = viewManagerEpoch.manager.getView();
|
||||
if (!view?.query || !view?.queryResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingResults = view.queryResults.getResults();
|
||||
if (!existingResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
let extended: { query: UnifiedQuery; results: ViewItem[] } | null;
|
||||
try {
|
||||
extended = await runner.extend(view.query, existingResults, direction, {
|
||||
useCache,
|
||||
});
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extended) {
|
||||
viewManagerEpoch.manager.setViewByParameters({
|
||||
baseView: view,
|
||||
params: {
|
||||
query: extended.query,
|
||||
queryResults: new QueryResults({
|
||||
results: extended.results,
|
||||
}).selectResultIfFound(
|
||||
(item) => item === view.queryResults?.getSelectedResult(),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { FoldersManager } from '../../card-controller/folders/manager';
|
||||
import { ViewManagerInterface } from '../../card-controller/view/types';
|
||||
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
|
||||
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
|
||||
|
||||
// The minimum width of a (folder) thumbnail with details enabled. This is
|
||||
// shorter than for regular camera media as this will consist of just a name.
|
||||
export const FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 200;
|
||||
|
||||
export class FolderGalleryController {
|
||||
private _host: HTMLElement;
|
||||
|
||||
public constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public setThumbnailSize(size?: number): void {
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-thumbnail-size',
|
||||
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
|
||||
);
|
||||
}
|
||||
|
||||
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
|
||||
return !thumbnailConfig
|
||||
? THUMBNAIL_WIDTH_DEFAULT
|
||||
: thumbnailConfig.show_details
|
||||
? FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
|
||||
: thumbnailConfig.size;
|
||||
}
|
||||
|
||||
public getColumnCountRoundMethod(
|
||||
thumbnailConfig?: MediaGalleryThumbnailsConfig,
|
||||
): GalleryColumnCountRoundMethod {
|
||||
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
|
||||
}
|
||||
|
||||
public itemClickHandler(
|
||||
viewManager: ViewManagerInterface,
|
||||
item: ViewItem,
|
||||
ev: Event,
|
||||
foldersManager?: FoldersManager,
|
||||
): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const view = viewManager.getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
if (ViewItemClassifier.isMedia(item)) {
|
||||
viewManager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
queryResults: view.queryResults
|
||||
?.clone()
|
||||
.selectResultIfFound((result) => result === item),
|
||||
},
|
||||
});
|
||||
} else if (
|
||||
ViewItemClassifier.isFolder(item) &&
|
||||
QueryClassifier.isFolderQuery(view.query)
|
||||
) {
|
||||
const rawQuery = view.query.getQuery();
|
||||
if (!rawQuery || !foldersManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuery = foldersManager.generateChildFolderQuery(rawQuery, item);
|
||||
if (!newQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewManager.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: view.query.clone().setQuery(newQuery),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
import { CameraManager, ExtendedMediaQueryResult } from '../../camera-manager/manager';
|
||||
import { EventQuery, MediaQuery, RecordingQuery } from '../../camera-manager/types';
|
||||
import {
|
||||
ViewManagerEpoch,
|
||||
ViewManagerInterface,
|
||||
} from '../../card-controller/view/types';
|
||||
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
|
||||
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action';
|
||||
import { errorToConsole } from '../../utils/basic';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { EventMediaQuery, RecordingMediaQuery } from '../../view/query';
|
||||
import { QueryClassifier } from '../../view/query-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { View } from '../../view/view';
|
||||
import { GalleryColumnCountRoundMethod } from './gallery-core-controller';
|
||||
|
||||
// The minimum width of a thumbnail with details enabled.
|
||||
export const MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||
|
||||
export class MediaGalleryController {
|
||||
private _host: HTMLElement;
|
||||
private _media: ViewItem[] | null = null;
|
||||
|
||||
public constructor(host: HTMLElement) {
|
||||
this._host = host;
|
||||
}
|
||||
|
||||
public getMedia(): ViewItem[] | null {
|
||||
return this._media;
|
||||
}
|
||||
|
||||
public setMediaFromView(newView?: View | null, oldView?: View | null): void {
|
||||
const newResults = newView?.queryResults?.getResults() ?? null;
|
||||
if (newResults === null) {
|
||||
this._media = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._media || oldView?.queryResults?.getResults() !== newResults) {
|
||||
// Media gallery places the most recent media at the top (the query
|
||||
// results place the most recent media at the end for use in the viewer).
|
||||
// This is copied to a new array to avoid reversing the query results in
|
||||
// place.
|
||||
this._media = [...newResults].reverse();
|
||||
}
|
||||
}
|
||||
|
||||
public setThumbnailSize(size?: number): void {
|
||||
this._host.style.setProperty(
|
||||
'--advanced-camera-card-thumbnail-size',
|
||||
`${size ?? THUMBNAIL_WIDTH_DEFAULT}px`,
|
||||
);
|
||||
}
|
||||
|
||||
public getColumnWidth(thumbnailConfig?: MediaGalleryThumbnailsConfig): number {
|
||||
return !thumbnailConfig
|
||||
? THUMBNAIL_WIDTH_DEFAULT
|
||||
: thumbnailConfig.show_details
|
||||
? MEDIA_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN
|
||||
: thumbnailConfig.size;
|
||||
}
|
||||
|
||||
public getColumnCountRoundMethod(
|
||||
thumbnailConfig?: MediaGalleryThumbnailsConfig,
|
||||
): GalleryColumnCountRoundMethod {
|
||||
return thumbnailConfig?.show_details ? 'floor' : 'ceil';
|
||||
}
|
||||
|
||||
public async extendMediaGallery(
|
||||
cameraManager: CameraManager,
|
||||
viewManagerEpoch: ViewManagerEpoch,
|
||||
direction: 'earlier' | 'later',
|
||||
useCache = true,
|
||||
): Promise<void> {
|
||||
const view = viewManagerEpoch.manager.getView();
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const query = view.query;
|
||||
const existingMedia = view.queryResults?.getResults();
|
||||
if (!existingMedia || !query || !QueryClassifier.isMediaQuery(query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawQueries = query.getQuery() ?? null;
|
||||
if (!rawQueries) {
|
||||
return;
|
||||
}
|
||||
|
||||
let extension: ExtendedMediaQueryResult<MediaQuery> | null;
|
||||
try {
|
||||
extension = await cameraManager.extendMediaQueries<MediaQuery>(
|
||||
rawQueries,
|
||||
existingMedia,
|
||||
direction,
|
||||
{
|
||||
useCache: useCache,
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
const newMediaQueries = QueryClassifier.isEventQuery(query)
|
||||
? new EventMediaQuery(extension.queries as EventQuery[])
|
||||
: QueryClassifier.isRecordingQuery(query)
|
||||
? new RecordingMediaQuery(extension.queries as RecordingQuery[])
|
||||
: /* istanbul ignore next: this path cannot be reached -- @preserve */
|
||||
null;
|
||||
|
||||
/* istanbul ignore else: this path cannot be reached, as we explicitly
|
||||
check for media queries above -- @preserve */
|
||||
if (newMediaQueries) {
|
||||
viewManagerEpoch.manager.setViewByParameters({
|
||||
baseView: view,
|
||||
params: {
|
||||
query: newMediaQueries,
|
||||
queryResults: new QueryResults({
|
||||
results: extension.results,
|
||||
}).selectResultIfFound(
|
||||
(media) => media === view.queryResults?.getSelectedResult(),
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public itemClickHandler(
|
||||
viewManager: ViewManagerInterface,
|
||||
reversedIndex: number,
|
||||
ev: Event,
|
||||
): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
|
||||
const view = viewManager.getView();
|
||||
if (!view || !this._media?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewManager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
queryResults: view.queryResults?.clone().selectIndex(
|
||||
// Media in the gallery is reversed vs the queryResults (see
|
||||
// note above).
|
||||
this._media.length - reversedIndex - 1,
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,27 +12,28 @@ import {
|
||||
} from 'date-fns';
|
||||
import { LitElement } from 'lit';
|
||||
import { isEqual, orderBy, uniqWith } from 'lodash-es';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { CameraManager, CameraQueryClassifier } from '../camera-manager/manager';
|
||||
import { DateRange, PartialDateRange } from '../camera-manager/range';
|
||||
import { CameraQuery, MediaMetadata, QueryType } from '../camera-manager/types';
|
||||
import {
|
||||
EventQuery,
|
||||
MediaMetadata,
|
||||
QueryType,
|
||||
ReviewQuery,
|
||||
} from '../camera-manager/types';
|
||||
import { FoldersManager } from '../card-controller/folders/manager';
|
||||
import { ViewManagerInterface } from '../card-controller/view/types';
|
||||
import { SelectOption, SelectValues } from '../components/select';
|
||||
import { CardWideConfig } from '../config/schema/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { ViewMediaType } from '../types';
|
||||
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
||||
import { EventMediaQuery, RecordingMediaQuery } from '../view/query';
|
||||
import { QueryClassifier } from '../view/query-classifier';
|
||||
|
||||
interface MediaFilterControls {
|
||||
events: boolean;
|
||||
recordings: boolean;
|
||||
favorites: boolean;
|
||||
}
|
||||
import { UnifiedQueryBuilder } from '../view/unified-query-builder';
|
||||
|
||||
export interface MediaFilterCoreDefaults {
|
||||
cameraIDs?: string[];
|
||||
favorite?: MediaFilterCoreFavoriteSelection;
|
||||
mediaType?: MediaFilterMediaType;
|
||||
reviewed?: MediaFilterCoreReviewedSelection;
|
||||
mediaTypes?: MediaFilterMediaType[];
|
||||
what?: string[];
|
||||
when?: string;
|
||||
where?: string[];
|
||||
@@ -44,6 +45,11 @@ export enum MediaFilterCoreFavoriteSelection {
|
||||
NotFavorite = 'not-favorite',
|
||||
}
|
||||
|
||||
export enum MediaFilterCoreReviewedSelection {
|
||||
Reviewed = 'reviewed',
|
||||
NotReviewed = 'not-reviewed',
|
||||
}
|
||||
|
||||
export enum MediaFilterCoreWhen {
|
||||
Today = 'today',
|
||||
Yesterday = 'yesterday',
|
||||
@@ -56,6 +62,7 @@ export enum MediaFilterMediaType {
|
||||
Clips = 'clips',
|
||||
Snapshots = 'snapshots',
|
||||
Recordings = 'recordings',
|
||||
Reviews = 'reviews',
|
||||
}
|
||||
|
||||
export class MediaFilterController {
|
||||
@@ -72,6 +79,7 @@ export class MediaFilterController {
|
||||
protected _whereOptions: SelectOption[] = [];
|
||||
protected _tagsOptions: SelectOption[] = [];
|
||||
protected _favoriteOptions: SelectOption[];
|
||||
protected _reviewedOptions: SelectOption[];
|
||||
|
||||
protected _defaults: MediaFilterCoreDefaults | null = null;
|
||||
protected _viewManager: ViewManagerInterface | null = null;
|
||||
@@ -102,6 +110,20 @@ export class MediaFilterController {
|
||||
value: MediaFilterMediaType.Recordings,
|
||||
label: localize('media_filter.media_types.recordings'),
|
||||
},
|
||||
{
|
||||
value: MediaFilterMediaType.Reviews,
|
||||
label: localize('media_filter.media_types.reviews'),
|
||||
},
|
||||
];
|
||||
this._reviewedOptions = [
|
||||
{
|
||||
value: MediaFilterCoreReviewedSelection.Reviewed,
|
||||
label: localize('media_filter.reviewed'),
|
||||
},
|
||||
{
|
||||
value: MediaFilterCoreReviewedSelection.NotReviewed,
|
||||
label: localize('media_filter.not_reviewed'),
|
||||
},
|
||||
];
|
||||
this._staticWhenOptions = [
|
||||
{
|
||||
@@ -149,6 +171,9 @@ export class MediaFilterController {
|
||||
public getFavoriteOptions(): SelectOption[] {
|
||||
return this._favoriteOptions;
|
||||
}
|
||||
public getReviewedOptions(): SelectOption[] {
|
||||
return this._reviewedOptions;
|
||||
}
|
||||
public getDefaults(): MediaFilterCoreDefaults | null {
|
||||
return this._defaults;
|
||||
}
|
||||
@@ -158,229 +183,204 @@ export class MediaFilterController {
|
||||
|
||||
public async valueChangeHandler(
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
cardWideConfig: CardWideConfig,
|
||||
values: {
|
||||
camera?: string | string[];
|
||||
mediaType?: MediaFilterMediaType;
|
||||
camera?: SelectValues;
|
||||
mediaTypes?: SelectValues;
|
||||
when: {
|
||||
selected?: string | string[];
|
||||
selected?: SelectValues;
|
||||
from?: Date | null;
|
||||
to?: Date | null;
|
||||
};
|
||||
favorite?: MediaFilterCoreFavoriteSelection;
|
||||
where?: string | string[];
|
||||
what?: string | string[];
|
||||
tags?: string | string[];
|
||||
favorite?: SelectValues;
|
||||
reviewed?: SelectValues;
|
||||
where?: SelectValues;
|
||||
what?: SelectValues;
|
||||
tags?: SelectValues;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_ev?: unknown,
|
||||
): Promise<void> {
|
||||
const getArrayValueAsSet = (val?: SelectValues): Set<string> | null => {
|
||||
const getArrayValueAsSet = <T extends string>(val?: SelectValues): Set<T> | null => {
|
||||
// The reported value may be '' if the field is clearable (i.e. the user
|
||||
// can click 'x').
|
||||
if (val && Array.isArray(val) && val.length && !val.includes('')) {
|
||||
return new Set([...val]);
|
||||
return new Set([...val]) as Set<T>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const cameraIDs =
|
||||
getArrayValueAsSet(values.camera) ?? this._getAllCameraIDs(cameraManager);
|
||||
if (!cameraIDs.size || !values.mediaType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const when = this._getWhen(values.when);
|
||||
const favorite = values.favorite
|
||||
? values.favorite === MediaFilterCoreFavoriteSelection.Favorite
|
||||
: null;
|
||||
|
||||
// A note on views:
|
||||
// - In the below, if the user selects a camera to view media for, the main
|
||||
// view camera is also set to that value (e.g. a user browsing the
|
||||
// gallery, chooses a different camera in the media filter, then
|
||||
// subsequently chooses the live button -- they would expect the live view
|
||||
// for that filtered camera not the prior camera).
|
||||
// - Similarly, if the user chooses clips or snapshots, set the actual view
|
||||
// to 'clips' or 'snapshots' in order to ensure the right icon is shown as
|
||||
// selected in the menu.
|
||||
const reviewed = values.reviewed
|
||||
? values.reviewed === MediaFilterCoreReviewedSelection.Reviewed
|
||||
: null;
|
||||
const where = getArrayValueAsSet(values.where);
|
||||
const what = getArrayValueAsSet(values.what);
|
||||
const tags = getArrayValueAsSet(values.tags);
|
||||
const limit = cardWideConfig.performance?.features.media_chunk_size;
|
||||
|
||||
if (
|
||||
values.mediaType === MediaFilterMediaType.Clips ||
|
||||
values.mediaType === MediaFilterMediaType.Snapshots
|
||||
) {
|
||||
const where = getArrayValueAsSet(values.where);
|
||||
const what = getArrayValueAsSet(values.what);
|
||||
const tags = getArrayValueAsSet(values.tags);
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const query = builder.buildFilterQuery(
|
||||
getArrayValueAsSet(values.camera),
|
||||
getArrayValueAsSet<ViewMediaType>(values.mediaTypes),
|
||||
{
|
||||
...(when?.start && { start: when.start }),
|
||||
...(when?.end && { end: when.end }),
|
||||
...(limit && { limit }),
|
||||
...(favorite !== null && { favorite }),
|
||||
...(reviewed !== null && { reviewed }),
|
||||
...(tags && { tags }),
|
||||
...(what && { what }),
|
||||
...(where && { where }),
|
||||
},
|
||||
);
|
||||
|
||||
const queries = new EventMediaQuery([
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: cameraIDs,
|
||||
...(tags && { tags: tags }),
|
||||
...(what && { what: what }),
|
||||
...(where && { where: where }),
|
||||
...(favorite !== null && { favorite: favorite }),
|
||||
...(when && {
|
||||
...(when.start && { start: when.start }),
|
||||
...(when.end && { end: when.end }),
|
||||
}),
|
||||
...(limit && { limit: limit }),
|
||||
...(values.mediaType === MediaFilterMediaType.Clips && { hasClip: true }),
|
||||
...(values.mediaType === MediaFilterMediaType.Snapshots && {
|
||||
hasSnapshot: true,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
this._viewManager?.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: queries,
|
||||
|
||||
// See 'A note on views' above for these two arguments
|
||||
...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }),
|
||||
view: values.mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const queries = new RecordingMediaQuery([
|
||||
{
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
...(limit && { limit: limit }),
|
||||
...(when && {
|
||||
...(when.start && { start: when.start }),
|
||||
...(when.end && { end: when.end }),
|
||||
}),
|
||||
...(favorite !== null && { favorite: favorite }),
|
||||
},
|
||||
]);
|
||||
|
||||
this._viewManager?.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query: queries,
|
||||
|
||||
// See 'A note on views' above for these two arguments
|
||||
...(cameraIDs.size === 1 && { camera: [...cameraIDs][0] }),
|
||||
view: 'recordings',
|
||||
},
|
||||
});
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get camera IDs from the query (builder may have defaulted these)
|
||||
const queryCameraIDs = query.getAllCameraIDs();
|
||||
const cameraID = queryCameraIDs.size === 1 ? [...queryCameraIDs][0] : undefined;
|
||||
|
||||
this._viewManager?.setViewByParametersWithExistingQuery({
|
||||
params: {
|
||||
query,
|
||||
// If single camera, set it as the active camera for menu navigation
|
||||
...(cameraID && { camera: cameraID }),
|
||||
},
|
||||
});
|
||||
|
||||
// Need to ensure we update the element as the date-picker selections may
|
||||
// have changed, and we need to un/set the selected class.
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
protected _getAllCameraIDs(cameraManager: CameraManager): Set<string> {
|
||||
return cameraManager.getStore().getCameraIDsWithCapability({
|
||||
anyCapabilities: ['clips', 'snapshots', 'recordings'],
|
||||
});
|
||||
}
|
||||
|
||||
public computeInitialDefaultsFromView(cameraManager: CameraManager): void {
|
||||
public computeInitialDefaultsFromView(
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
): void {
|
||||
const view = this._viewManager?.getView();
|
||||
const query = view?.query;
|
||||
const allCameraIDs = this._getAllCameraIDs(cameraManager);
|
||||
if (!view || !QueryClassifier.isMediaQuery(query) || !allCameraIDs.size) {
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
const allCameraIDs = builder.getAllMediaCapableCameraIDs();
|
||||
if (!view || !query?.hasNodes() || !allCameraIDs.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const queries = query.getQuery();
|
||||
if (!queries) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mediaType: MediaFilterMediaType | undefined;
|
||||
const mediaQueries = query.getMediaQueries();
|
||||
const mediaTypes: MediaFilterMediaType[] = [];
|
||||
let cameraIDs: string[] | undefined;
|
||||
let what: string[] | undefined;
|
||||
let where: string[] | undefined;
|
||||
let favorite: MediaFilterCoreFavoriteSelection | undefined;
|
||||
let reviewed: MediaFilterCoreReviewedSelection | undefined;
|
||||
let tags: string[] | undefined;
|
||||
|
||||
const cameraIDSets = uniqWith(
|
||||
queries.map((query: CameraQuery) => query.cameraIDs),
|
||||
isEqual,
|
||||
);
|
||||
// Special note: If all visible cameras are selected, this is the same as no
|
||||
// selector at all.
|
||||
if (cameraIDSets.length === 1 && !isEqual(queries[0].cameraIDs, allCameraIDs)) {
|
||||
cameraIDs = [...queries[0].cameraIDs];
|
||||
const cameraIDsFromQuery = query.getAllCameraIDs();
|
||||
|
||||
// Note: With folder filtering, selecting all cameras is NOT the same as
|
||||
// selecting none. "All cameras" means just camera media, while "none"
|
||||
// would include folder media too.
|
||||
if (cameraIDsFromQuery.size > 0) {
|
||||
cameraIDs = [...cameraIDsFromQuery];
|
||||
}
|
||||
|
||||
const favoriteValues = uniqWith(
|
||||
queries.map((query) => query.favorite),
|
||||
isEqual,
|
||||
// Extract favorite from all media queries (only if explicitly set to true/false)
|
||||
const favoriteValues = new Set(
|
||||
mediaQueries.map((mediaQuery) =>
|
||||
CameraQueryClassifier.isEventQuery(mediaQuery) ? mediaQuery.favorite : undefined,
|
||||
),
|
||||
);
|
||||
if (favoriteValues.length === 1 && queries[0].favorite !== undefined) {
|
||||
favorite = queries[0].favorite
|
||||
? MediaFilterCoreFavoriteSelection.Favorite
|
||||
: MediaFilterCoreFavoriteSelection.NotFavorite;
|
||||
if (favoriteValues.size === 1) {
|
||||
const fav = [...favoriteValues][0];
|
||||
if (fav !== undefined) {
|
||||
favorite = fav
|
||||
? MediaFilterCoreFavoriteSelection.Favorite
|
||||
: MediaFilterCoreFavoriteSelection.NotFavorite;
|
||||
}
|
||||
}
|
||||
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (QueryClassifier.isEventQuery(view.query)) {
|
||||
const queries = view.query.getQuery();
|
||||
// Detect media types from queries
|
||||
const eventQueries = query.getMediaQueries<EventQuery>({ type: QueryType.Event });
|
||||
if (eventQueries.length > 0) {
|
||||
const hasClips = eventQueries.some((q) => q.hasClip);
|
||||
const hasSnapshots = eventQueries.some((q) => q.hasSnapshot);
|
||||
const hasNeither = !hasClips && !hasSnapshots;
|
||||
|
||||
/* istanbul ignore if: the if path cannot be reached -- @preserve */
|
||||
if (!queries) {
|
||||
return;
|
||||
if (hasClips || hasNeither) {
|
||||
mediaTypes.push(MediaFilterMediaType.Clips);
|
||||
}
|
||||
|
||||
const hasClips = uniqWith(
|
||||
queries.map((query) => query.hasClip),
|
||||
isEqual,
|
||||
);
|
||||
const hasSnapshots = uniqWith(
|
||||
queries.map((query) => query.hasSnapshot),
|
||||
isEqual,
|
||||
);
|
||||
if (hasClips.length === 1 && hasSnapshots.length === 1) {
|
||||
mediaType = !!hasClips[0]
|
||||
? MediaFilterMediaType.Clips
|
||||
: !!hasSnapshots[0]
|
||||
? MediaFilterMediaType.Snapshots
|
||||
: undefined;
|
||||
if (hasSnapshots || hasNeither) {
|
||||
mediaTypes.push(MediaFilterMediaType.Snapshots);
|
||||
}
|
||||
}
|
||||
if (query.hasMediaQueriesOfType(QueryType.Recording)) {
|
||||
mediaTypes.push(MediaFilterMediaType.Recordings);
|
||||
}
|
||||
if (query.hasMediaQueriesOfType(QueryType.Review)) {
|
||||
mediaTypes.push(MediaFilterMediaType.Reviews);
|
||||
}
|
||||
|
||||
if (eventQueries.length > 0) {
|
||||
const whatSets = uniqWith(
|
||||
queries.map((query) => query.what),
|
||||
eventQueries.map((q) => q.what),
|
||||
isEqual,
|
||||
);
|
||||
if (whatSets.length === 1 && queries[0].what?.size) {
|
||||
what = [...queries[0].what];
|
||||
if (whatSets.length === 1 && eventQueries[0].what?.size) {
|
||||
what = [...eventQueries[0].what];
|
||||
}
|
||||
const whereSets = uniqWith(
|
||||
queries.map((query) => query.where),
|
||||
eventQueries.map((q) => q.where),
|
||||
isEqual,
|
||||
);
|
||||
if (whereSets.length === 1 && queries[0].where?.size) {
|
||||
where = [...queries[0].where];
|
||||
if (whereSets.length === 1 && eventQueries[0].where?.size) {
|
||||
where = [...eventQueries[0].where];
|
||||
}
|
||||
const tagsSets = uniqWith(
|
||||
queries.map((query) => query.tags),
|
||||
eventQueries.map((q) => q.tags),
|
||||
isEqual,
|
||||
);
|
||||
if (tagsSets.length === 1 && queries[0].tags?.size) {
|
||||
tags = [...queries[0].tags];
|
||||
if (tagsSets.length === 1 && eventQueries[0].tags?.size) {
|
||||
tags = [...eventQueries[0].tags];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract reviewed from review queries (only if explicitly set to true/false)
|
||||
const reviewQueries = query.getMediaQueries<ReviewQuery>({ type: QueryType.Review });
|
||||
if (reviewQueries.length > 0) {
|
||||
const reviewedValues = new Set(reviewQueries.map((q) => q.reviewed));
|
||||
if (reviewedValues.size === 1) {
|
||||
const rev = [...reviewedValues][0];
|
||||
if (rev !== undefined) {
|
||||
reviewed = rev
|
||||
? MediaFilterCoreReviewedSelection.Reviewed
|
||||
: MediaFilterCoreReviewedSelection.NotReviewed;
|
||||
}
|
||||
}
|
||||
} else if (QueryClassifier.isRecordingQuery(view.query)) {
|
||||
mediaType = MediaFilterMediaType.Recordings;
|
||||
}
|
||||
|
||||
this._defaults = {
|
||||
...(mediaType && { mediaType: mediaType }),
|
||||
...(cameraIDs && { cameraIDs: cameraIDs }),
|
||||
...(what && { what: what }),
|
||||
...(where && { where: where }),
|
||||
...(favorite !== undefined && { favorite: favorite }),
|
||||
...(tags && { tags: tags }),
|
||||
...(mediaTypes.length && { mediaTypes }),
|
||||
...(cameraIDs && { cameraIDs }),
|
||||
...(what && { what }),
|
||||
...(where && { where }),
|
||||
...(favorite !== undefined && { favorite }),
|
||||
...(reviewed !== undefined && { reviewed }),
|
||||
...(tags && { tags }),
|
||||
};
|
||||
}
|
||||
|
||||
public computeCameraOptions(cameraManager: CameraManager): void {
|
||||
this._cameraOptions = [...this._getAllCameraIDs(cameraManager)].map((cameraID) => ({
|
||||
public computeCameraOptions(
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
): void {
|
||||
const builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
this._cameraOptions = [...builder.getAllMediaCapableCameraIDs()].map((cameraID) => ({
|
||||
value: cameraID,
|
||||
label: cameraManager.getCameraMetadata(cameraID)?.title ?? cameraID,
|
||||
}));
|
||||
@@ -441,23 +441,6 @@ export class MediaFilterController {
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
public getControlsToShow(cameraManager: CameraManager): MediaFilterControls {
|
||||
const view = this._viewManager?.getView();
|
||||
const events = QueryClassifier.isEventQuery(view?.query);
|
||||
const recordings = QueryClassifier.isRecordingQuery(view?.query);
|
||||
const managerCapabilities = cameraManager.getAggregateCameraCapabilities();
|
||||
|
||||
return {
|
||||
events: events,
|
||||
recordings: recordings,
|
||||
favorites: events
|
||||
? managerCapabilities?.has('favorite-events')
|
||||
: recordings
|
||||
? managerCapabilities?.has('favorite-recordings')
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
protected _computeWhenOptions(): void {
|
||||
this._whenOptions = [...this._staticWhenOptions, ...this._metaDataWhenOptions];
|
||||
}
|
||||
|
||||
+130
-13
@@ -1,27 +1,40 @@
|
||||
import { format } from 'date-fns';
|
||||
import { CameraManager } from '../../camera-manager/manager';
|
||||
import { CameraManagerCameraMetadata } from '../../camera-manager/types';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { Icon } from '../../types';
|
||||
import { MetadataField, OverlayMessage, OverlayMessageControl } from '../../types';
|
||||
import { getDurationString, prettifyTitle } from '../../utils/basic';
|
||||
import {
|
||||
downloadMedia,
|
||||
navigateToTimeline,
|
||||
toggleFavorite,
|
||||
toggleReviewed,
|
||||
} from '../../utils/media-actions';
|
||||
import { ViewItem } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { ViewItemCapabilities } from '../../view/types';
|
||||
|
||||
interface Detail {
|
||||
icon?: Icon;
|
||||
hint?: string;
|
||||
title: string;
|
||||
export interface OverlayControlsContext {
|
||||
hass?: HomeAssistant;
|
||||
viewItemManager?: ViewItemManager;
|
||||
viewManagerEpoch?: ViewManagerEpoch;
|
||||
capabilities?: ViewItemCapabilities | null;
|
||||
}
|
||||
|
||||
export class ThumbnailDetailsController {
|
||||
private _details: Detail[] = [];
|
||||
private _heading: string | null = null;
|
||||
export class MediaDetailsController {
|
||||
private _details: MetadataField[] = [];
|
||||
private _heading: MetadataField | null = null;
|
||||
private _item: ViewItem | null = null;
|
||||
|
||||
public calculate(
|
||||
cameraManager?: CameraManager | null,
|
||||
item?: ViewItem,
|
||||
seek?: Date,
|
||||
): void {
|
||||
this._item = item ?? null;
|
||||
const cameraID = ViewItemClassifier.isMedia(item) ? item.getCameraID() : null;
|
||||
const cameraMetadata = cameraID
|
||||
? cameraManager?.getCameraMetadata(cameraID) ?? null
|
||||
@@ -43,12 +56,34 @@ export class ThumbnailDetailsController {
|
||||
const rawScore = item.getScore();
|
||||
const score = rawScore ? (rawScore * 100).toFixed(2) + '%' : null;
|
||||
|
||||
this._heading = whatWithTags ? `${whatWithTags}${score ? ` ${score}` : ''}` : null;
|
||||
this._heading = whatWithTags
|
||||
? { title: `${whatWithTags}${score ? ` ${score}` : ''}` }
|
||||
: null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ViewItemClassifier.isReview(item)) {
|
||||
const title = item.getTitle();
|
||||
const severity = item.getSeverity();
|
||||
|
||||
this._heading = title
|
||||
? {
|
||||
title: title,
|
||||
emphasis: severity ?? undefined,
|
||||
hint:
|
||||
localize('common.severity') +
|
||||
': ' +
|
||||
localize('common.severities.' + severity),
|
||||
icon: { icon: 'mdi:circle-medium' },
|
||||
}
|
||||
: null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (cameraMetadata?.title) {
|
||||
this._heading = cameraMetadata.title;
|
||||
this._heading = {
|
||||
title: cameraMetadata.title,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,7 +172,9 @@ export class ThumbnailDetailsController {
|
||||
|
||||
// To avoid duplication, if the event has a starttime, the title is omitted
|
||||
// from the details.
|
||||
const includeTitle = !ViewItemClassifier.isEvent(item) || !startTime;
|
||||
const includeTitle =
|
||||
(!ViewItemClassifier.isEvent(item) && !ViewItemClassifier.isReview(item)) ||
|
||||
!startTime;
|
||||
this._details = [
|
||||
...(includeTitle && itemTitle
|
||||
? [
|
||||
@@ -154,11 +191,91 @@ export class ThumbnailDetailsController {
|
||||
];
|
||||
}
|
||||
|
||||
public getHeading(): string | null {
|
||||
public getHeading(): MetadataField | null {
|
||||
return this._heading;
|
||||
}
|
||||
|
||||
public getDetails(): Detail[] {
|
||||
public getDetails(): MetadataField[] {
|
||||
return this._details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an overlay message for the item.
|
||||
* @param context Optional context to include controls.
|
||||
* @returns An OverlayMessage.
|
||||
*/
|
||||
public getMessage(context?: OverlayControlsContext): OverlayMessage {
|
||||
return {
|
||||
heading: this._heading ?? undefined,
|
||||
controls: context ? this._getControls(context) : undefined,
|
||||
details: this._details,
|
||||
text: ViewItemClassifier.isMedia(this._item)
|
||||
? this._item.getDescription() ?? undefined
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
protected _getControls(context: OverlayControlsContext): OverlayMessageControl[] {
|
||||
const controls: OverlayMessageControl[] = [];
|
||||
const item = this._item;
|
||||
|
||||
if (!item) {
|
||||
return controls;
|
||||
}
|
||||
|
||||
if (ViewItemClassifier.isReview(item)) {
|
||||
const isReviewed = item.isReviewed();
|
||||
controls.push({
|
||||
title: isReviewed
|
||||
? localize('common.set_reviews.unreviewed')
|
||||
: localize('common.set_reviews.reviewed'),
|
||||
icon: { icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline' },
|
||||
callback: async () => {
|
||||
const success = await toggleReviewed(item, context);
|
||||
return success ? this.getMessage(context) : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (context.capabilities?.canFavorite && ViewItemClassifier.isMedia(item)) {
|
||||
const isFavorite = item.isFavorite();
|
||||
controls.push({
|
||||
title: localize('thumbnail.retain_indefinitely'),
|
||||
icon: { icon: isFavorite ? 'mdi:star' : 'mdi:star-outline' },
|
||||
emphasis: isFavorite ? 'medium' : undefined,
|
||||
callback: async () => {
|
||||
const success = await toggleFavorite(item, context);
|
||||
return success ? this.getMessage(context) : null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (context.capabilities?.canDownload && item.getID()) {
|
||||
controls.push({
|
||||
title: localize('thumbnail.download'),
|
||||
icon: { icon: 'mdi:download' },
|
||||
callback: async () => {
|
||||
await downloadMedia(item, context);
|
||||
|
||||
// Close overlay message after download.
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (ViewItemClassifier.supportsTimeline(item) && context.viewManagerEpoch) {
|
||||
controls.push({
|
||||
title: localize('thumbnail.timeline'),
|
||||
icon: { icon: 'mdi:target' },
|
||||
callback: () => {
|
||||
navigateToTimeline(item, context);
|
||||
|
||||
// Close overlay after timeline navigation
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return controls;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import { FullscreenManager } from '../card-controller/fullscreen/fullscreen-mana
|
||||
import { MediaPlayerManager } from '../card-controller/media-player-manager';
|
||||
import { MicrophoneManager } from '../card-controller/microphone-manager';
|
||||
import { ViewManager } from '../card-controller/view/view-manager';
|
||||
import { VIEWS_USER_SPECIFIED } from '../config/schema/common/const';
|
||||
import {
|
||||
AdvancedCameraCardView,
|
||||
VIEWS_USER_SPECIFIED,
|
||||
} from '../config/schema/common/const';
|
||||
import { MenuItemBase } from '../config/schema/elements/custom/menu/base';
|
||||
import { MenuItem } from '../config/schema/elements/custom/menu/types';
|
||||
import { AdvancedCameraCardConfig } from '../config/schema/types';
|
||||
import { getEntityTitle } from '../ha/get-entity-title';
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
createMediaPlayerAction,
|
||||
createPTZControlsAction,
|
||||
createPTZMultiAction,
|
||||
createSetReviewAction,
|
||||
createViewAction,
|
||||
isAdvancedCameraCardCustomAction,
|
||||
} from '../utils/action';
|
||||
@@ -27,7 +32,6 @@ import { isBeingCasted } from '../utils/casting';
|
||||
import { getPTZTarget } from '../utils/ptz';
|
||||
import { getStreamCameraID, hasSubstream } from '../utils/substream';
|
||||
import { ViewItemClassifier } from '../view/item-classifier';
|
||||
import { QueryClassifier } from '../view/query-classifier';
|
||||
import { View } from '../view/view';
|
||||
import { getCameraIDsForViewName, isViewSupportedByCamera } from '../view/view-support';
|
||||
|
||||
@@ -77,9 +81,12 @@ export class MenuButtonController {
|
||||
this._getClipsButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getSnapshotsButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getRecordingsButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getReviewsButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getImageButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getTimelineButton(config, cameraManager, foldersManager, options?.view),
|
||||
this._getDownloadButton(config, cameraManager, options?.view),
|
||||
this._getInfoButton(config, cameraManager, options?.view),
|
||||
this._getSetReviewButton(config, options?.view),
|
||||
this._getCameraUIButton(config, options?.showCameraUIButton),
|
||||
this._getMicrophoneButton(
|
||||
config,
|
||||
@@ -245,17 +252,45 @@ export class MenuButtonController {
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a media button (clips/snapshots) should be shown. These are
|
||||
* hidden by default if reviews are supported.
|
||||
*/
|
||||
protected _shouldShowEventMediaButton(
|
||||
viewName: 'clips' | 'snapshots',
|
||||
buttonConfig: MenuItemBase,
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): boolean {
|
||||
const supportsEventView =
|
||||
view &&
|
||||
isViewSupportedByCamera(viewName, cameraManager, foldersManager, view.camera);
|
||||
const supportsReviewsView =
|
||||
view &&
|
||||
isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera);
|
||||
|
||||
// Show if: explicitly enabled OR (supports view AND reviews not supported)
|
||||
return !!supportsEventView && (buttonConfig.enabled || !supportsReviewsView);
|
||||
}
|
||||
|
||||
protected _getClipsButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('clips', cameraManager, foldersManager, view.camera)
|
||||
return this._shouldShowEventMediaButton(
|
||||
'clips',
|
||||
config.menu.buttons.clips,
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
view,
|
||||
)
|
||||
? {
|
||||
icon: 'mdi:filmstrip',
|
||||
...config.menu.buttons.clips,
|
||||
enabled: true,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.view.views.clips'),
|
||||
style: view?.is('clips') ? this._getEmphasizedStyle() : {},
|
||||
@@ -271,11 +306,17 @@ export class MenuButtonController {
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('snapshots', cameraManager, foldersManager, view.camera)
|
||||
return this._shouldShowEventMediaButton(
|
||||
'snapshots',
|
||||
config.menu.buttons.snapshots,
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
view,
|
||||
)
|
||||
? {
|
||||
icon: 'mdi:camera',
|
||||
...config.menu.buttons.snapshots,
|
||||
enabled: true,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.view.views.snapshots'),
|
||||
style: view?.is('snapshots') ? this._getEmphasizedStyle() : {},
|
||||
@@ -305,6 +346,26 @@ export class MenuButtonController {
|
||||
: null;
|
||||
}
|
||||
|
||||
protected _getReviewsButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
return view &&
|
||||
isViewSupportedByCamera('reviews', cameraManager, foldersManager, view.camera)
|
||||
? {
|
||||
icon: 'mdi:play-box-multiple',
|
||||
...config.menu.buttons.reviews,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.view.views.reviews'),
|
||||
style: view.is('reviews') ? this._getEmphasizedStyle() : {},
|
||||
tap_action: createViewAction('reviews'),
|
||||
hold_action: createViewAction('review'),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
protected _getImageButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
cameraManager: CameraManager,
|
||||
@@ -365,6 +426,49 @@ export class MenuButtonController {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected _getInfoButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
_cameraManager: CameraManager,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
const selectedItem = view?.queryResults?.getSelectedResult();
|
||||
if (!ViewItemClassifier.isMedia(selectedItem)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
icon: 'mdi:information-outline',
|
||||
...config.menu.buttons.info,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: localize('config.menu.buttons.info'),
|
||||
tap_action: createGeneralAction('info'),
|
||||
};
|
||||
}
|
||||
|
||||
protected _getSetReviewButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
view?: View | null,
|
||||
): MenuItem | null {
|
||||
const selectedItem = view?.queryResults?.getSelectedResult();
|
||||
if (!view?.isViewerView() || !ViewItemClassifier.isReview(selectedItem)) {
|
||||
return null;
|
||||
}
|
||||
const isReviewed = selectedItem.isReviewed();
|
||||
if (isReviewed === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
icon: isReviewed ? 'mdi:check-circle' : 'mdi:check-circle-outline',
|
||||
...config.menu.buttons.set_review,
|
||||
type: 'custom:advanced-camera-card-menu-icon',
|
||||
title: isReviewed
|
||||
? localize('common.set_reviews.unreviewed')
|
||||
: localize('common.set_reviews.reviewed'),
|
||||
tap_action: createSetReviewAction(),
|
||||
style: isReviewed ? this._getEmphasizedStyle() : {},
|
||||
};
|
||||
}
|
||||
|
||||
protected _getCameraUIButton(
|
||||
config: AdvancedCameraCardConfig,
|
||||
showCameraUIButton?: boolean,
|
||||
@@ -667,9 +771,8 @@ export class MenuButtonController {
|
||||
}
|
||||
|
||||
if (folders.length === 1) {
|
||||
const isSelected =
|
||||
QueryClassifier.isFolderQuery(view?.query) &&
|
||||
view.query.getQuery()?.folder.id === folders[0][0];
|
||||
const folderID = folders[0][0];
|
||||
const isSelected = !!view?.query?.getFolderQueries(folderID).length;
|
||||
const folder = folders[0][1];
|
||||
|
||||
return {
|
||||
@@ -684,9 +787,7 @@ export class MenuButtonController {
|
||||
}
|
||||
|
||||
const submenuItems = folders.map(([id, folder]) => {
|
||||
const isSelected =
|
||||
QueryClassifier.isFolderQuery(view?.query) &&
|
||||
view.query.getQuery()?.folder.id === id;
|
||||
const isSelected = !!view?.query?.getFolderQueries(id).length;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
@@ -736,6 +837,7 @@ export class MenuButtonController {
|
||||
button: MenuItem,
|
||||
options?: MenuButtonControllerOptions,
|
||||
): StyleInfo {
|
||||
// Review
|
||||
for (const actionSet of [
|
||||
button.tap_action,
|
||||
button.double_tap_action,
|
||||
@@ -752,7 +854,9 @@ export class MenuButtonController {
|
||||
VIEWS_USER_SPECIFIED.some(
|
||||
(viewName) =>
|
||||
viewName === action.advanced_camera_card_action &&
|
||||
options?.view?.is(action.advanced_camera_card_action),
|
||||
options?.view?.is(
|
||||
action.advanced_camera_card_action as AdvancedCameraCardView,
|
||||
),
|
||||
) ||
|
||||
(action.advanced_camera_card_action === 'default' &&
|
||||
options?.view?.is(config.view.default)) ||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NonEmptyTuple } from 'type-fest';
|
||||
import { FolderPathComponent, FolderQuery } from '../card-controller/folders/types';
|
||||
import { ViewManagerEpoch, ViewModifier } from '../card-controller/view/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { ViewFolder, ViewMedia } from '../view/item';
|
||||
import { UnifiedQuery } from '../view/unified-query';
|
||||
import { UnifiedQueryBuilder } from '../view/unified-query-builder';
|
||||
|
||||
export interface FolderNavigationParamaters {
|
||||
viewManagerEpoch: ViewManagerEpoch;
|
||||
builder: UnifiedQueryBuilder;
|
||||
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface MediaNavigationParamaters {
|
||||
viewManagerEpoch: ViewManagerEpoch;
|
||||
|
||||
modifiers?: ViewModifier[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a navigable-up folder query from a UnifiedQuery. Returns the query only
|
||||
* if there's exactly one folder query and it has depth > 1.
|
||||
*/
|
||||
const getSingleNavigableUpFolderQuery = (
|
||||
query?: UnifiedQuery | null,
|
||||
): FolderQuery | null => {
|
||||
const folderQueries = query?.getFolderQueries();
|
||||
if (folderQueries?.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const folderQuery = folderQueries[0];
|
||||
return folderQuery.path.length > 1 ? folderQuery : null;
|
||||
};
|
||||
|
||||
export const navigateUp = (options?: FolderNavigationParamaters | null): void => {
|
||||
const folderQuery = getSingleNavigableUpFolderQuery(
|
||||
options?.viewManagerEpoch.manager.getView()?.query,
|
||||
);
|
||||
if (!folderQuery || folderQuery.path.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentPath = folderQuery.path.slice(0, -1);
|
||||
const nonEmptyPath: NonEmptyTuple<FolderPathComponent> = [
|
||||
parentPath[0],
|
||||
...parentPath.slice(1),
|
||||
];
|
||||
const query = options?.builder.buildFolderQueryWithPath(
|
||||
folderQuery.folder,
|
||||
nonEmptyPath,
|
||||
{
|
||||
limit: options?.limit,
|
||||
},
|
||||
);
|
||||
|
||||
options?.viewManagerEpoch.manager.setViewByParametersWithExistingQuery({
|
||||
params: { query },
|
||||
});
|
||||
};
|
||||
|
||||
export const navigateToFolder = (
|
||||
item: ViewFolder,
|
||||
options?: FolderNavigationParamaters | null,
|
||||
): void => {
|
||||
const newPath = [...item.getPath(), { folder: item }];
|
||||
const nonEmptyPath: NonEmptyTuple<FolderPathComponent> = [
|
||||
newPath[0],
|
||||
...newPath.slice(1),
|
||||
];
|
||||
const query = options?.builder.buildFolderQueryWithPath(
|
||||
item.getFolder(),
|
||||
nonEmptyPath,
|
||||
{
|
||||
limit: options?.limit,
|
||||
},
|
||||
);
|
||||
|
||||
options?.viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||
params: { query },
|
||||
});
|
||||
};
|
||||
|
||||
export const navigateToMedia = (
|
||||
media: ViewMedia,
|
||||
options?: MediaNavigationParamaters | null,
|
||||
): void => {
|
||||
const manager = options?.viewManagerEpoch.manager;
|
||||
const view = manager?.getView();
|
||||
|
||||
if (!manager || !view?.queryResults || !options) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newResults = view.queryResults
|
||||
.clone()
|
||||
.selectResultIfFound((result) => result === media);
|
||||
|
||||
const cameraID = media.getCameraID();
|
||||
manager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
queryResults: newResults,
|
||||
...(cameraID && { camera: cameraID }),
|
||||
},
|
||||
modifiers: options?.modifiers,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get up-folder item for display. Returns a ViewFolder if there's exactly one
|
||||
* folder query and it's navigable-up.
|
||||
*/
|
||||
export const getUpFolderItem = (query?: UnifiedQuery | null): ViewFolder | null => {
|
||||
const folderQuery = getSingleNavigableUpFolderQuery(query);
|
||||
return folderQuery
|
||||
? new ViewFolder(folderQuery.folder, folderQuery.path, {
|
||||
icon: 'mdi:arrow-up-left',
|
||||
title: localize('common.up'),
|
||||
})
|
||||
: null;
|
||||
};
|
||||
@@ -76,7 +76,7 @@ export class ThumbnailFeatureController {
|
||||
if (thumbnail) {
|
||||
this._thumbnail = thumbnail;
|
||||
this._icon = null;
|
||||
this._thumbnailClass = isBrandUrl(thumbnail) ? 'brand' : null;
|
||||
this._thumbnailClass = isBrandUrl(thumbnail) ? 'placeholder' : null;
|
||||
} else {
|
||||
this._thumbnail = null;
|
||||
this._thumbnailClass = null;
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from 'vis-timeline';
|
||||
import { CameraManager } from '../../camera-manager/manager';
|
||||
import { rangesOverlap } from '../../camera-manager/range';
|
||||
import { MediaQuery } from '../../camera-manager/types';
|
||||
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
|
||||
import { FoldersManager } from '../../card-controller/folders/manager';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager';
|
||||
@@ -41,22 +40,12 @@ import { findBestMediaTimeIndex } from '../../utils/find-best-media-time-index';
|
||||
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import {
|
||||
EventMediaQuery,
|
||||
FolderViewQuery,
|
||||
Query,
|
||||
RecordingMediaQuery,
|
||||
} from '../../view/query';
|
||||
import { QueryClassifier, QueryType } from '../../view/query-classifier';
|
||||
import { QueryResults } from '../../view/query-results';
|
||||
import { UnifiedQuery } from '../../view/unified-query';
|
||||
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer';
|
||||
import { mergeViewContext } from '../../view/view';
|
||||
import { AdvancedCameraCardTimelineItem, TimelineDataSource } from './source';
|
||||
import {
|
||||
ExtendedTimeline,
|
||||
TimelineItemClickAction,
|
||||
TimelineKeys,
|
||||
TimelineRangeChange,
|
||||
} from './types';
|
||||
import { ExtendedTimeline, TimelineItemClickAction, TimelineRangeChange } from './types';
|
||||
|
||||
// An event used to fetch data required for thumbnail rendering. See special
|
||||
// note below on why this is necessary.
|
||||
@@ -81,7 +70,7 @@ interface TimelineControllerOptions {
|
||||
timelineConfig?: TimelineCoreConfig;
|
||||
mini?: boolean;
|
||||
thumbnailConfig?: ThumbnailsControlBaseConfig;
|
||||
keys?: TimelineKeys;
|
||||
query?: UnifiedQuery;
|
||||
}
|
||||
|
||||
const TIMELINE_TARGET_BAR_ID = 'target_bar';
|
||||
@@ -94,10 +83,14 @@ export class TimelineController {
|
||||
private _timeline: ExtendedTimeline | null = null;
|
||||
|
||||
private _hass: HomeAssistant | null = null;
|
||||
|
||||
private _cameraManager: CameraManager | null = null;
|
||||
private _foldersManager: FoldersManager | null = null;
|
||||
|
||||
private _viewItemManager: ViewItemManager | null = null;
|
||||
private _viewManagerEpoch: ViewManagerEpoch | null = null;
|
||||
private _timelineConfig: TimelineCoreConfig | null = null;
|
||||
|
||||
private _mini = false;
|
||||
|
||||
private _panMode: TimelinePanMode | null = null;
|
||||
@@ -136,26 +129,56 @@ export class TimelineController {
|
||||
this._pointerHeld = null;
|
||||
}
|
||||
|
||||
public setOptions(options: TimelineControllerOptions): void {
|
||||
this.destroyTimeline();
|
||||
/**
|
||||
* Extract the "shape" of a query - a clone without time ranges.
|
||||
* Shape determines timeline structure (groups).
|
||||
*/
|
||||
private _getQueryShape(query: UnifiedQuery): UnifiedQuery {
|
||||
return UnifiedQueryTransformer.stripTimeRange(query);
|
||||
}
|
||||
|
||||
if (
|
||||
options.keys &&
|
||||
options.cameraManager &&
|
||||
options.foldersManager &&
|
||||
options.conditionStateManager &&
|
||||
options.timelineConfig
|
||||
) {
|
||||
this._source = new TimelineDataSource(
|
||||
options.cameraManager,
|
||||
options.foldersManager,
|
||||
options.conditionStateManager,
|
||||
options.keys,
|
||||
options.timelineConfig.events_media_type,
|
||||
options.timelineConfig.show_recordings,
|
||||
);
|
||||
} else {
|
||||
this._source = null;
|
||||
private _hasSameShape(a?: UnifiedQuery | null, b?: UnifiedQuery | null): boolean {
|
||||
if (!a && !b) {
|
||||
return true;
|
||||
}
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
return a.isEqual(b);
|
||||
}
|
||||
|
||||
public setOptions(options: TimelineControllerOptions): void {
|
||||
// Extract the shape (query without time ranges) for comparison.
|
||||
const newShape = options.query ? this._getQueryShape(options.query) : null;
|
||||
|
||||
// Rebuild source if config, dependencies, or shape changed.
|
||||
const needsRebuild =
|
||||
!this._source ||
|
||||
this._cameraManager !== (options.cameraManager ?? null) ||
|
||||
this._foldersManager !== (options.foldersManager ?? null) ||
|
||||
!isEqual(this._timelineConfig, options.timelineConfig ?? null) ||
|
||||
!this._hasSameShape(this._source?.shape, newShape);
|
||||
|
||||
if (needsRebuild) {
|
||||
this.destroyTimeline();
|
||||
|
||||
if (
|
||||
newShape &&
|
||||
options.cameraManager &&
|
||||
options.foldersManager &&
|
||||
options.conditionStateManager &&
|
||||
options.timelineConfig
|
||||
) {
|
||||
this._source = new TimelineDataSource(
|
||||
options.cameraManager,
|
||||
options.foldersManager,
|
||||
options.conditionStateManager,
|
||||
newShape,
|
||||
options.timelineConfig.show_recordings,
|
||||
);
|
||||
} else {
|
||||
this._source = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._thumbnailConfig !== (options.thumbnailConfig ?? null)) {
|
||||
@@ -187,6 +210,7 @@ export class TimelineController {
|
||||
|
||||
this._thumbnailConfig = options?.thumbnailConfig ?? null;
|
||||
this._cameraManager = options?.cameraManager ?? null;
|
||||
this._foldersManager = options?.foldersManager ?? null;
|
||||
this._viewItemManager = options?.viewItemManager ?? null;
|
||||
this._timelineConfig = options?.timelineConfig ?? null;
|
||||
this._mini = options?.mini ?? false;
|
||||
@@ -268,13 +292,13 @@ export class TimelineController {
|
||||
this._timeline = new Timeline(
|
||||
this._timelineElement,
|
||||
this._source.dataset,
|
||||
this._source.groups,
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
this._timeline = new Timeline(
|
||||
this._timelineElement,
|
||||
this._source.dataset,
|
||||
this._source.groups,
|
||||
options,
|
||||
);
|
||||
}
|
||||
@@ -500,16 +524,15 @@ export class TimelineController {
|
||||
}
|
||||
|
||||
const view = this._viewManagerEpoch?.manager.getView();
|
||||
const id = String(properties.item);
|
||||
const item = this._source?.dataset.get(id) ?? null;
|
||||
const id = properties.item ? String(properties.item) : null;
|
||||
const item = id ? this._source?.dataset.get(id) ?? null : null;
|
||||
|
||||
if (
|
||||
this._ignoreClick ||
|
||||
!view ||
|
||||
!this._viewManagerEpoch ||
|
||||
!this._source ||
|
||||
!properties.what ||
|
||||
!item
|
||||
!properties.what
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -519,9 +542,15 @@ export class TimelineController {
|
||||
if (
|
||||
this._timelineConfig?.show_recordings &&
|
||||
properties.time &&
|
||||
['background', 'axis'].includes(properties.what)
|
||||
['background', 'axis'].includes(properties.what) &&
|
||||
this._source &&
|
||||
this._timeline
|
||||
) {
|
||||
const query = this._createQuery('recording');
|
||||
const query = this._source.buildRecordingsWindowedQuery(
|
||||
convertRangeToCacheFriendlyTimes(
|
||||
this._getPrefetchWindow(this._timeline.getWindow()),
|
||||
),
|
||||
);
|
||||
if (query) {
|
||||
await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||
baseView: view,
|
||||
@@ -533,9 +562,14 @@ export class TimelineController {
|
||||
},
|
||||
},
|
||||
},
|
||||
modifiers: [
|
||||
new MergeContextViewModifier({
|
||||
mediaViewer: { seek: properties.time },
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
} else if (properties.item && properties.what === 'item') {
|
||||
} else if (item && properties.what === 'item') {
|
||||
const cameraID = String(properties.group);
|
||||
|
||||
const criteria = {
|
||||
@@ -566,49 +600,16 @@ export class TimelineController {
|
||||
// - If a folder media was loaded into the timeline from a prior folder
|
||||
// query other than the one stored in the view (e.g. user navigated to
|
||||
// a different folder in the thumbnails carousel).
|
||||
if (item.query) {
|
||||
// Item has a reference query (e.g. folders), use that.
|
||||
const media = this._source.dataset
|
||||
.get({
|
||||
filter: (timelineItem) => item.query === timelineItem.query,
|
||||
})
|
||||
.map((timelineItem) => timelineItem.media)
|
||||
.filter(isTruthy);
|
||||
const selectedIndex = media.findIndex((m) => m.getID() === id);
|
||||
|
||||
if (selectedIndex >= 0) {
|
||||
const queryResults = new QueryResults({ results: media, selectedIndex });
|
||||
this._viewManagerEpoch?.manager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
query: item.query,
|
||||
queryResults,
|
||||
},
|
||||
modifiers: [new MergeContextViewModifier(context)],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const currentQueryType =
|
||||
QueryClassifier.getQueryType(view.query) ??
|
||||
this._source.getKeyType() === 'camera'
|
||||
? 'event'
|
||||
: this._source.getKeyType() === 'folder'
|
||||
? 'folder'
|
||||
: null;
|
||||
|
||||
const query = currentQueryType ? this._createQuery(currentQueryType) : null;
|
||||
if (query) {
|
||||
await this._viewManagerEpoch?.manager.setViewByParametersWithExistingQuery({
|
||||
params: { view: 'media', query: query },
|
||||
queryExecutorOptions: {
|
||||
selectResult: {
|
||||
id,
|
||||
},
|
||||
rejectResults: (results) => !results.hasResults(),
|
||||
},
|
||||
modifiers: [new MergeContextViewModifier(context)],
|
||||
});
|
||||
}
|
||||
const queryResults = this._buildQueryResultsFromExistingItem(item);
|
||||
if (item && item.query && queryResults) {
|
||||
this._viewManagerEpoch?.manager.setViewByParameters({
|
||||
params: {
|
||||
view: 'media',
|
||||
query: item.query,
|
||||
queryResults,
|
||||
},
|
||||
modifiers: [new MergeContextViewModifier(context)],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this._viewManagerEpoch.manager.setViewByParameters({
|
||||
@@ -630,6 +631,25 @@ export class TimelineController {
|
||||
this._ignoreClick = false;
|
||||
}
|
||||
|
||||
private _buildQueryResultsFromExistingItem(
|
||||
item: AdvancedCameraCardTimelineItem,
|
||||
): QueryResults | null {
|
||||
const query = item.query;
|
||||
if (!query || !this._source) {
|
||||
return null;
|
||||
}
|
||||
const media = this._source.dataset
|
||||
.get({
|
||||
filter: (timelineItem) => !!timelineItem.query && query === timelineItem.query,
|
||||
})
|
||||
.map((timelineItem) => timelineItem.media)
|
||||
.filter(isTruthy);
|
||||
const selectedIndex = media.findIndex((m) => m.getID() === item.id);
|
||||
return selectedIndex >= 0
|
||||
? new QueryResults({ results: media, selectedIndex })
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a broader prefetch window from a start and end basis.
|
||||
* @param window The window to broaden.
|
||||
@@ -643,31 +663,19 @@ export class TimelineController {
|
||||
};
|
||||
}
|
||||
|
||||
private _createQuery(
|
||||
type: QueryType,
|
||||
options?: {
|
||||
window?: TimelineWindow;
|
||||
},
|
||||
): Query | null {
|
||||
if (!this._timeline || !this._source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(
|
||||
this._getPrefetchWindow(options?.window ?? this._timeline.getWindow()),
|
||||
);
|
||||
|
||||
if (type === 'event') {
|
||||
const queries = this._source.getTimelineEventQueries(cacheFriendlyWindow);
|
||||
return queries ? new EventMediaQuery(queries) : null;
|
||||
} else if (type === 'recording') {
|
||||
const queries = this._source.getTimelineRecordingQueries(cacheFriendlyWindow);
|
||||
return queries ? new RecordingMediaQuery(queries) : null;
|
||||
} else if (type === 'folder') {
|
||||
const queries = this._source.getTimelineFolderQuery();
|
||||
return queries ? new FolderViewQuery(queries) : null;
|
||||
}
|
||||
return null;
|
||||
/**
|
||||
* Apply a cache-friendly prefetch window to all media queries.
|
||||
*/
|
||||
private _applyWindowToQuery(
|
||||
query: UnifiedQuery,
|
||||
window: TimelineWindow,
|
||||
): UnifiedQuery {
|
||||
const prefetchWindow = this._getPrefetchWindow(window);
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(prefetchWindow);
|
||||
return UnifiedQueryTransformer.rebuildQuery(query, {
|
||||
start: cacheFriendlyWindow.start,
|
||||
end: cacheFriendlyWindow.end,
|
||||
});
|
||||
}
|
||||
|
||||
private _timelineRangeChangedHandler = async (properties: {
|
||||
@@ -690,16 +698,14 @@ export class TimelineController {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._source?.refresh(this._getPrefetchWindow(properties), {
|
||||
view,
|
||||
});
|
||||
await this._source?.refresh(this._getPrefetchWindow(properties));
|
||||
|
||||
const queryType = QueryClassifier.getQueryType(view.query);
|
||||
if (!queryType) {
|
||||
if (!view.query) {
|
||||
return;
|
||||
}
|
||||
const query = this._createQuery(queryType);
|
||||
if (!query || this._alreadyHasAcceptableMediaQuery(query)) {
|
||||
const query = this._applyWindowToQuery(view.query, properties);
|
||||
|
||||
if (this._alreadyHasAcceptableMediaQuery(query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -720,27 +726,22 @@ export class TimelineController {
|
||||
});
|
||||
};
|
||||
|
||||
private _alreadyHasAcceptableMediaQuery(freshQuery: Query): boolean {
|
||||
private _alreadyHasAcceptableMediaQuery(freshQuery: UnifiedQuery): boolean {
|
||||
const view = this._viewManagerEpoch?.manager.getView();
|
||||
const query = view?.query;
|
||||
|
||||
if (!this._cameraManager || !query) {
|
||||
if (!this._source || !query) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentResultTimestamp = view?.queryResults?.getResultsTimestamp();
|
||||
if (!currentResultTimestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!query?.getQuery() &&
|
||||
!!currentResultTimestamp &&
|
||||
((QueryClassifier.isFolderQuery(query) && query.isEqual(freshQuery)) ||
|
||||
(QueryClassifier.isMediaQuery(query) &&
|
||||
QueryClassifier.isMediaQuery(freshQuery) &&
|
||||
query.isSupersetOf(freshQuery) &&
|
||||
this._cameraManager.areMediaQueriesResultsFresh<MediaQuery>(
|
||||
currentResultTimestamp,
|
||||
query.getQuery(),
|
||||
)))
|
||||
query.isSupersetOf(freshQuery) &&
|
||||
this._source.areResultsFresh(currentResultTimestamp, query)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -787,16 +788,14 @@ export class TimelineController {
|
||||
}
|
||||
const prefetchedWindow = this._getPrefetchWindow(desiredWindow);
|
||||
|
||||
if (!this._pointerHeld) {
|
||||
if (!this._pointerHeld && view.query) {
|
||||
// Don't fetch any data or touch the timeline in any way if the user is
|
||||
// currently interacting with it. Without this the subsequent data fetches
|
||||
// (via fetchIfNecessary) may update the timeline contents which causes
|
||||
// the visjs timeline to stop dragging/panning operations which is very
|
||||
// disruptive to the user.
|
||||
await this._source?.refresh(prefetchedWindow, {
|
||||
view,
|
||||
});
|
||||
this._source.addEventMediaToDataset(view.queryResults?.getResults(), view.query);
|
||||
await this._source?.refresh(prefetchedWindow);
|
||||
this._source.addMediaToDataset(view.query, view.queryResults?.getResults());
|
||||
}
|
||||
|
||||
const currentSelection = this._timeline.getSelection();
|
||||
@@ -845,14 +844,11 @@ export class TimelineController {
|
||||
//
|
||||
// Also don't generate thumbnails in mini-timelines (they will already have
|
||||
// been generated).
|
||||
const queryType = QueryClassifier.getQueryType(view.query);
|
||||
if (!queryType) {
|
||||
if (!view.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshMediaQuery = this._createQuery(queryType, {
|
||||
window: desiredWindow,
|
||||
});
|
||||
const freshMediaQuery = this._applyWindowToQuery(view.query, desiredWindow);
|
||||
|
||||
if (
|
||||
!this._mini &&
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
import { add, sub } from 'date-fns';
|
||||
import { DataSet } from 'vis-data';
|
||||
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
|
||||
import { EqualityCache } from '../../cache/equality-cache';
|
||||
import { CameraManager } from '../../camera-manager/manager';
|
||||
import {
|
||||
compressRanges,
|
||||
ExpiringMemoryRangeSet,
|
||||
MemoryRangeSet,
|
||||
} from '../../camera-manager/range';
|
||||
import {
|
||||
EventQuery,
|
||||
RecordingQuery,
|
||||
RecordingSegment,
|
||||
} from '../../camera-manager/types';
|
||||
import { RecordingSegment } from '../../camera-manager/types';
|
||||
import { capEndDate } from '../../camera-manager/utils/cap-end-date';
|
||||
import { convertRangeToCacheFriendlyTimes } from '../../camera-manager/utils/range-to-cache-friendly';
|
||||
import { FoldersManager } from '../../card-controller/folders/manager';
|
||||
import { FolderQuery } from '../../card-controller/folders/types';
|
||||
import { ConditionStateManagerReadonlyInterface } from '../../conditions/types';
|
||||
import { FolderConfig } from '../../config/schema/folders';
|
||||
import { ClipsOrSnapshotsOrAll } from '../../types';
|
||||
import { errorToConsole, ModifyInterface } from '../../utils/basic.js';
|
||||
import { errorToConsole } from '../../utils/basic.js';
|
||||
import { ViewItem, ViewMedia } from '../../view/item';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier';
|
||||
import { FolderViewQuery, Query } from '../../view/query';
|
||||
import { View } from '../../view/view';
|
||||
import { TimelineKeys } from './types';
|
||||
import { UnifiedQuery } from '../../view/unified-query';
|
||||
import { UnifiedQueryBuilder } from '../../view/unified-query-builder';
|
||||
import { UnifiedQueryRunner } from '../../view/unified-query-runner';
|
||||
import { UnifiedQueryTransformer } from '../../view/unified-query-transformer';
|
||||
|
||||
// Allow timeline freshness to be at least this number of seconds out of date
|
||||
// (caching times in the data-engine may increase the effective delay).
|
||||
@@ -37,9 +31,7 @@ const TIMELINE_FRESHNESS_TOLERANCE_SECONDS = 30;
|
||||
// instead of clean recording blocks.
|
||||
const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60;
|
||||
|
||||
type TimelineViewQuery = Query;
|
||||
|
||||
export interface AdvancedCameraCardTimelineItem extends TimelineItem {
|
||||
export type AdvancedCameraCardTimelineItem = TimelineItem & {
|
||||
// Use numbers to avoid significant volumes of Date object construction (for
|
||||
// high-quantity recording segments).
|
||||
start: number;
|
||||
@@ -48,25 +40,33 @@ export interface AdvancedCameraCardTimelineItem extends TimelineItem {
|
||||
// DataSet requires string (not HTMLElement) content.
|
||||
content: string;
|
||||
|
||||
media?: ViewMedia;
|
||||
|
||||
// View query object from which this timeline item is associated with.
|
||||
query?: TimelineViewQuery;
|
||||
}
|
||||
// Severity is duplicated here (also available via media.getSeverity())
|
||||
// because vis-timeline's dataAttributes option requires properties to exist
|
||||
// directly on the item object to render them as data-* HTML attributes for
|
||||
// CSS styling.
|
||||
severity?: string;
|
||||
} & ( // Ensure that if there's a media item there is a query it is associated with.
|
||||
| {
|
||||
media: ViewMedia;
|
||||
query: UnifiedQuery;
|
||||
}
|
||||
| {
|
||||
media?: never;
|
||||
query?: never;
|
||||
}
|
||||
);
|
||||
|
||||
interface AdvancedCameraCardGroup {
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface RefreshOptions {
|
||||
view?: View;
|
||||
}
|
||||
|
||||
export class TimelineDataSource {
|
||||
private _cameraManager: CameraManager;
|
||||
private _foldersManager: FoldersManager;
|
||||
private _conditionStateManager: ConditionStateManagerReadonlyInterface;
|
||||
|
||||
private _builder: UnifiedQueryBuilder;
|
||||
private _runner: UnifiedQueryRunner;
|
||||
|
||||
private _dataset: DataSet<AdvancedCameraCardTimelineItem> = new DataSet();
|
||||
private _groups: DataSet<AdvancedCameraCardGroup>;
|
||||
|
||||
@@ -76,33 +76,34 @@ export class TimelineDataSource {
|
||||
// high-N segments into a smaller number of consecutive recording blocks).
|
||||
private _recordingRanges = new MemoryRangeSet();
|
||||
|
||||
// Cache event ranges since re-adding the same events is a timeline
|
||||
// performance killer (even if the request results are cached).
|
||||
private _eventRanges = new ExpiringMemoryRangeSet();
|
||||
private _folderCache = new EqualityCache<FolderQuery, Date>();
|
||||
// Cache for all query results in this source instance.
|
||||
// Uses a single range set since shape determines content type.
|
||||
private _cache = new ExpiringMemoryRangeSet();
|
||||
|
||||
private _eventsMediaType: ClipsOrSnapshotsOrAll;
|
||||
private _showRecordings: boolean;
|
||||
|
||||
private _keys: TimelineKeys;
|
||||
// The "shape" of the query, a UnifiedQuery without time ranges. Determines
|
||||
// the groups/structure of the timeline.
|
||||
private _shape: UnifiedQuery;
|
||||
|
||||
constructor(
|
||||
cameraManager: CameraManager,
|
||||
foldersManager: FoldersManager,
|
||||
conditionStateManager: ConditionStateManagerReadonlyInterface,
|
||||
keys: TimelineKeys,
|
||||
eventsMediaType: ClipsOrSnapshotsOrAll,
|
||||
shape: UnifiedQuery,
|
||||
showRecordings: boolean,
|
||||
) {
|
||||
this._cameraManager = cameraManager;
|
||||
this._foldersManager = foldersManager;
|
||||
this._conditionStateManager = conditionStateManager;
|
||||
this._keys = keys;
|
||||
|
||||
this._groups = this._generateGroups(keys);
|
||||
|
||||
this._eventsMediaType = eventsMediaType;
|
||||
this._builder = new UnifiedQueryBuilder(cameraManager, foldersManager);
|
||||
this._runner = new UnifiedQueryRunner(
|
||||
cameraManager,
|
||||
foldersManager,
|
||||
conditionStateManager,
|
||||
);
|
||||
this._shape = shape;
|
||||
this._showRecordings = showRecordings;
|
||||
|
||||
this._groups = this._generateGroups();
|
||||
}
|
||||
|
||||
get dataset(): DataSet<AdvancedCameraCardTimelineItem> {
|
||||
@@ -113,8 +114,12 @@ export class TimelineDataSource {
|
||||
return this._groups;
|
||||
}
|
||||
|
||||
public getKeyType(): 'camera' | 'folder' {
|
||||
return this._keys.type;
|
||||
get shape(): UnifiedQuery {
|
||||
return this._shape;
|
||||
}
|
||||
|
||||
public areResultsFresh(resultsTimestamp: Date, query: UnifiedQuery): boolean {
|
||||
return this._runner.areResultsFresh(resultsTimestamp, query);
|
||||
}
|
||||
|
||||
private _getGroupIDForCamera(cameraID: string): string {
|
||||
@@ -122,30 +127,32 @@ export class TimelineDataSource {
|
||||
}
|
||||
|
||||
private _getGroupIDForFolder(folderConfig: FolderConfig): string {
|
||||
return folderConfig.id;
|
||||
return `folder/${folderConfig.id}`;
|
||||
}
|
||||
|
||||
private _generateGroups(keys: TimelineKeys): DataSet<AdvancedCameraCardGroup> {
|
||||
private _generateGroups(): DataSet<AdvancedCameraCardGroup> {
|
||||
const groups: AdvancedCameraCardGroup[] = [];
|
||||
|
||||
/* istanbul ignore else: the else path cannot be reached -- @preserve */
|
||||
if (keys.type === 'camera') {
|
||||
keys.cameraIDs?.forEach((cameraID) => {
|
||||
const cameraMetadata = this._cameraManager.getCameraMetadata(cameraID);
|
||||
|
||||
groups.push({
|
||||
id: this._getGroupIDForCamera(cameraID),
|
||||
content: cameraMetadata?.title ?? cameraID,
|
||||
});
|
||||
});
|
||||
} else if (keys.type === 'folder') {
|
||||
const folderID = this._getGroupIDForFolder(keys.folder);
|
||||
// Add folder-based groups
|
||||
const folderQueries = this._shape.getFolderQueries();
|
||||
for (const folderQuery of folderQueries) {
|
||||
const folderID = this._getGroupIDForFolder(folderQuery.folder);
|
||||
groups.push({
|
||||
id: folderID,
|
||||
content: keys.folder?.title ?? folderID,
|
||||
content: folderQuery.folder.title ?? folderID,
|
||||
});
|
||||
}
|
||||
|
||||
// Add camera-based groups
|
||||
const cameraIDs = this._shape.getAllCameraIDs();
|
||||
cameraIDs.forEach((cameraID) => {
|
||||
const cameraMetadata = this._cameraManager.getCameraMetadata(cameraID);
|
||||
groups.push({
|
||||
id: this._getGroupIDForCamera(cameraID),
|
||||
content: cameraMetadata?.title ?? cameraID,
|
||||
});
|
||||
});
|
||||
|
||||
return new DataSet(groups);
|
||||
}
|
||||
|
||||
@@ -164,14 +171,11 @@ export class TimelineDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
public addEventMediaToDataset(
|
||||
mediaArray?: ViewItem[] | null,
|
||||
query?: TimelineViewQuery | null,
|
||||
): void {
|
||||
public addMediaToDataset(query: UnifiedQuery, mediaArray?: ViewItem[] | null): void {
|
||||
const data: AdvancedCameraCardTimelineItem[] = [];
|
||||
|
||||
for (const media of mediaArray ?? []) {
|
||||
if (!ViewItemClassifier.isEvent(media)) {
|
||||
if (!ViewItemClassifier.isEvent(media) && !ViewItemClassifier.isReview(media)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -193,7 +197,10 @@ export class TimelineDataSource {
|
||||
start: startTime.getTime(),
|
||||
type: 'range',
|
||||
end: media.getUsableEndTime()?.getTime(),
|
||||
...(query && { query }),
|
||||
...(ViewItemClassifier.isReview(media) && {
|
||||
severity: media.getSeverity() ?? undefined,
|
||||
}),
|
||||
query,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -201,134 +208,60 @@ export class TimelineDataSource {
|
||||
this._dataset.update(data);
|
||||
}
|
||||
|
||||
private async _refreshEvents(
|
||||
window: TimelineWindow,
|
||||
options?: RefreshOptions,
|
||||
): Promise<void> {
|
||||
await this._refreshEventsFromCamera(window, options);
|
||||
await this._refreshEventsFromFolder();
|
||||
public buildRecordingsWindowedQuery(window: TimelineWindow): UnifiedQuery | null {
|
||||
return this._builder.buildRecordingsQuery(this._shape.getAllCameraIDs(), {
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
});
|
||||
}
|
||||
|
||||
private async _refreshEventsFromCamera(
|
||||
window: TimelineWindow,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_options?: RefreshOptions,
|
||||
): Promise<void> {
|
||||
if (this._keys.type !== 'camera') {
|
||||
return;
|
||||
}
|
||||
private async _refreshQuery(window: TimelineWindow): Promise<void> {
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
|
||||
|
||||
if (
|
||||
this._eventRanges.hasCoverage({
|
||||
start: window.start,
|
||||
end: sub(capEndDate(window.end), {
|
||||
this._cache.hasCoverage({
|
||||
start: cacheFriendlyWindow.start,
|
||||
end: sub(capEndDate(cacheFriendlyWindow.end), {
|
||||
seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS,
|
||||
}),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window);
|
||||
const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow);
|
||||
if (!eventQueries) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addEventMediaToDataset(
|
||||
await this._cameraManager.executeMediaQueries(eventQueries),
|
||||
);
|
||||
const query = UnifiedQueryTransformer.rebuildQuery(this._shape, {
|
||||
start: cacheFriendlyWindow.start,
|
||||
end: cacheFriendlyWindow.end,
|
||||
});
|
||||
|
||||
this._eventRanges.add({
|
||||
this.addMediaToDataset(query, await this._runner.execute(query));
|
||||
this._cache.add({
|
||||
...cacheFriendlyWindow,
|
||||
expires: add(new Date(), { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }),
|
||||
});
|
||||
}
|
||||
|
||||
private async _refreshEventsFromFolder(): Promise<void> {
|
||||
if (this._keys.type !== 'folder') {
|
||||
return;
|
||||
}
|
||||
|
||||
const folderQuery = this.getTimelineFolderQuery();
|
||||
if (!folderQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastDate = this._folderCache.get(folderQuery);
|
||||
const now = new Date();
|
||||
|
||||
if (
|
||||
lastDate &&
|
||||
lastDate >= sub(now, { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.addEventMediaToDataset(
|
||||
await this._foldersManager.expandFolder(
|
||||
folderQuery,
|
||||
this._conditionStateManager.getState(),
|
||||
),
|
||||
new FolderViewQuery(folderQuery),
|
||||
);
|
||||
this._folderCache.set(folderQuery, now);
|
||||
}
|
||||
|
||||
public async refresh(window: TimelineWindow, options?: RefreshOptions): Promise<void> {
|
||||
public async refresh(window: TimelineWindow): Promise<void> {
|
||||
try {
|
||||
await Promise.all([
|
||||
this._refreshEvents(window, options),
|
||||
this._refreshQuery(window),
|
||||
...(this._showRecordings ? [this._refreshRecordings(window)] : []),
|
||||
]);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
|
||||
// Intentionally ignore errors here, since it is likely the user will
|
||||
// change the range again and a subsequent call may work. To do otherwise
|
||||
// would be jarring to the timeline experience in the case of transient
|
||||
// errors from the backend.
|
||||
}
|
||||
}
|
||||
|
||||
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null {
|
||||
if (this._keys.type !== 'camera' || !this._keys.cameraIDs.size) {
|
||||
return null;
|
||||
}
|
||||
return this._cameraManager.generateDefaultEventQueries(this._keys.cameraIDs, {
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
...(this._eventsMediaType === 'clips' && { hasClip: true }),
|
||||
...(this._eventsMediaType === 'snapshots' && { hasSnapshot: true }),
|
||||
});
|
||||
}
|
||||
|
||||
public getTimelineRecordingQueries(window: TimelineWindow): RecordingQuery[] | null {
|
||||
if (this._keys.type !== 'camera' || !this._keys.cameraIDs.size) {
|
||||
return null;
|
||||
}
|
||||
return this._cameraManager.generateDefaultRecordingQueries(this._keys.cameraIDs, {
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
});
|
||||
}
|
||||
|
||||
public getTimelineFolderQuery(): FolderQuery | null {
|
||||
if (this._keys.type !== 'folder') {
|
||||
return null;
|
||||
}
|
||||
return this._foldersManager.generateDefaultFolderQuery(this._keys.folder);
|
||||
}
|
||||
|
||||
private async _refreshRecordings(window: TimelineWindow): Promise<void> {
|
||||
const cameraIDs = this._keys.type === 'camera' ? this._keys.cameraIDs : null;
|
||||
// Recordings only apply to camera-based shapes
|
||||
const cameraIDs = this._shape.getAllCameraIDs();
|
||||
if (!cameraIDs?.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
type AdvancedCameraCardTimelineItemWithEnd = ModifyInterface<
|
||||
AdvancedCameraCardTimelineItem,
|
||||
{ end: number }
|
||||
>;
|
||||
type AdvancedCameraCardTimelineItemWithEnd = AdvancedCameraCardTimelineItem & {
|
||||
end: number;
|
||||
};
|
||||
|
||||
const convertSegmentToRecording = (
|
||||
cameraID: string,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { CameraManager } from '../../camera-manager/manager';
|
||||
import { ViewItemManager } from '../../card-controller/view/item-manager';
|
||||
import { ViewManagerEpoch } from '../../card-controller/view/types';
|
||||
import { CameraConfig } from '../../config/schema/cameras';
|
||||
import { FolderConfig } from '../../config/schema/folders';
|
||||
import { HomeAssistant } from '../../ha/types';
|
||||
import { ViewMedia } from '../../view/item';
|
||||
|
||||
@@ -21,16 +20,6 @@ export interface ThumbnailDataRequest {
|
||||
|
||||
export class ThumbnailDataRequestEvent extends CustomEvent<ThumbnailDataRequest> {}
|
||||
|
||||
export type TimelineKeys =
|
||||
| {
|
||||
type: 'camera';
|
||||
cameraIDs: Set<string>;
|
||||
}
|
||||
| {
|
||||
type: 'folder';
|
||||
folder: FolderConfig;
|
||||
};
|
||||
|
||||
export interface ExtendedTimeline extends Timeline {
|
||||
// setCustomTimeMarker currently missing from Timeline types.
|
||||
setCustomTimeMarker?(time: DateType, id?: IdType): void;
|
||||
|
||||
Reference in New Issue
Block a user