Initial support for grid for live and media viewer.
This commit is contained in:
@@ -1,40 +1,151 @@
|
||||
import clone from 'lodash-es/clone.js';
|
||||
import { isSuperset } from '../utils/basic.js';
|
||||
import { ViewMedia } from './media.js';
|
||||
|
||||
export class MediaQueriesResults {
|
||||
protected _results: ViewMedia[] | null = null;
|
||||
protected _resultsTimestamp: Date | null = null;
|
||||
protected _selectedIndex: number | null = null;
|
||||
type CameraResultSlices = Map<string, ResultSlice>;
|
||||
type SelectApproach = 'first' | 'last';
|
||||
|
||||
constructor(results?: ViewMedia[], selectedIndex?: number | null) {
|
||||
if (results) {
|
||||
this.setResults(results);
|
||||
interface ResultSliceOptions {
|
||||
results?: ViewMedia[];
|
||||
selectedIndex?: number | null;
|
||||
selectApproach?: SelectApproach;
|
||||
}
|
||||
|
||||
class ResultSlice {
|
||||
protected _results: ViewMedia[];
|
||||
protected _selectedIndex: number | null;
|
||||
|
||||
constructor(options?: ResultSliceOptions) {
|
||||
this._results = options?.results ?? [];
|
||||
this._selectedIndex = this._getInitialSelectedIndex(options);
|
||||
}
|
||||
|
||||
protected _getInitialSelectedIndex(options?: ResultSliceOptions): number | null {
|
||||
if (options?.selectedIndex !== undefined && options?.selectedIndex !== null) {
|
||||
return options.selectedIndex;
|
||||
}
|
||||
if (selectedIndex !== undefined) {
|
||||
this.selectResult(selectedIndex);
|
||||
if (options?.results && options.results.length) {
|
||||
if (!options?.selectApproach || options?.selectApproach === 'last') {
|
||||
return options.results.length - 1;
|
||||
} else if (options.selectApproach === 'first') {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public clone(): ResultSlice {
|
||||
return new ResultSlice({
|
||||
results: this._results,
|
||||
selectedIndex: this._selectedIndex,
|
||||
});
|
||||
}
|
||||
|
||||
public getResults(): ViewMedia[] {
|
||||
return this._results;
|
||||
}
|
||||
public getSelectedIndex(): number | null {
|
||||
return this._selectedIndex;
|
||||
}
|
||||
public getResultsCount(): number {
|
||||
return this.getResults().length;
|
||||
}
|
||||
public hasResults(): boolean {
|
||||
return this.getResultsCount() !== 0;
|
||||
}
|
||||
public getResult(index?: number): ViewMedia | null {
|
||||
return index === undefined ? null : this._results[index];
|
||||
}
|
||||
public getSelectedResult(): ViewMedia | null {
|
||||
const index = this.getSelectedIndex();
|
||||
return index !== null ? this.getResult(index) : null;
|
||||
}
|
||||
public hasSelectedResult(): boolean {
|
||||
return this.getSelectedResult() !== null;
|
||||
}
|
||||
public resetSelectedResult(): void {
|
||||
this._selectedIndex = null;
|
||||
}
|
||||
|
||||
public selectIndex(index: number | null): void {
|
||||
if (index === null || (index >= 0 && index < this._results.length)) {
|
||||
this._selectedIndex = index;
|
||||
}
|
||||
}
|
||||
public selectResultIfFound(func: (media: ViewMedia) => boolean): void {
|
||||
for (const [index, result] of this._results.entries()) {
|
||||
if (func(result)) {
|
||||
this.selectIndex(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
public selectBestResult(func: (media: ViewMedia[]) => number | null): void {
|
||||
const resultIndex = func(this._results);
|
||||
if (resultIndex !== null) {
|
||||
this.selectIndex(resultIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ResultSliceSelectionCriteria {
|
||||
main?: boolean;
|
||||
cameraID?: string;
|
||||
allCameras?: boolean;
|
||||
}
|
||||
|
||||
export class MediaQueriesResults {
|
||||
protected _resultsTimestamp: Date | null = null;
|
||||
protected _main: ResultSlice;
|
||||
protected _cameras: CameraResultSlices = new Map();
|
||||
|
||||
constructor(options?: ResultSliceOptions) {
|
||||
this._resultsTimestamp = new Date();
|
||||
this._main = new ResultSlice(options);
|
||||
this._buildByCameraSlices(options?.selectApproach);
|
||||
}
|
||||
|
||||
protected _buildByCameraSlices(selectApproach?: SelectApproach): void {
|
||||
const cameraMap: Map<string, ViewMedia[]> = new Map();
|
||||
for (const result of this._main.getResults()) {
|
||||
const cameraID = result.getCameraID();
|
||||
const media: ViewMedia[] = cameraMap.get(cameraID) ?? [];
|
||||
media.push(result);
|
||||
cameraMap.set(cameraID, media);
|
||||
}
|
||||
|
||||
for (const [cameraID, media] of cameraMap.entries()) {
|
||||
this._cameras.set(
|
||||
cameraID,
|
||||
new ResultSlice({
|
||||
results: media,
|
||||
selectApproach: selectApproach,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public clone(): MediaQueriesResults {
|
||||
// Shallow clone -- will reuse the same _results object (as there are no
|
||||
// Shallow clone -- will reuse the same results object (as there are no
|
||||
// methods that support modification of the results themselves, and since
|
||||
// changing the selectedIndex on a consistent set of results is a common
|
||||
// changing the index on a consistent set of results is a very common
|
||||
// operation).
|
||||
return clone(this);
|
||||
const copy = new MediaQueriesResults();
|
||||
copy._resultsTimestamp = this._resultsTimestamp;
|
||||
copy._main = this._main.clone();
|
||||
|
||||
for (const [cameraID, slice] of this._cameras.entries()) {
|
||||
copy._cameras.set(cameraID, slice.clone());
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
public isSupersetOf(that: MediaQueriesResults): boolean {
|
||||
if (!this._results || !that._results) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const thisMediaIDs = new Set(this._results.map((media) => media.getID()));
|
||||
const thatMediaIDs = new Set(that._results.map((media) => media.getID()));
|
||||
const thisMediaIDs = new Set(this._main.getResults()?.map((media) => media.getID()));
|
||||
const thatMediaIDs = new Set(that._main.getResults()?.map((media) => media.getID()));
|
||||
|
||||
if (
|
||||
!thisMediaIDs ||
|
||||
!thatMediaIDs ||
|
||||
!thisMediaIDs.size ||
|
||||
!thatMediaIDs.size ||
|
||||
// If either media sets contain a null identifier (i.e. a media item with
|
||||
// no ID) we must assume this is not a subset as multiple media items may
|
||||
// reduce to the same null identifier above.
|
||||
@@ -46,68 +157,105 @@ export class MediaQueriesResults {
|
||||
return isSuperset(thisMediaIDs, thatMediaIDs);
|
||||
}
|
||||
|
||||
public getResults(): ViewMedia[] | null {
|
||||
return this._results;
|
||||
public getCameraIDs(): Set<string> {
|
||||
return new Set(this._cameras.keys());
|
||||
}
|
||||
public getResultsCount(): number {
|
||||
return this._results?.length ?? 0;
|
||||
|
||||
public getSlice(cameraID?: string): ResultSlice | null {
|
||||
return cameraID ? this._cameras.get(cameraID) ?? null : this._main;
|
||||
}
|
||||
public hasResults(): boolean {
|
||||
return !!this._results;
|
||||
|
||||
public getResults(cameraID?: string): ViewMedia[] | null {
|
||||
return this.getSlice(cameraID)?.getResults() ?? null;
|
||||
}
|
||||
public setResults(results: ViewMedia[]) {
|
||||
this._results = results;
|
||||
this._resultsTimestamp = new Date();
|
||||
public getResultsCount(cameraID?: string): number {
|
||||
return this.getSlice(cameraID)?.getResultsCount() ?? 0;
|
||||
}
|
||||
public getResult(index?: number): ViewMedia | null {
|
||||
if (!this._results || index === undefined) {
|
||||
return null;
|
||||
}
|
||||
return this._results[index];
|
||||
public hasResults(cameraID?: string): boolean {
|
||||
return this.getSlice(cameraID)?.getResultsCount() !== 0;
|
||||
}
|
||||
public getSelectedResult(): ViewMedia | null {
|
||||
return this._selectedIndex === null ? null : this.getResult(this._selectedIndex);
|
||||
public getResult(index?: number, cameraID?: string): ViewMedia | null {
|
||||
return this.getSlice(cameraID)?.getResult(index) ?? null;
|
||||
}
|
||||
public getSelectedIndex(): number | null {
|
||||
return this._selectedIndex;
|
||||
public getSelectedIndex(cameraID?: string): number | null {
|
||||
return this.getSlice(cameraID)?.getSelectedIndex() ?? null;
|
||||
}
|
||||
public hasSelectedResult(): boolean {
|
||||
return this.getSelectedResult() !== null;
|
||||
public getSelectedResult(cameraID?: string): ViewMedia | null {
|
||||
return this.getSlice(cameraID)?.getSelectedResult() ?? null;
|
||||
}
|
||||
public resetSelectedResult(): MediaQueriesResults {
|
||||
this._selectedIndex = null;
|
||||
public hasSelectedResult(cameraID?: string): boolean {
|
||||
return this.getSlice(cameraID)?.hasSelectedResult() ?? false;
|
||||
}
|
||||
public resetSelectedResult(cameraID?: string): MediaQueriesResults {
|
||||
this.getSlice(cameraID)?.resetSelectedResult();
|
||||
return this;
|
||||
}
|
||||
public getResultsTimestamp(): Date | null {
|
||||
return this._resultsTimestamp;
|
||||
}
|
||||
|
||||
public selectResult(index: number | null): MediaQueriesResults {
|
||||
if (
|
||||
index === null ||
|
||||
(this._results && index >= 0 && index < this._results.length)
|
||||
) {
|
||||
this._selectedIndex = index;
|
||||
public selectIndex(index: number, cameraID?: string): MediaQueriesResults {
|
||||
this.getSlice(cameraID)?.selectIndex(index);
|
||||
if (!cameraID) {
|
||||
// If the main selection is changed, it must also change the matching
|
||||
// camera selection.
|
||||
this.demoteMainSelectionToCameraSelection();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
public selectResultIfFound(func: (media: ViewMedia) => boolean): MediaQueriesResults {
|
||||
for (const [index, result] of this._results?.entries() ?? []) {
|
||||
if (func(result)) {
|
||||
this._selectedIndex = index;
|
||||
break;
|
||||
}
|
||||
|
||||
public demoteMainSelectionToCameraSelection(): MediaQueriesResults {
|
||||
const selected = this.getSelectedResult();
|
||||
if (selected) {
|
||||
const cameraID = selected.getCameraID();
|
||||
this.resetSelectedResult(cameraID);
|
||||
this.selectResultIfFound((media) => media === selected, { cameraID: cameraID });
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public promoteCameraSelectionToMainSelection(cameraID: string): MediaQueriesResults {
|
||||
const selected = this.getSelectedResult(cameraID);
|
||||
this.resetSelectedResult();
|
||||
this.selectResultIfFound((media) => media === selected);
|
||||
return this;
|
||||
}
|
||||
|
||||
protected _getCameraIDsFromCriteria(
|
||||
criteria?: ResultSliceSelectionCriteria,
|
||||
): Set<string> | null {
|
||||
return criteria?.allCameras
|
||||
? this.getCameraIDs()
|
||||
: criteria?.cameraID
|
||||
? new Set([criteria.cameraID])
|
||||
: null;
|
||||
}
|
||||
|
||||
public selectResultIfFound(
|
||||
func: (media: ViewMedia) => boolean,
|
||||
criteria?: ResultSliceSelectionCriteria,
|
||||
): MediaQueriesResults {
|
||||
if (!criteria || criteria?.main) {
|
||||
this._main.selectResultIfFound(func);
|
||||
this.demoteMainSelectionToCameraSelection();
|
||||
}
|
||||
const cameraIDs = this._getCameraIDsFromCriteria(criteria);
|
||||
for (const cameraID of cameraIDs ?? []) {
|
||||
this.getSlice(cameraID)?.selectResultIfFound(func);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
public selectBestResult(
|
||||
func: (media: ViewMedia[]) => number | null,
|
||||
criteria?: ResultSliceSelectionCriteria,
|
||||
): MediaQueriesResults {
|
||||
if (this._results) {
|
||||
const resultIndex = func(this._results);
|
||||
if (resultIndex !== null) {
|
||||
this._selectedIndex = resultIndex;
|
||||
}
|
||||
if (!criteria || criteria.main) {
|
||||
this._main.selectBestResult(func);
|
||||
this.demoteMainSelectionToCameraSelection();
|
||||
}
|
||||
const cameraIDs = this._getCameraIDsFromCriteria(criteria);
|
||||
for (const cameraID of cameraIDs ?? []) {
|
||||
this.getSlice(cameraID)?.selectBestResult(func);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -23,14 +23,24 @@ class MediaQueriesBase<T extends MediaQuery> {
|
||||
public setQueries(queries: T[]): void {
|
||||
this._queries = queries;
|
||||
}
|
||||
|
||||
public hasQueriesForCameraIDs(cameraIDs: Set<string>) {
|
||||
for (const cameraID of cameraIDs) {
|
||||
if (!this._queries?.some((query) => query.cameraIDs.has(cameraID))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class EventMediaQueries extends MediaQueriesBase<EventQuery> {
|
||||
public convertToClipsQueries(): void {
|
||||
public convertToClipsQueries(): this {
|
||||
for (const query of this._queries ?? []) {
|
||||
delete query.hasSnapshot;
|
||||
query.hasClip = true;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public clone(): EventMediaQueries {
|
||||
|
||||
@@ -13,9 +13,6 @@ export class ViewMedia {
|
||||
this._mediaType = mediaType;
|
||||
this._cameraID = cameraID;
|
||||
}
|
||||
public getContentType(): 'image' | 'video' {
|
||||
return this._mediaType === 'snapshot' ? 'image' : 'video';
|
||||
}
|
||||
public getCameraID(): string {
|
||||
return this._cameraID;
|
||||
}
|
||||
|
||||
+29
-1
@@ -1,5 +1,5 @@
|
||||
import { ViewContext } from 'view';
|
||||
import { ClipsOrSnapshots, FrigateCardView } from '../types.js';
|
||||
import { ClipsOrSnapshots, FrigateCardView, ViewDisplayMode } from '../types.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { MediaQueries } from './media-queries';
|
||||
import { MediaQueriesClassifier } from './media-queries-classifier.js';
|
||||
@@ -11,6 +11,7 @@ interface ViewEvolveParameters {
|
||||
query?: MediaQueries | null;
|
||||
queryResults?: MediaQueriesResults | null;
|
||||
context?: ViewContext | null;
|
||||
displayMode?: ViewDisplayMode | null;
|
||||
}
|
||||
|
||||
export interface ViewParameters extends ViewEvolveParameters {
|
||||
@@ -24,6 +25,7 @@ export class View {
|
||||
public query: MediaQueries | null;
|
||||
public queryResults: MediaQueriesResults | null;
|
||||
public context: ViewContext | null;
|
||||
public displayMode: ViewDisplayMode | null;
|
||||
|
||||
constructor(params: ViewParameters) {
|
||||
this.view = params.view;
|
||||
@@ -31,6 +33,7 @@ export class View {
|
||||
this.query = params.query ?? null;
|
||||
this.queryResults = params.queryResults ?? null;
|
||||
this.context = params.context ?? null;
|
||||
this.displayMode = params.displayMode ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,6 +145,7 @@ export class View {
|
||||
query: this.query?.clone() ?? null,
|
||||
queryResults: this.queryResults?.clone() ?? null,
|
||||
context: this.context,
|
||||
displayMode: this.displayMode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,6 +164,8 @@ export class View {
|
||||
? params.queryResults
|
||||
: this.queryResults?.clone() ?? null,
|
||||
context: params.context !== undefined ? params.context : this.context,
|
||||
displayMode:
|
||||
params.displayMode !== undefined ? params.displayMode : this.displayMode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,6 +191,17 @@ export class View {
|
||||
return this;
|
||||
}
|
||||
|
||||
public removeContextProperty(
|
||||
contextKey: keyof ViewContext,
|
||||
removeKey: PropertyKey,
|
||||
): View {
|
||||
const contextObj = this.context?.[contextKey];
|
||||
if (contextObj) {
|
||||
delete contextObj[removeKey];
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if current view matches a named view.
|
||||
*/
|
||||
@@ -214,6 +231,13 @@ export class View {
|
||||
return ['clip', 'snapshot', 'media', 'recording'].includes(this.view);
|
||||
}
|
||||
|
||||
public hasMultipleDisplayModes(cameraCount?: number): boolean {
|
||||
return (
|
||||
(this.is('live') && (cameraCount ?? 0) > 1) ||
|
||||
(this.isViewerView() && (this.queryResults?.getCameraIDs().size ?? 0) > 1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default media type for this view if available.
|
||||
* @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown
|
||||
@@ -232,6 +256,10 @@ export class View {
|
||||
return null;
|
||||
}
|
||||
|
||||
public isGrid(): boolean {
|
||||
return this.displayMode === 'grid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event to request a view change.
|
||||
* @param target The target dispatching the event.
|
||||
|
||||
Reference in New Issue
Block a user