Merge pull request #926 from dermotduffy/major-engine-refactor
Complete engine overhaul
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
/.rpt2_cache/
|
||||
package-lock.json
|
||||
/dist
|
||||
/.yarn/
|
||||
|
||||
.env
|
||||
.envrc
|
||||
|
||||
@@ -392,7 +392,7 @@ live:
|
||||
| `show_details` | `false` | :white_check_mark: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.|
|
||||
| `show_favorite_control` | `true` | :white_check_mark: | Whether to show the favorite ('star') control on each thumbnail.|
|
||||
| `show_timeline_control` | `true` | :white_check_mark: | Whether to show the timeline ('target') control on each thumbnail.|
|
||||
| `media` | `clips` | :white_check_mark: | Whether to show `clips` or `snapshots` in the thumbnail carousel in the `live` view.|
|
||||
| `media` | `all` | :white_check_mark: | Whether to show `clips`, `snapshots` or `all` in the thumbnail carousel in the `live` view.|
|
||||
|
||||
#### Live Controls: Next / Previous
|
||||
|
||||
@@ -548,11 +548,39 @@ See the [fully expanded event gallery configuration example](#config-expanded-ev
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the event gallery in pixels. Must be >= `75` and <= `175`.|
|
||||
| `show_details` | `false` | :heavy_multiplication_x: | Whether to show event details (e.g. duration, start time, object detected, etc) alongside the thumbnail.|
|
||||
| `controls` | | :heavy_multiplication_x: | Configuration for the Media viewer controls. See below. |
|
||||
| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`, `recordings`). See [actions](#actions) below.|
|
||||
|
||||
#### Event Gallery Controls: Filter
|
||||
|
||||
All configuration is under:
|
||||
|
||||
```yaml
|
||||
event_gallery:
|
||||
controls:
|
||||
filter:
|
||||
```
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `mode` | `right` | :heavy_multiplication_x: | Whether to show the gallery media filter to the `left`, to the `right` or `none` for no media filter. |
|
||||
|
||||
#### Event Gallery Controls: Thumbnails
|
||||
|
||||
All configuration is under:
|
||||
|
||||
```yaml
|
||||
event_gallery:
|
||||
controls:
|
||||
thumbnails:
|
||||
```
|
||||
|
||||
| Option | Default | Overridable | Description |
|
||||
| - | - | - | - |
|
||||
| `size` | 100 | :heavy_multiplication_x: | The size of the thumbnails in the gallery. Must be >= `75` and <= `175`.|
|
||||
| `show_details` | `false` | :heavy_multiplication_x: | Whether to show media details (e.g. duration, start time, object detected, etc) alongside the thumbnail.|
|
||||
| `show_favorite_control` | `true` | :heavy_multiplication_x: | Whether to show the favorite ('star') control on each thumbnail.|
|
||||
| `show_timeline_control` | `true` | :heavy_multiplication_x: | Whether to show the timeline ('target') control on each thumbnail.|
|
||||
| `actions` | | :heavy_multiplication_x: | Actions to use for all views that use the `event_gallery` (e.g. `clips`, `snapshots`, `recordings`). See [actions](#actions) below.|
|
||||
|
||||
### Image Options
|
||||
|
||||
@@ -1575,6 +1603,8 @@ Reference: [Event Gallery Options](#event-gallery-options).
|
||||
```yaml
|
||||
event_gallery:
|
||||
controls:
|
||||
filter:
|
||||
mode: 'right'
|
||||
thumbnails:
|
||||
size: 100
|
||||
show_details: false
|
||||
|
||||
+7
-3
@@ -17,6 +17,8 @@
|
||||
"dependencies": {
|
||||
"@cycjimmy/jsmpeg-player": "^6.0.4",
|
||||
"@egjs/hammerjs": "^2.0.17",
|
||||
"@graphiteds/core": "^1.9.6",
|
||||
"@lit-labs/scoped-registry-mixin": "^1.0.1",
|
||||
"@lit-labs/task": "^1.1.3",
|
||||
"@types/bluebird": "^3.5.36",
|
||||
"component-emitter": "^1.3.0",
|
||||
@@ -37,11 +39,11 @@
|
||||
"side-drawer": "^3.1.0",
|
||||
"ts-toolbelt": "^9.6.0",
|
||||
"uuid": "^8.3.2",
|
||||
"vis-data": "^7.1.3",
|
||||
"vis-data": "^7.1.4",
|
||||
"vis-timeline": "^7.7.0",
|
||||
"vis-util": "^5.0.2",
|
||||
"xss": "^1.0.14",
|
||||
"zod": "^3.19.0"
|
||||
"zod": "^3.20.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.0",
|
||||
@@ -70,12 +72,14 @@
|
||||
"rollup-plugin-typescript2": "^0.33.0",
|
||||
"rollup-plugin-visualizer": "^5.8.2",
|
||||
"sass": "^1.54.9",
|
||||
"ts-prune": "^0.10.3",
|
||||
"typescript": "^4.8.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "rollup -c --watch",
|
||||
"build": "yarn run lint && yarn run rollup",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"rollup": "rollup -c"
|
||||
"rollup": "rollup -c",
|
||||
"prune": "ts-prune"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ const getActionHandler = (): ActionHandler => {
|
||||
return actionhandler as ActionHandler;
|
||||
};
|
||||
|
||||
export const actionHandlerBind = (
|
||||
const actionHandlerBind = (
|
||||
element: ActionHandlerElement,
|
||||
options?: FrigateCardActionHandlerOptions,
|
||||
): void => {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import sortedUniqBy from 'lodash-es/sortedUniqBy';
|
||||
import { DateRange, MemoryRangeSet } from './range';
|
||||
import { DataQuery, QueryResults, RecordingSegment } from './types';
|
||||
|
||||
interface RequestCacheItem<Request, Response> {
|
||||
request: Request;
|
||||
response: Response;
|
||||
expires?: Date;
|
||||
}
|
||||
|
||||
interface CameraManagerCache<Request, Response> {
|
||||
get(request: Request): Response | null;
|
||||
has(request: Request): boolean;
|
||||
set(request: Request, response: Response, expiry?: Date): void;
|
||||
}
|
||||
|
||||
class MemoryRequestCache<Request, Response>
|
||||
implements CameraManagerCache<Request, Response>
|
||||
{
|
||||
protected _data: RequestCacheItem<Request, Response>[] = [];
|
||||
|
||||
public get(request: Request): Response | null {
|
||||
const now = new Date();
|
||||
for (const item of this._data) {
|
||||
if (
|
||||
(!item.expires || now <= item.expires) &&
|
||||
this._contains(request, item.request)
|
||||
) {
|
||||
return item.response;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public has(request: Request): boolean {
|
||||
return !!this.get(request);
|
||||
}
|
||||
|
||||
public set(request: Request, response: Response, expiry?: Date): void {
|
||||
this._data.push({
|
||||
request: request,
|
||||
response: response,
|
||||
expires: expiry,
|
||||
});
|
||||
|
||||
// Clean up old requests on set.
|
||||
this._expireOldRequests();
|
||||
}
|
||||
|
||||
protected _contains(a: Request, b: Request): boolean {
|
||||
return isEqual(a, b);
|
||||
}
|
||||
|
||||
protected _expireOldRequests(): void {
|
||||
const now = new Date();
|
||||
this._data = this._data.filter((item) => !item.expires || now < item.expires);
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestCache extends MemoryRequestCache<DataQuery, QueryResults> {}
|
||||
|
||||
class MemoryRangedCache<Data> {
|
||||
protected _ranges: MemoryRangeSet = new MemoryRangeSet();
|
||||
protected _data: Data[] = [];
|
||||
protected _timeFunc: (data: Data) => number;
|
||||
protected _idFunc: (data: Data) => string;
|
||||
|
||||
constructor(timeFunc: (data: Data) => number, idFunc: (data: Data) => string) {
|
||||
this._timeFunc = timeFunc;
|
||||
this._idFunc = idFunc;
|
||||
}
|
||||
|
||||
public add(range: DateRange, data: Data[]) {
|
||||
this._ranges.add(range);
|
||||
this._data = sortedUniqBy(
|
||||
orderBy(this._data.concat(data), this._timeFunc, 'asc'),
|
||||
this._idFunc,
|
||||
);
|
||||
}
|
||||
|
||||
public hasCoverage(range: DateRange): boolean {
|
||||
return this._ranges.hasCoverage(range);
|
||||
}
|
||||
|
||||
public get(range: DateRange): Data[] | null {
|
||||
if (!this.hasCoverage(range)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: Data[] = [];
|
||||
for (const data of this._data) {
|
||||
const start = this._timeFunc(data);
|
||||
if (start > range.start.getTime()) {
|
||||
if (start > range.end.getTime()) {
|
||||
// Data is kept in order.
|
||||
break;
|
||||
}
|
||||
output.push(data);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this._data.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove old data that matches a given predicate. No change to the covered
|
||||
* ranges is made, i.e. this is asserting authoritiatively that this data does
|
||||
* not exist in the current ranges.
|
||||
* @param predicate A predicate to run on each data element.
|
||||
*/
|
||||
public expireMatches(predicate: (data: Data) => boolean): void {
|
||||
this._data = this._data.filter(predicate);
|
||||
}
|
||||
}
|
||||
|
||||
export class RecordingSegmentsCache {
|
||||
protected _segments: Map<string, MemoryRangedCache<RecordingSegment>> = new Map();
|
||||
|
||||
public add(cameraID: string, range: DateRange, segments: RecordingSegment[]) {
|
||||
let cameraSegmentCache: MemoryRangedCache<RecordingSegment> | undefined =
|
||||
this._segments.get(cameraID);
|
||||
if (!cameraSegmentCache) {
|
||||
cameraSegmentCache = new MemoryRangedCache(
|
||||
(segment: RecordingSegment) => segment.start_time * 1000,
|
||||
(segment: RecordingSegment) => segment.id,
|
||||
);
|
||||
this._segments.set(cameraID, cameraSegmentCache);
|
||||
}
|
||||
cameraSegmentCache.add(range, segments);
|
||||
}
|
||||
|
||||
public hasCoverage(cameraID: string, range: DateRange): boolean {
|
||||
return !!this._segments.get(cameraID)?.hasCoverage(range);
|
||||
}
|
||||
|
||||
public get(cameraID: string, range: DateRange): RecordingSegment[] | null {
|
||||
return this._segments.get(cameraID)?.get(range) ?? null;
|
||||
}
|
||||
|
||||
public getCache(cameraID: string): MemoryRangedCache<RecordingSegment> | null {
|
||||
return this._segments.get(cameraID) ?? null;
|
||||
}
|
||||
|
||||
public getCameraIDs(): string[] {
|
||||
return [...this._segments.keys()];
|
||||
}
|
||||
|
||||
public expireMatches(
|
||||
cameraID: string,
|
||||
func: (segment: RecordingSegment) => boolean,
|
||||
): void {
|
||||
this._segments.get(cameraID)?.expireMatches(func);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { CameraConfig, CardWideConfig } from '../types';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { RecordingSegmentsCache, RequestCache } from './cache';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
|
||||
import { Engine } from './types';
|
||||
|
||||
type CameraManagerEngineCameraIDMap = Map<CameraManagerEngine, Set<string>>;
|
||||
|
||||
export class CameraManagerEngineFactory {
|
||||
protected _engines: Map<Engine, CameraManagerEngine> = new Map();
|
||||
protected _cardWideConfig: CardWideConfig;
|
||||
|
||||
constructor(cardWideConfig: CardWideConfig) {
|
||||
this._cardWideConfig = cardWideConfig;
|
||||
}
|
||||
|
||||
public getEngine(engine: Engine): CameraManagerEngine | null {
|
||||
const cachedEngine = this._engines.get(engine);
|
||||
if (cachedEngine) {
|
||||
return cachedEngine;
|
||||
}
|
||||
let cameraManagerEngine: CameraManagerEngine | null = null;
|
||||
switch (engine) {
|
||||
case Engine.Frigate:
|
||||
cameraManagerEngine = new FrigateCameraManagerEngine(
|
||||
this._cardWideConfig,
|
||||
new RecordingSegmentsCache(),
|
||||
new RequestCache(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
if (cameraManagerEngine) {
|
||||
this._engines.set(engine, cameraManagerEngine);
|
||||
}
|
||||
return cameraManagerEngine;
|
||||
}
|
||||
|
||||
public getEngineForCamera(cameraConfig?: CameraConfig): CameraManagerEngine | null {
|
||||
if (!cameraConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let engine: Engine | null = null;
|
||||
if (cameraConfig.frigate.camera_name) {
|
||||
engine = Engine.Frigate;
|
||||
}
|
||||
return engine ? this.getEngine(engine) : null;
|
||||
}
|
||||
|
||||
public getEnginesForCameraIDs(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
): CameraManagerEngineCameraIDMap | null {
|
||||
const output: CameraManagerEngineCameraIDMap = new Map();
|
||||
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
if (!cameraConfig) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const engine = this.getEngineForCamera(cameraConfig);
|
||||
if (!engine) {
|
||||
continue;
|
||||
}
|
||||
if (!output.has(engine)) {
|
||||
output.set(engine, new Set());
|
||||
}
|
||||
output.get(engine)?.add(cameraID);
|
||||
}
|
||||
return output.size ? output : null;
|
||||
}
|
||||
|
||||
public getEngineForMedia(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
media: ViewMedia,
|
||||
): CameraManagerEngine | null {
|
||||
const cameraID = media.getCameraID();
|
||||
if (!cameraID) {
|
||||
return null;
|
||||
}
|
||||
const engines = this.getEnginesForCameraIDs(cameras, new Set([cameraID]));
|
||||
return engines ? ([...engines.keys()][0] ?? null) : null;
|
||||
}
|
||||
|
||||
public getAllEngines(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): CameraManagerEngine[] | null {
|
||||
const engines = this.getEnginesForCameraIDs(cameras, new Set(cameras.keys()));
|
||||
return engines ? [...engines.keys()] : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig } from '../types';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import {
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
EventQueryResultsMap,
|
||||
MediaMetadata,
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
QueryReturnType,
|
||||
RecordingQuery,
|
||||
RecordingQueryResultsMap,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
CameraManagerEngineCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
CameraManagerCameraMetadata,
|
||||
} from './types';
|
||||
|
||||
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||
|
||||
export interface CameraManagerEngine {
|
||||
generateDefaultEventQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialEventQuery,
|
||||
): EventQuery[] | null;
|
||||
|
||||
generateDefaultRecordingQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialRecordingQuery,
|
||||
): RecordingQuery[] | null;
|
||||
|
||||
generateDefaultRecordingSegmentsQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialRecordingSegmentsQuery,
|
||||
): RecordingSegmentsQuery[] | null;
|
||||
|
||||
getEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: EventQuery,
|
||||
): Promise<EventQueryResultsMap | null>;
|
||||
|
||||
getRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingQuery,
|
||||
): Promise<RecordingQueryResultsMap | null>;
|
||||
|
||||
getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingSegmentsQuery,
|
||||
): Promise<RecordingSegmentsQueryResultsMap | null>;
|
||||
|
||||
generateMediaFromEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: EventQuery,
|
||||
results: QueryReturnType<EventQuery>,
|
||||
): ViewMedia[] | null;
|
||||
|
||||
generateMediaFromRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingQuery,
|
||||
results: QueryReturnType<RecordingQuery>,
|
||||
): ViewMedia[] | null;
|
||||
|
||||
getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null;
|
||||
|
||||
favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
favorite: boolean,
|
||||
): Promise<void>;
|
||||
|
||||
getQueryResultMaxAge(query: DataQuery): number | null;
|
||||
|
||||
getMediaSeekTime(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
media: ViewMedia,
|
||||
target: Date,
|
||||
): Promise<number | null>;
|
||||
|
||||
getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): Promise<MediaMetadata | null>;
|
||||
|
||||
getCameraMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): CameraManagerCameraMetadata;
|
||||
|
||||
getCapabilities(): CameraManagerEngineCapabilities | null;
|
||||
getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null;
|
||||
}
|
||||
@@ -0,0 +1,839 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import add from 'date-fns/add';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import { CAMERA_BIRDSEYE } from '../../const';
|
||||
import { CameraConfig, CardWideConfig } from '../../types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { RequestCache, RecordingSegmentsCache } from '../cache';
|
||||
import {
|
||||
CameraManagerEngine,
|
||||
CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||
} from '../engine';
|
||||
import { DateRange } from '../range';
|
||||
import {
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerEngineCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
Engine,
|
||||
EventQuery,
|
||||
EventQueryResults,
|
||||
EventQueryResultsMap,
|
||||
FrigateEventQueryResults,
|
||||
FrigateRecordingQueryResults,
|
||||
FrigateRecordingSegmentsQueryResults,
|
||||
MediaMetadata,
|
||||
PartialEventQuery,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
QueryType,
|
||||
RecordingQuery,
|
||||
RecordingQueryResults,
|
||||
RecordingQueryResultsMap,
|
||||
RecordingSegment,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
} from '../types';
|
||||
import { FrigateRecording } from './types';
|
||||
import {
|
||||
getEvents,
|
||||
getEventSummary,
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
NativeFrigateEventQuery,
|
||||
NativeFrigateRecordingSegmentsQuery,
|
||||
retainEvent,
|
||||
} from './requests';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import {
|
||||
allPromises,
|
||||
formatDate,
|
||||
prettifyTitle,
|
||||
runWhenIdleIfSupported,
|
||||
} from '../../utils/basic';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import sum from 'lodash-es/sum';
|
||||
import { FrigateViewMediaClassifier } from './media-classifier';
|
||||
import { ViewMediaClassifier } from '../../view/media-classifier';
|
||||
import { FrigateViewMediaFactory } from './media';
|
||||
import { log } from '../../utils/debug';
|
||||
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
|
||||
|
||||
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||
|
||||
class FrigateQueryResultsClassifier {
|
||||
public static isFrigateEventQueryResults(
|
||||
results: QueryResults,
|
||||
): results is FrigateEventQueryResults {
|
||||
return results.engine === Engine.Frigate && results.type === QueryResultsType.Event;
|
||||
}
|
||||
|
||||
public static isFrigateRecordingQueryResults(
|
||||
results: QueryResults,
|
||||
): results is FrigateRecordingQueryResults {
|
||||
return (
|
||||
results.engine === Engine.Frigate && results.type === QueryResultsType.Recording
|
||||
);
|
||||
}
|
||||
|
||||
public static isFrigateRecordingSegmentsResults(
|
||||
results: QueryResults,
|
||||
): results is FrigateRecordingSegmentsQueryResults {
|
||||
return (
|
||||
results.engine === Engine.Frigate &&
|
||||
results.type === QueryResultsType.RecordingSegments
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FrigateCameraManagerEngine implements CameraManagerEngine {
|
||||
protected _recordingSegmentsCache: RecordingSegmentsCache;
|
||||
protected _requestCache: RequestCache;
|
||||
protected _cardWideConfig: CardWideConfig;
|
||||
|
||||
// Garbage collect segments at most once an hour.
|
||||
protected _throttledSegmentGarbageCollector = throttle(
|
||||
this._garbageCollectSegments.bind(this),
|
||||
60 * 60 * 1000,
|
||||
{ leading: false, trailing: true },
|
||||
);
|
||||
|
||||
constructor(
|
||||
cardWideConfig: CardWideConfig,
|
||||
recordingSegmentsCache: RecordingSegmentsCache,
|
||||
requestCache: RequestCache,
|
||||
) {
|
||||
this._cardWideConfig = cardWideConfig;
|
||||
this._recordingSegmentsCache = recordingSegmentsCache;
|
||||
this._requestCache = requestCache;
|
||||
}
|
||||
|
||||
public getMediaDownloadPath(
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
): string | null {
|
||||
let path: string | null = null;
|
||||
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
|
||||
path =
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/notifications/${media.getID()}/` +
|
||||
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
|
||||
`?download=true`;
|
||||
} else if (FrigateViewMediaClassifier.isFrigateRecording(media)) {
|
||||
path =
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/recording/${cameraConfig.frigate.camera_name}` +
|
||||
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
|
||||
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
|
||||
`?download=true`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
public generateDefaultEventQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query?: PartialEventQuery,
|
||||
): EventQuery[] | null {
|
||||
const relevantCameraConfigs = Array.from(cameraIDs).map((cameraID) =>
|
||||
cameras.get(cameraID),
|
||||
);
|
||||
|
||||
// If there isn't a label or zone specified, we can come up with a single
|
||||
// batch query for Frigate that will match across all cameras.
|
||||
const canDoBatchQuery = relevantCameraConfigs.every(
|
||||
(cameraConfig) => !cameraConfig?.frigate.label && !cameraConfig?.frigate.zone,
|
||||
);
|
||||
|
||||
if (canDoBatchQuery) {
|
||||
return [
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: cameraIDs,
|
||||
...query,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const output: EventQuery[] = [];
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
if (cameraConfig) {
|
||||
output.push({
|
||||
type: QueryType.Event,
|
||||
cameraIDs: new Set([cameraID]),
|
||||
...(cameraConfig.frigate.label && {
|
||||
what: new Set([cameraConfig.frigate.label]),
|
||||
}),
|
||||
...(cameraConfig.frigate.zone && {
|
||||
where: new Set([cameraConfig.frigate.zone]),
|
||||
}),
|
||||
...query,
|
||||
});
|
||||
}
|
||||
}
|
||||
return output.length ? output : null;
|
||||
}
|
||||
|
||||
public generateDefaultRecordingQuery(
|
||||
_cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query?: PartialRecordingQuery,
|
||||
): RecordingQuery[] {
|
||||
return [
|
||||
{
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
...query,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public generateDefaultRecordingSegmentsQuery(
|
||||
_cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
query: PartialRecordingSegmentsQuery,
|
||||
): RecordingSegmentsQuery[] | null {
|
||||
if (!query.start || !query.end) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: QueryType.RecordingSegments,
|
||||
cameraIDs: cameraIDs,
|
||||
start: query.start,
|
||||
end: query.end,
|
||||
...query,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public async favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
media: ViewMedia,
|
||||
favorite: boolean,
|
||||
): Promise<void> {
|
||||
if (!FrigateViewMediaClassifier.isFrigateEvent(media)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await retainEvent(hass, cameraConfig.frigate.client_id, media.getID(), favorite);
|
||||
media.setFavorite(favorite);
|
||||
}
|
||||
|
||||
protected _buildInstanceToCameraIDMapFromQuery(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
): Map<string, Set<string>> {
|
||||
const output: Map<string, Set<string>> = new Map();
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
|
||||
const clientID = cameraConfig?.frigate.client_id;
|
||||
if (clientID) {
|
||||
if (!output.has(clientID)) {
|
||||
output.set(clientID, new Set());
|
||||
}
|
||||
output.get(clientID)?.add(cameraID);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
protected _getFrigateCameraNamesForCameraIDs(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
): Set<string> {
|
||||
const output = new Set<string>();
|
||||
for (const cameraID of cameraIDs) {
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
|
||||
if (cameraConfig?.frigate.camera_name) {
|
||||
output.add(cameraConfig.frigate.camera_name);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public async getEvents(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: EventQuery,
|
||||
): Promise<EventQueryResultsMap | null> {
|
||||
const output: EventQueryResultsMap = new Map();
|
||||
|
||||
const processInstanceQuery = async (
|
||||
instanceID: string,
|
||||
cameraIDs?: Set<string>,
|
||||
): Promise<void> => {
|
||||
if (!cameraIDs || !cameraIDs.size) {
|
||||
return;
|
||||
}
|
||||
const instanceQuery = { ...query, cameraIDs: cameraIDs };
|
||||
const cachedResult = this._requestCache.get(instanceQuery);
|
||||
if (cachedResult) {
|
||||
output.set(query, cachedResult as EventQueryResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const nativeQuery: NativeFrigateEventQuery = {
|
||||
instance_id: instanceID,
|
||||
cameras: Array.from(this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs)),
|
||||
...(query.what && { labels: Array.from(query.what) }),
|
||||
...(query.where && { zones: Array.from(query.where) }),
|
||||
...(query.end && { before: Math.floor(query.end.getTime() / 1000) }),
|
||||
...(query.start && { after: Math.floor(query.start.getTime() / 1000) }),
|
||||
...(query.limit && { limit: query.limit }),
|
||||
...(query.hasClip && { has_clip: query.hasClip }),
|
||||
...(query.hasSnapshot && { has_snapshot: query.hasSnapshot }),
|
||||
...(query.favorite && { favorites: query.favorite }),
|
||||
limit: query?.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT,
|
||||
};
|
||||
|
||||
const result: FrigateEventQueryResults = {
|
||||
type: QueryResultsType.Event,
|
||||
engine: Engine.Frigate,
|
||||
instanceID: instanceID,
|
||||
events: await getEvents(hass, nativeQuery),
|
||||
expiry: add(new Date(), { seconds: EVENT_REQUEST_CACHE_MAX_AGE_SECONDS }),
|
||||
cached: false,
|
||||
};
|
||||
|
||||
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||
output.set(instanceQuery, result);
|
||||
};
|
||||
|
||||
// Frigate allows multiple cameras to be searched for events in a single
|
||||
// query. Break them down into groups of cameras per Frigate instance, then
|
||||
// query once per instance for all cameras in that instance.
|
||||
const instances = this._buildInstanceToCameraIDMapFromQuery(
|
||||
cameras,
|
||||
query.cameraIDs,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(instances.keys()).map((instanceID) =>
|
||||
processInstanceQuery(instanceID, instances.get(instanceID)),
|
||||
),
|
||||
);
|
||||
return output.size ? output : null;
|
||||
}
|
||||
|
||||
public async getRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingQuery,
|
||||
): Promise<RecordingQueryResultsMap | null> {
|
||||
const output: RecordingQueryResultsMap = new Map();
|
||||
|
||||
const processQuery = async (
|
||||
baseQuery: RecordingQuery,
|
||||
cameraID: string,
|
||||
): Promise<void> => {
|
||||
const query = { ...baseQuery, cameraIDs: new Set([cameraID]) };
|
||||
const cachedResult = this._requestCache.get(query);
|
||||
if (cachedResult) {
|
||||
output.set(query, cachedResult as RecordingQueryResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
|
||||
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordingSummary = await getRecordingsSummary(
|
||||
hass,
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
);
|
||||
|
||||
let recordings: FrigateRecording[] = [];
|
||||
|
||||
for (const dayData of recordingSummary ?? []) {
|
||||
for (const hourData of dayData.hours) {
|
||||
const hour = add(dayData.day, { hours: hourData.hour });
|
||||
const startHour = startOfHour(hour);
|
||||
const endHour = endOfHour(hour);
|
||||
if (
|
||||
(!query.start || startHour >= query.start) &&
|
||||
(!query.end || endHour <= query.end)
|
||||
) {
|
||||
recordings.push({
|
||||
cameraID: cameraID,
|
||||
startTime: startHour,
|
||||
endTime: endHour,
|
||||
events: hourData.events,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (query.limit !== undefined) {
|
||||
// Frigate does not natively support a way to limit recording searches so
|
||||
// this simulates it.
|
||||
recordings = orderBy(
|
||||
recordings,
|
||||
(recording: FrigateRecording) => recording.startTime,
|
||||
'desc',
|
||||
).slice(0, query.limit);
|
||||
}
|
||||
|
||||
const result: FrigateRecordingQueryResults = {
|
||||
type: QueryResultsType.Recording,
|
||||
engine: Engine.Frigate,
|
||||
instanceID: cameraConfig.frigate.client_id,
|
||||
recordings: recordings,
|
||||
expiry: add(new Date(), {
|
||||
seconds: RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS,
|
||||
}),
|
||||
cached: false,
|
||||
};
|
||||
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||
output.set(query, result);
|
||||
};
|
||||
|
||||
// Frigate recordings can only be queried for a single camera, so fan out
|
||||
// the inbound query into multiple outbound queries.
|
||||
await Promise.all(
|
||||
Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)),
|
||||
);
|
||||
return output.size ? output : null;
|
||||
}
|
||||
|
||||
public async getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: RecordingSegmentsQuery,
|
||||
): Promise<RecordingSegmentsQueryResultsMap | null> {
|
||||
const output: RecordingSegmentsQueryResultsMap = new Map();
|
||||
|
||||
const processQuery = async (
|
||||
baseQuery: RecordingSegmentsQuery,
|
||||
cameraID: string,
|
||||
): Promise<void> => {
|
||||
const query = { ...baseQuery, cameraIDs: new Set([cameraID]) };
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
|
||||
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range: DateRange = { start: query.start, end: query.end };
|
||||
|
||||
// A note on Frigate Recording Segments:
|
||||
// - There is an internal cache at the engine level for segments to allow
|
||||
// caching "within an existing query" (e.g. if we already cached hour
|
||||
// 1-8, we will avoid a fetch if we request hours 2-3 even though the
|
||||
// query is different -- the segments won't be). This is since the
|
||||
// volume of data in segment transfers can be high, and the segments can
|
||||
// be used in high frequency situations (e.g. video seeking).
|
||||
const cachedSegments = this._recordingSegmentsCache.get(cameraID, range);
|
||||
if (cachedSegments) {
|
||||
output.set(query, <FrigateRecordingSegmentsQueryResults>{
|
||||
type: QueryResultsType.RecordingSegments,
|
||||
engine: Engine.Frigate,
|
||||
instanceID: cameraConfig.frigate.client_id,
|
||||
segments: cachedSegments,
|
||||
cached: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const request: NativeFrigateRecordingSegmentsQuery = {
|
||||
instance_id: cameraConfig.frigate.client_id,
|
||||
camera: cameraConfig.frigate.camera_name,
|
||||
after: Math.floor(query.start.getTime() / 1000),
|
||||
before: Math.floor(query.end.getTime() / 1000),
|
||||
};
|
||||
|
||||
const segments = await getRecordingSegments(hass, request);
|
||||
this._recordingSegmentsCache.add(cameraID, range, segments);
|
||||
|
||||
output.set(query, <FrigateRecordingSegmentsQueryResults>{
|
||||
type: QueryResultsType.RecordingSegments,
|
||||
engine: Engine.Frigate,
|
||||
instanceID: cameraConfig.frigate.client_id,
|
||||
segments: segments,
|
||||
cached: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Frigate recording segments can only be queried for a single camera, so
|
||||
// fan out the inbound query into multiple outbound queries.
|
||||
await Promise.all(
|
||||
Array.from(query.cameraIDs).map((cameraID) => processQuery(query, cameraID)),
|
||||
);
|
||||
|
||||
runWhenIdleIfSupported(() => this._throttledSegmentGarbageCollector(hass, cameras));
|
||||
return output.size ? output : null;
|
||||
}
|
||||
|
||||
protected _getCameraIDMatch(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: DataQuery,
|
||||
instanceID: string,
|
||||
cameraName: string,
|
||||
): string | null {
|
||||
// If the query is only for a single cameraID, all results are assumed to
|
||||
// belong to it for performance reasons. Otherwise, we need to map the
|
||||
// instanceID and camera name for the known cameras, and get the precise
|
||||
// cameraID that matches the expected instance ID / camera name.
|
||||
if (query.cameraIDs.size === 1) {
|
||||
return [...query.cameraIDs][0];
|
||||
}
|
||||
for (const [cameraID, cameraConfig] of cameras.entries()) {
|
||||
if (
|
||||
cameraConfig.frigate.client_id === instanceID &&
|
||||
cameraConfig.frigate.camera_name === cameraName
|
||||
) {
|
||||
return cameraID;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public generateMediaFromEvents(
|
||||
_hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
query: EventQuery,
|
||||
results: QueryReturnType<EventQuery>,
|
||||
): ViewMedia[] | null {
|
||||
if (!FrigateQueryResultsClassifier.isFrigateEventQueryResults(results)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: ViewMedia[] = [];
|
||||
for (const event of results.events) {
|
||||
const cameraID = this._getCameraIDMatch(
|
||||
cameras,
|
||||
query,
|
||||
results.instanceID,
|
||||
event.camera,
|
||||
);
|
||||
if (!cameraID) {
|
||||
continue;
|
||||
}
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, cameraID);
|
||||
if (!cameraConfig) {
|
||||
continue;
|
||||
}
|
||||
let mediaType: 'clip' | 'snapshot' | null = null;
|
||||
if (
|
||||
!query.hasClip &&
|
||||
!query.hasSnapshot &&
|
||||
(event.has_clip || event.has_snapshot)
|
||||
) {
|
||||
mediaType = event.has_clip ? 'clip' : 'snapshot';
|
||||
} else if (query.hasSnapshot && event.has_snapshot) {
|
||||
mediaType = 'snapshot';
|
||||
} else if (query.hasClip && event.has_clip) {
|
||||
mediaType = 'clip';
|
||||
}
|
||||
if (!mediaType) {
|
||||
continue;
|
||||
}
|
||||
const media = FrigateViewMediaFactory.createEventViewMedia(
|
||||
mediaType,
|
||||
cameraID,
|
||||
cameraConfig,
|
||||
event,
|
||||
);
|
||||
if (media) {
|
||||
output.push(media);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public generateMediaFromRecordings(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
_query: RecordingQuery,
|
||||
results: QueryReturnType<RecordingQuery>,
|
||||
): ViewMedia[] | null {
|
||||
if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(results)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: ViewMedia[] = [];
|
||||
for (const recording of results.recordings) {
|
||||
const cameraConfig = this._getQueryableCameraConfig(cameras, recording.cameraID);
|
||||
if (!cameraConfig) {
|
||||
continue;
|
||||
}
|
||||
const media = FrigateViewMediaFactory.createRecordingViewMedia(
|
||||
recording.cameraID,
|
||||
recording,
|
||||
cameraConfig,
|
||||
this.getCameraMetadata(hass, cameraConfig).title,
|
||||
);
|
||||
if (media) {
|
||||
output.push(media);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
public getQueryResultMaxAge(query: DataQuery): number | null {
|
||||
if (query.type === QueryType.Event) {
|
||||
return EVENT_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||
} else if (query.type === QueryType.Recording) {
|
||||
return RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public async getMediaSeekTime(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
media: ViewMedia,
|
||||
target: Date,
|
||||
): Promise<number | null> {
|
||||
const start = media.getStartTime();
|
||||
const end = media.getEndTime();
|
||||
if (!start || !end || target < start || target > end) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cameraID = media.getCameraID();
|
||||
const query: RecordingSegmentsQuery = {
|
||||
cameraIDs: new Set([cameraID]),
|
||||
start: start,
|
||||
end: end,
|
||||
type: QueryType.RecordingSegments,
|
||||
};
|
||||
|
||||
const results = await this.getRecordingSegments(hass, cameras, query);
|
||||
|
||||
if (results) {
|
||||
return this._getSeekTimeInSegments(
|
||||
start,
|
||||
target,
|
||||
// There will only be a single result since Frigate recording segments
|
||||
// searches are per camera which is specified singularly above.
|
||||
Array.from(results.values())[0].segments,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
protected _getQueryableCameraConfig(
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraID: string,
|
||||
): CameraConfig | null {
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
if (!cameraConfig || cameraConfig.frigate.camera_name == CAMERA_BIRDSEYE) {
|
||||
return null;
|
||||
}
|
||||
return cameraConfig;
|
||||
}
|
||||
|
||||
public async getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): Promise<MediaMetadata | null> {
|
||||
const what: Set<string> = new Set();
|
||||
const where: Set<string> = new Set();
|
||||
const days: Set<string> = new Set();
|
||||
|
||||
const instances = this._buildInstanceToCameraIDMapFromQuery(
|
||||
cameras,
|
||||
new Set(cameras.keys()),
|
||||
);
|
||||
|
||||
const processEventSummary = async (
|
||||
instanceID: string,
|
||||
cameraIDs: Set<string>,
|
||||
): Promise<void> => {
|
||||
const cameraNames = this._getFrigateCameraNamesForCameraIDs(cameras, cameraIDs);
|
||||
for (const entry of await getEventSummary(hass, instanceID)) {
|
||||
if (!cameraNames.has(entry.camera)) {
|
||||
// If this entry applies to a camera that *is* in this Frigate
|
||||
// instance, but is *not* a configured camera in the card, skip it.
|
||||
continue;
|
||||
}
|
||||
if (entry.label) {
|
||||
what.add(entry.label);
|
||||
}
|
||||
if (entry.zones.length) {
|
||||
entry.zones.forEach(where.add, where);
|
||||
}
|
||||
if (entry.day) {
|
||||
days.add(entry.day);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const processRecordings = async (cameraIDs: Set<string>): Promise<void> => {
|
||||
const recordings = await this.getRecordings(hass, cameras, {
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
});
|
||||
if (!recordings) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const result of recordings.values()) {
|
||||
if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const recording of result.recordings) {
|
||||
// Frigate recordings are always 1 hour long, i.e. never span a day.
|
||||
days.add(formatDate(recording.startTime));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await allPromises([...instances.entries()], ([instanceID, cameraIDs]) =>
|
||||
(async () => {
|
||||
await Promise.all([
|
||||
processEventSummary(instanceID, cameraIDs),
|
||||
processRecordings(cameraIDs),
|
||||
]);
|
||||
})(),
|
||||
);
|
||||
|
||||
if (!what.size && !where.size && !days.size) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(what.size && { what: what }),
|
||||
...(where.size && { where: where }),
|
||||
...(days.size && { days: days }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage collect recording segments that no longer feature in the recordings
|
||||
* returned by the Frigate backend.
|
||||
*/
|
||||
protected async _garbageCollectSegments(
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
): Promise<void> {
|
||||
const cameraIDs = this._recordingSegmentsCache.getCameraIDs();
|
||||
const recordingQuery: RecordingQuery = {
|
||||
cameraIDs: new Set(cameraIDs),
|
||||
type: QueryType.Recording,
|
||||
};
|
||||
|
||||
const countSegments = () =>
|
||||
sum(
|
||||
cameraIDs.map(
|
||||
(cameraID) => this._recordingSegmentsCache.getCache(cameraID)?.size() ?? 0,
|
||||
),
|
||||
);
|
||||
const segmentsStart = countSegments();
|
||||
|
||||
// Performance: _recordingSegments is potentially very large (e.g. 10K - 1M
|
||||
// items) and each item must be examined, so care required here to stick to
|
||||
// nothing worse than O(n) performance.
|
||||
const getHourID = (cameraID: string, startTime: Date): string => {
|
||||
return `${cameraID}/${startTime.getDate()}/${startTime.getHours()}`;
|
||||
};
|
||||
|
||||
const results = await this.getRecordings(hass, cameras, recordingQuery);
|
||||
if (!results) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [query, result] of results) {
|
||||
if (!FrigateQueryResultsClassifier.isFrigateRecordingQueryResults(result)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const goodHours: Set<string> = new Set();
|
||||
for (const recording of result.recordings) {
|
||||
goodHours.add(getHourID(recording.cameraID, recording.startTime));
|
||||
}
|
||||
|
||||
// Frigate recordings are always executed individually, so there'll only
|
||||
// be a single results.
|
||||
const cameraID = Array.from(query.cameraIDs)[0];
|
||||
this._recordingSegmentsCache.expireMatches(
|
||||
cameraID,
|
||||
(segment: RecordingSegment) => {
|
||||
const hourID = getHourID(cameraID, fromUnixTime(segment.start_time));
|
||||
// ~O(1) lookup time for a JS set.
|
||||
return goodHours.has(hourID);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
log(
|
||||
this._cardWideConfig,
|
||||
'Frigate Card recording segment garbage collection: ' +
|
||||
`Released ${segmentsStart - countSegments()} segment(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of seconds to seek into a video stream consisting of the
|
||||
* provided segments to reach the target time provided.
|
||||
* @param startTime The earliest allowable time to seek from.
|
||||
* @param targetTime Target time.
|
||||
* @param segments An array of segments dataset items. Must be sorted from oldest to youngest.
|
||||
* @returns
|
||||
*/
|
||||
protected _getSeekTimeInSegments(
|
||||
startTime: Date,
|
||||
targetTime: Date,
|
||||
segments: RecordingSegment[],
|
||||
): number | null {
|
||||
if (!segments.length) {
|
||||
return null;
|
||||
}
|
||||
let seekMilliseconds = 0;
|
||||
|
||||
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
||||
for (const segment of segments) {
|
||||
const segmentStart = fromUnixTime(segment.start_time);
|
||||
if (segmentStart > targetTime) {
|
||||
break;
|
||||
}
|
||||
const segmentEnd = fromUnixTime(segment.end_time);
|
||||
const start = segmentStart < startTime ? startTime : segmentStart;
|
||||
const end = segmentEnd > targetTime ? targetTime : segmentEnd;
|
||||
seekMilliseconds += end.getTime() - start.getTime();
|
||||
}
|
||||
return seekMilliseconds / 1000;
|
||||
}
|
||||
|
||||
public getCapabilities(): CameraManagerEngineCapabilities {
|
||||
return {
|
||||
canFavoriteEvents: true,
|
||||
canFavoriteRecordings: false,
|
||||
};
|
||||
}
|
||||
|
||||
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities {
|
||||
return {
|
||||
canFavorite: ViewMediaClassifier.isEvent(media),
|
||||
};
|
||||
}
|
||||
|
||||
public getCameraMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): CameraManagerCameraMetadata {
|
||||
return {
|
||||
title:
|
||||
cameraConfig.title ??
|
||||
getEntityTitle(hass, cameraConfig.camera_entity) ??
|
||||
getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ??
|
||||
prettifyTitle(cameraConfig.frigate?.camera_name) ??
|
||||
cameraConfig.id ??
|
||||
'',
|
||||
icon:
|
||||
cameraConfig?.icon ??
|
||||
getEntityIcon(hass, cameraConfig.camera_entity) ??
|
||||
'mdi:video',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const FRIGATE_ICON_SVG_PATH =
|
||||
'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' +
|
||||
'-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' +
|
||||
'-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' +
|
||||
'5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' +
|
||||
'1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' +
|
||||
'-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' +
|
||||
'-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' +
|
||||
'-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' +
|
||||
'-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' +
|
||||
'-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' +
|
||||
'0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' +
|
||||
'0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' +
|
||||
'0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' +
|
||||
'0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' +
|
||||
'-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' +
|
||||
'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' +
|
||||
'-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' +
|
||||
'-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' +
|
||||
'-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z';
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { FrigateEventViewMedia, FrigateRecordingViewMedia } from './media';
|
||||
|
||||
export class FrigateViewMediaClassifier {
|
||||
public static isFrigateMedia(
|
||||
media: ViewMedia,
|
||||
): media is FrigateEventViewMedia | FrigateRecordingViewMedia {
|
||||
return this.isFrigateEvent(media) || this.isFrigateRecording(media);
|
||||
}
|
||||
public static isFrigateEvent(media: ViewMedia): media is FrigateEventViewMedia {
|
||||
return media instanceof FrigateEventViewMedia;
|
||||
}
|
||||
public static isFrigateRecording(
|
||||
media: ViewMedia,
|
||||
): media is FrigateRecordingViewMedia {
|
||||
return media instanceof FrigateRecordingViewMedia;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { CameraConfig } from '../../types';
|
||||
import {
|
||||
ViewMedia,
|
||||
EventViewMedia,
|
||||
RecordingViewMedia,
|
||||
ViewMediaType,
|
||||
} from '../../view/media';
|
||||
import { FrigateEvent, FrigateRecording } from './types';
|
||||
import {
|
||||
getEventMediaContentID,
|
||||
getEventThumbnailURL,
|
||||
getEventTitle,
|
||||
getRecordingID,
|
||||
getRecordingMediaContentID,
|
||||
getRecordingTitle,
|
||||
} from './util';
|
||||
|
||||
export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||
protected _event: FrigateEvent;
|
||||
protected _contentID: string;
|
||||
protected _thumbnail: string;
|
||||
|
||||
constructor(
|
||||
mediaType: ViewMediaType,
|
||||
cameraID: string,
|
||||
event: FrigateEvent,
|
||||
contentID: string,
|
||||
thumbnail: string,
|
||||
) {
|
||||
super(mediaType, cameraID);
|
||||
this._event = event;
|
||||
this._contentID = contentID;
|
||||
this._thumbnail = thumbnail;
|
||||
}
|
||||
|
||||
public hasClip(): boolean {
|
||||
return !!this._event.has_clip;
|
||||
}
|
||||
public getStartTime(): Date {
|
||||
return fromUnixTime(this._event.start_time);
|
||||
}
|
||||
public getEndTime(): Date | null {
|
||||
return this._event.end_time ? fromUnixTime(this._event.end_time) : null;
|
||||
}
|
||||
public getID(): string {
|
||||
return this._event.id;
|
||||
}
|
||||
public getContentID(): string {
|
||||
return this._contentID;
|
||||
}
|
||||
public getTitle(): string | null {
|
||||
return getEventTitle(this._event);
|
||||
}
|
||||
public getThumbnail(): string | null {
|
||||
return this._thumbnail;
|
||||
}
|
||||
public isFavorite(): boolean | null {
|
||||
return this._event.retain_indefinitely ?? null;
|
||||
}
|
||||
public setFavorite(favorite: boolean): void {
|
||||
this._event.retain_indefinitely = favorite;
|
||||
}
|
||||
public getWhat(): string[] | null {
|
||||
return [this._event.label];
|
||||
}
|
||||
public getWhere(): string[] | null {
|
||||
const zones = this._event.zones;
|
||||
return zones.length ? zones : null;
|
||||
}
|
||||
public getScore(): number | null {
|
||||
return this._event.top_score;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public isGroupableWith(that: EventViewMedia): boolean {
|
||||
return (
|
||||
this.getMediaType() === that.getMediaType() &&
|
||||
isEqual(this.getWhere(), that.getWhere()) &&
|
||||
isEqual(this.getWhat(), that.getWhat())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class FrigateRecordingViewMedia extends ViewMedia implements RecordingViewMedia {
|
||||
protected _recording: FrigateRecording;
|
||||
protected _id: string;
|
||||
protected _contentID: string;
|
||||
protected _title: string;
|
||||
|
||||
constructor(
|
||||
mediaType: ViewMediaType,
|
||||
cameraID: string,
|
||||
recording: FrigateRecording,
|
||||
id: string,
|
||||
contentID: string,
|
||||
title: string,
|
||||
) {
|
||||
super(mediaType, cameraID);
|
||||
this._recording = recording;
|
||||
this._id = id;
|
||||
this._contentID = contentID;
|
||||
this._title = title;
|
||||
}
|
||||
|
||||
public getID(): string {
|
||||
return this._id;
|
||||
}
|
||||
public getStartTime(): Date {
|
||||
return this._recording.startTime;
|
||||
}
|
||||
public getEndTime(): Date {
|
||||
return this._recording.endTime;
|
||||
}
|
||||
public getContentID(): string | null {
|
||||
return this._contentID;
|
||||
}
|
||||
public getTitle(): string | null {
|
||||
return this._title;
|
||||
}
|
||||
public getEventCount(): number {
|
||||
return this._recording.events;
|
||||
}
|
||||
}
|
||||
|
||||
export class FrigateViewMediaFactory {
|
||||
static createEventViewMedia(
|
||||
mediaType: 'clip' | 'snapshot',
|
||||
cameraID: string,
|
||||
cameraConfig: CameraConfig,
|
||||
event: FrigateEvent,
|
||||
): FrigateEventViewMedia | null {
|
||||
if (
|
||||
(mediaType === 'clip' && !event.has_clip) ||
|
||||
(mediaType === 'snapshot' && !event.has_snapshot) ||
|
||||
!cameraConfig.frigate.client_id ||
|
||||
!cameraConfig.frigate.camera_name
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FrigateEventViewMedia(
|
||||
mediaType,
|
||||
cameraID,
|
||||
event,
|
||||
getEventMediaContentID(
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
event,
|
||||
mediaType === 'clip' ? 'clips' : 'snapshots',
|
||||
),
|
||||
getEventThumbnailURL(cameraConfig.frigate.client_id, event),
|
||||
);
|
||||
}
|
||||
|
||||
static createRecordingViewMedia(
|
||||
cameraID: string,
|
||||
recording: FrigateRecording,
|
||||
cameraConfig: CameraConfig,
|
||||
cameraTitle: string,
|
||||
): FrigateRecordingViewMedia | null {
|
||||
if (!cameraConfig.frigate.client_id || !cameraConfig.frigate.camera_name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new FrigateRecordingViewMedia(
|
||||
'recording',
|
||||
cameraID,
|
||||
recording,
|
||||
getRecordingID(cameraConfig, recording),
|
||||
getRecordingMediaContentID(
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
recording,
|
||||
),
|
||||
getRecordingTitle(cameraTitle, recording),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { FrigateCardError } from '../../types';
|
||||
import { homeAssistantWSRequest } from '../../utils/ha';
|
||||
import { RecordingSegment } from '../types';
|
||||
import {
|
||||
EventSummary,
|
||||
eventSummarySchema,
|
||||
FrigateEvent,
|
||||
frigateEventsSchema,
|
||||
recordingSegmentsSchema,
|
||||
RecordingSummary,
|
||||
recordingSummarySchema,
|
||||
RetainResult,
|
||||
retainResultSchema,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Get the recordings summary. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param clientID The Frigate clientID.
|
||||
* @param camera_name The Frigate camera name.
|
||||
* @returns A RecordingSummary object.
|
||||
*/
|
||||
export const getRecordingsSummary = async (
|
||||
hass: HomeAssistant,
|
||||
clientID: string,
|
||||
camera_name: string,
|
||||
): Promise<RecordingSummary> => {
|
||||
return (await homeAssistantWSRequest(
|
||||
hass,
|
||||
recordingSummarySchema,
|
||||
{
|
||||
type: 'frigate/recordings/summary',
|
||||
instance_id: clientID,
|
||||
camera: camera_name,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
true,
|
||||
// See: https://github.com/colinhacks/zod/pull/1752
|
||||
)) as RecordingSummary;
|
||||
};
|
||||
|
||||
export interface NativeFrigateRecordingSegmentsQuery {
|
||||
instance_id: string;
|
||||
camera: string;
|
||||
after: number;
|
||||
before: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the recording segments. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param params The recording segment query parameters.
|
||||
* @returns A RecordingSegments object.
|
||||
*/
|
||||
export const getRecordingSegments = async (
|
||||
hass: HomeAssistant,
|
||||
params: NativeFrigateRecordingSegmentsQuery,
|
||||
): Promise<RecordingSegment[]> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
recordingSegmentsSchema,
|
||||
{
|
||||
type: 'frigate/recordings/get',
|
||||
...params,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Request that Frigate retain an event. May throw.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param clientID The Frigate clientID.
|
||||
* @param eventID The event ID to retain.
|
||||
* @param retain `true` to retain or `false` to unretain.
|
||||
*/
|
||||
export async function retainEvent(
|
||||
hass: HomeAssistant,
|
||||
clientID: string,
|
||||
eventID: string,
|
||||
retain: boolean,
|
||||
): Promise<void> {
|
||||
const retainRequest = {
|
||||
type: 'frigate/event/retain',
|
||||
instance_id: clientID,
|
||||
event_id: eventID,
|
||||
retain: retain,
|
||||
};
|
||||
const response = await homeAssistantWSRequest<RetainResult>(
|
||||
hass,
|
||||
retainResultSchema,
|
||||
retainRequest,
|
||||
true,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new FrigateCardError(localize('error.failed_retain'), {
|
||||
request: retainRequest,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface NativeFrigateEventQuery {
|
||||
instance_id?: string;
|
||||
cameras?: string[];
|
||||
labels?: string[];
|
||||
zones?: string[];
|
||||
after?: number;
|
||||
before?: number;
|
||||
limit?: number;
|
||||
has_clip?: boolean;
|
||||
has_snapshot?: boolean;
|
||||
favorites?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events over websocket. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param params The events search parameters.
|
||||
* @returns An array of 'FrigateEvent's.
|
||||
*/
|
||||
export const getEvents = async (
|
||||
hass: HomeAssistant,
|
||||
params?: NativeFrigateEventQuery,
|
||||
): Promise<FrigateEvent[]> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
frigateEventsSchema,
|
||||
{
|
||||
type: 'frigate/events/get',
|
||||
...params,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
export const getEventSummary = async (
|
||||
hass: HomeAssistant,
|
||||
clientID: string,
|
||||
): Promise<EventSummary> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
eventSummarySchema,
|
||||
{
|
||||
type: 'frigate/events/summary',
|
||||
instance_id: clientID,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod';
|
||||
import { dayToDate } from '../../utils/basic';
|
||||
|
||||
const dayStringToDate = (arg: unknown): Date | unknown => {
|
||||
return typeof arg === 'string' ? dayToDate(arg) : arg;
|
||||
};
|
||||
|
||||
const eventSchema = z.object({
|
||||
camera: z.string(),
|
||||
end_time: z.number().nullable(),
|
||||
false_positive: z.boolean().nullable(),
|
||||
has_clip: z.boolean(),
|
||||
has_snapshot: z.boolean(),
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
start_time: z.number(),
|
||||
top_score: z.number(),
|
||||
zones: z.string().array(),
|
||||
retain_indefinitely: z.boolean().optional(),
|
||||
});
|
||||
export const frigateEventsSchema = eventSchema.array();
|
||||
|
||||
export type FrigateEvent = z.infer<typeof eventSchema>;
|
||||
|
||||
const recordingSummaryHourSchema = z.object({
|
||||
hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)),
|
||||
duration: z.number().min(0),
|
||||
events: z.number().min(0),
|
||||
});
|
||||
|
||||
export const recordingSummarySchema = z
|
||||
.object({
|
||||
day: z.preprocess(dayStringToDate, z.date()),
|
||||
events: z.number(),
|
||||
hours: recordingSummaryHourSchema.array(),
|
||||
})
|
||||
.array();
|
||||
export type RecordingSummary = z.infer<typeof recordingSummarySchema>;
|
||||
|
||||
const recordingSegmentSchema = z.object({
|
||||
start_time: z.number(),
|
||||
end_time: z.number(),
|
||||
id: z.string(),
|
||||
});
|
||||
export const recordingSegmentsSchema = recordingSegmentSchema.array();
|
||||
|
||||
export const retainResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
});
|
||||
export type RetainResult = z.infer<typeof retainResultSchema>;
|
||||
|
||||
export interface FrigateRecording {
|
||||
cameraID: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
events: number;
|
||||
}
|
||||
|
||||
export const eventSummarySchema = z
|
||||
.object({
|
||||
camera: z.string(),
|
||||
day: z.string(),
|
||||
label: z.string(),
|
||||
zones: z.string().array(),
|
||||
})
|
||||
.array();
|
||||
export type EventSummary = z.infer<typeof eventSummarySchema>;
|
||||
@@ -0,0 +1,97 @@
|
||||
import utcToZonedTime from 'date-fns-tz/utcToZonedTime';
|
||||
import { CameraConfig, ClipsOrSnapshots } from '../../types';
|
||||
import { formatDateAndTime, prettifyTitle } from '../../utils/basic';
|
||||
import { FrigateEvent, FrigateRecording } from './types';
|
||||
|
||||
/**
|
||||
* Given an event generate a title.
|
||||
* @param event
|
||||
*/
|
||||
export const getEventTitle = (event: FrigateEvent): string => {
|
||||
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const durationSeconds = Math.round(
|
||||
event.end_time
|
||||
? event.end_time - event.start_time
|
||||
: Date.now() / 1000 - event.start_time,
|
||||
);
|
||||
return `${formatDateAndTime(
|
||||
utcToZonedTime(event.start_time * 1000, localTimezone),
|
||||
)} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round(
|
||||
event.top_score * 100,
|
||||
)}%]`;
|
||||
};
|
||||
|
||||
export const getRecordingTitle = (
|
||||
cameraTitle: string,
|
||||
recording: FrigateRecording,
|
||||
): string => {
|
||||
return `${cameraTitle} ${formatDateAndTime(recording.startTime)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a thumbnail URL for an event.
|
||||
* @param clientId The Frigate client id.
|
||||
* @param event The event.
|
||||
* @returns A string URL.
|
||||
*/
|
||||
export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => {
|
||||
return `/api/frigate/${clientId}/thumbnail/${event.id}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a media content ID for an event.
|
||||
* @param clientId The Frigate client id.
|
||||
* @param cameraName The Frigate camera name.
|
||||
* @param event The Frigate event.
|
||||
* @param mediaType The media type required.
|
||||
* @returns A string media content id.
|
||||
*/
|
||||
export const getEventMediaContentID = (
|
||||
clientId: string,
|
||||
cameraName: string,
|
||||
event: FrigateEvent,
|
||||
mediaType: ClipsOrSnapshots,
|
||||
): string => {
|
||||
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${event.id}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a recording identifier.
|
||||
* @param clientId The Frigate client id.
|
||||
* @param cameraName The Frigate camera name.
|
||||
* @param recording The Frigate recording.
|
||||
* @returns A recording identifier.
|
||||
*/
|
||||
export const getRecordingMediaContentID = (
|
||||
clientId: string,
|
||||
cameraName: string,
|
||||
recording: FrigateRecording,
|
||||
): string => {
|
||||
return [
|
||||
'media-source://frigate',
|
||||
clientId,
|
||||
'recordings',
|
||||
cameraName,
|
||||
`${recording.startTime.getFullYear()}-${String(
|
||||
recording.startTime.getMonth() + 1,
|
||||
).padStart(2, '0')}-${String(
|
||||
String(recording.startTime.getDate()).padStart(2, '0'),
|
||||
)}`,
|
||||
String(recording.startTime.getHours()).padStart(2, '0'),
|
||||
].join('/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a recording ID for internal de-duping.
|
||||
*/
|
||||
export const getRecordingID = (
|
||||
cameraConfig: CameraConfig,
|
||||
recording: FrigateRecording,
|
||||
): string => {
|
||||
// ID name is derived from the real camera name (not CameraID) since the
|
||||
// recordings for the same camera across multiple zones will be the same and
|
||||
// can be dedup'd from this id.
|
||||
return `${cameraConfig.frigate?.client_id ?? ''}/${
|
||||
cameraConfig.frigate.camera_name ?? ''
|
||||
}/${recording.startTime.getTime()}/${recording.endTime.getTime()}}`;
|
||||
};
|
||||
@@ -0,0 +1,544 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig, CardWideConfig } from '../types.js';
|
||||
import { allPromises, arrayify, setify } from '../utils/basic.js';
|
||||
import {
|
||||
CameraManagerCameraMetadata,
|
||||
CameraManagerCapabilities,
|
||||
CameraManagerMediaCapabilities,
|
||||
DataQuery,
|
||||
EventQuery,
|
||||
EventQueryResults,
|
||||
EventQueryResultsMap,
|
||||
MediaMetadata,
|
||||
MediaQuery,
|
||||
PartialDataQuery,
|
||||
PartialEventQuery,
|
||||
PartialQueryConcreteType,
|
||||
PartialRecordingQuery,
|
||||
PartialRecordingSegmentsQuery,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
QueryType,
|
||||
RecordingQuery,
|
||||
RecordingQueryResults,
|
||||
RecordingQueryResultsMap,
|
||||
RecordingSegmentsQuery,
|
||||
RecordingSegmentsQueryResults,
|
||||
RecordingSegmentsQueryResultsMap,
|
||||
ResultsMap,
|
||||
} from './types.js';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import { CameraManagerEngineFactory } from './engine-factory.js';
|
||||
import { ViewMedia } from '../view/media.js';
|
||||
import uniqBy from 'lodash-es/uniqBy';
|
||||
import { CameraManagerEngine } from './engine.js';
|
||||
import sum from 'lodash-es/sum';
|
||||
import add from 'date-fns/add';
|
||||
import { log } from '../utils/debug.js';
|
||||
|
||||
class QueryClassifier {
|
||||
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
||||
return query.type === QueryType.Event;
|
||||
}
|
||||
public static isRecordingQuery(
|
||||
query: DataQuery | PartialDataQuery,
|
||||
): query is RecordingQuery {
|
||||
return query.type === QueryType.Recording;
|
||||
}
|
||||
public static isRecordingSegmentsQuery(
|
||||
query: DataQuery | PartialDataQuery,
|
||||
): query is RecordingSegmentsQuery {
|
||||
return query.type === QueryType.RecordingSegments;
|
||||
}
|
||||
}
|
||||
|
||||
class QueryResultClassifier {
|
||||
public static isEventQueryResult(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is EventQueryResults {
|
||||
return queryResults.type === QueryResultsType.Event;
|
||||
}
|
||||
public static isRecordingQuery(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is RecordingQueryResults {
|
||||
return queryResults.type === QueryResultsType.Recording;
|
||||
}
|
||||
public static isRecordingSegmentsQuery(
|
||||
queryResults: QueryResults,
|
||||
): queryResults is RecordingSegmentsQueryResults {
|
||||
return queryResults.type === QueryResultsType.RecordingSegments;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExtendedMediaQueryResult<T extends MediaQuery> {
|
||||
queries: T[];
|
||||
results: ViewMedia[];
|
||||
}
|
||||
|
||||
export class CameraManager {
|
||||
protected _engineFactory: CameraManagerEngineFactory;
|
||||
protected _cameras: Map<string, CameraConfig>;
|
||||
protected _cardWideConfig?: CardWideConfig;
|
||||
|
||||
constructor(
|
||||
engineFactory: CameraManagerEngineFactory,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cardWideConfig?: CardWideConfig,
|
||||
) {
|
||||
this._engineFactory = engineFactory;
|
||||
this._cameras = cameras;
|
||||
this._cardWideConfig = cardWideConfig;
|
||||
}
|
||||
|
||||
public generateDefaultEventQueries(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery?: PartialEventQuery,
|
||||
): EventQuery[] | null {
|
||||
return this._generateDefaultQueries(cameraIDs, {
|
||||
type: QueryType.Event,
|
||||
...partialQuery,
|
||||
});
|
||||
}
|
||||
|
||||
public generateDefaultRecordingQueries(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery?: PartialRecordingQuery,
|
||||
): RecordingQuery[] | null {
|
||||
return this._generateDefaultQueries(cameraIDs, {
|
||||
type: QueryType.Recording,
|
||||
...partialQuery,
|
||||
});
|
||||
}
|
||||
|
||||
public generateDefaultRecordingSegmentsQueries(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery?: PartialRecordingSegmentsQuery,
|
||||
): RecordingSegmentsQuery[] | null {
|
||||
return this._generateDefaultQueries(cameraIDs, {
|
||||
type: QueryType.RecordingSegments,
|
||||
...partialQuery,
|
||||
});
|
||||
}
|
||||
|
||||
public async getMediaMetadata(hass: HomeAssistant): Promise<MediaMetadata | null> {
|
||||
const what: Set<string> = new Set();
|
||||
const where: Set<string> = new Set();
|
||||
const days: Set<string> = new Set();
|
||||
|
||||
const engines = this._engineFactory.getAllEngines(this._cameras);
|
||||
if (!engines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const processMetadata = async (engine: CameraManagerEngine): Promise<void> => {
|
||||
const engineMetadata = await engine.getMediaMetadata(hass, this._cameras);
|
||||
if (engineMetadata) {
|
||||
if (engineMetadata.what) {
|
||||
engineMetadata.what.forEach(what.add, what);
|
||||
}
|
||||
if (engineMetadata.where) {
|
||||
engineMetadata.where.forEach(where.add, where);
|
||||
}
|
||||
if (engineMetadata.days) {
|
||||
engineMetadata.days.forEach(days.add, days);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await allPromises(engines, (engine) => processMetadata(engine));
|
||||
|
||||
if (!what.size && !where.size && !days.size) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...(what.size && { what: what }),
|
||||
...(where.size && { where: where }),
|
||||
...(days.size && { days: days }),
|
||||
};
|
||||
}
|
||||
|
||||
protected _generateDefaultQueries<PQT extends PartialDataQuery>(
|
||||
cameraIDs: string | Set<string>,
|
||||
partialQuery: PQT,
|
||||
): PartialQueryConcreteType<PQT>[] | null {
|
||||
const concreteQueries: PartialQueryConcreteType<PQT>[] = [];
|
||||
const _cameraIDs = setify(cameraIDs);
|
||||
|
||||
const engines = this._engineFactory.getEnginesForCameraIDs(
|
||||
this._cameras,
|
||||
_cameraIDs,
|
||||
);
|
||||
|
||||
if (!engines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [engine, cameraIDs] of engines) {
|
||||
let queries: DataQuery[] | null = null;
|
||||
if (QueryClassifier.isEventQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultEventQuery(
|
||||
this._cameras,
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
} else if (QueryClassifier.isRecordingQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultRecordingQuery(
|
||||
this._cameras,
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
} else if (QueryClassifier.isRecordingSegmentsQuery(partialQuery)) {
|
||||
queries = engine.generateDefaultRecordingSegmentsQuery(
|
||||
this._cameras,
|
||||
cameraIDs,
|
||||
partialQuery,
|
||||
);
|
||||
}
|
||||
|
||||
for (const query of queries ?? []) {
|
||||
concreteQueries.push(query as PartialQueryConcreteType<PQT>);
|
||||
}
|
||||
}
|
||||
return concreteQueries;
|
||||
}
|
||||
|
||||
public async getEvents(
|
||||
hass: HomeAssistant,
|
||||
query: EventQuery | EventQuery[],
|
||||
): Promise<EventQueryResultsMap> {
|
||||
return await this._handleQuery(hass, query);
|
||||
}
|
||||
|
||||
public async getRecordings(
|
||||
hass: HomeAssistant,
|
||||
query: RecordingQuery | RecordingQuery[],
|
||||
): Promise<RecordingQueryResultsMap> {
|
||||
return await this._handleQuery(hass, query);
|
||||
}
|
||||
|
||||
public async getRecordingSegments(
|
||||
hass: HomeAssistant,
|
||||
query: RecordingSegmentsQuery | RecordingSegmentsQuery[],
|
||||
): Promise<RecordingSegmentsQueryResultsMap> {
|
||||
return await this._handleQuery(hass, query);
|
||||
}
|
||||
|
||||
public async executeMediaQueries<T extends MediaQuery>(
|
||||
hass: HomeAssistant,
|
||||
queries: T[],
|
||||
): Promise<ViewMedia[] | null> {
|
||||
return this._convertQueryResultsToMedia(
|
||||
hass,
|
||||
await this._handleQuery(hass, queries),
|
||||
);
|
||||
}
|
||||
|
||||
public async extendMediaQueries<T extends MediaQuery>(
|
||||
hass: HomeAssistant,
|
||||
queries: T[],
|
||||
results: ViewMedia[],
|
||||
direction: 'earlier' | 'later',
|
||||
chunkSize: number,
|
||||
): Promise<ExtendedMediaQueryResult<T> | null> {
|
||||
const getTimeFromResults = (want: 'earliest' | 'latest'): Date | null => {
|
||||
let output: Date | null = null;
|
||||
for (const result of results) {
|
||||
const startTime = result.getStartTime();
|
||||
if (
|
||||
startTime &&
|
||||
(!output ||
|
||||
(want === 'earliest' && startTime < output) ||
|
||||
(want === 'latest' && startTime > output))
|
||||
) {
|
||||
output = startTime;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
// The queries associated with the chunk to fetch.
|
||||
const newChunkQueries: T[] = [];
|
||||
|
||||
// The re-constituted combined query.
|
||||
const extendedQueries: T[] = [];
|
||||
|
||||
for (const query of queries) {
|
||||
const newChunkQuery = { ...query };
|
||||
|
||||
if (direction === 'later') {
|
||||
const latestResult = getTimeFromResults('latest');
|
||||
if (latestResult) {
|
||||
newChunkQuery.start = latestResult;
|
||||
}
|
||||
} else if (direction === 'earlier') {
|
||||
const earliestResult = getTimeFromResults('earliest');
|
||||
if (earliestResult) {
|
||||
newChunkQuery.end = earliestResult;
|
||||
}
|
||||
}
|
||||
newChunkQuery.limit = chunkSize;
|
||||
|
||||
extendedQueries.push({
|
||||
...query,
|
||||
limit: (query.limit ?? 0) + chunkSize,
|
||||
});
|
||||
newChunkQueries.push(newChunkQuery);
|
||||
}
|
||||
|
||||
const newChunkMedia = this._convertQueryResultsToMedia(
|
||||
hass,
|
||||
await this._handleQuery(hass, newChunkQueries),
|
||||
);
|
||||
|
||||
if (!newChunkMedia.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
queries: extendedQueries,
|
||||
results: this._sortMedia(results.concat(newChunkMedia)),
|
||||
};
|
||||
}
|
||||
|
||||
public getMediaDownloadPath(media: ViewMedia): string | null {
|
||||
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||
const engine = cameraConfig
|
||||
? this._engineFactory.getEngineForCamera(cameraConfig)
|
||||
: null;
|
||||
if (!cameraConfig || !engine) {
|
||||
return null;
|
||||
}
|
||||
return engine.getMediaDownloadPath(cameraConfig, media);
|
||||
}
|
||||
|
||||
public getCapabilities(): CameraManagerCapabilities | null {
|
||||
const engines = this._engineFactory.getAllEngines(this._cameras);
|
||||
if (!engines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
canFavoriteEvents: engines.some(
|
||||
(engine) => engine.getCapabilities()?.canFavoriteEvents,
|
||||
),
|
||||
canFavoriteRecordings: engines.some(
|
||||
(engine) => engine.getCapabilities()?.canFavoriteRecordings,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
|
||||
const engine = this._engineFactory.getEngineForMedia(this._cameras, media);
|
||||
if (!engine) {
|
||||
return null;
|
||||
}
|
||||
return engine.getMediaCapabilities(media);
|
||||
}
|
||||
|
||||
public async favoriteMedia(
|
||||
hass: HomeAssistant,
|
||||
media: ViewMedia,
|
||||
favorite: boolean,
|
||||
): Promise<void> {
|
||||
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
if (engine) {
|
||||
const queryStartTime = new Date();
|
||||
await engine.favoriteMedia(hass, cameraConfig, media, favorite);
|
||||
|
||||
log(
|
||||
this._cardWideConfig,
|
||||
'Frigate Card CameraManager favorite request (',
|
||||
`Duration: ${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
|
||||
'Media:',
|
||||
media.getID(),
|
||||
', Favorite:',
|
||||
favorite,
|
||||
')',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public areMediaQueriesResultsFresh<T extends MediaQuery>(
|
||||
queries: T[],
|
||||
resultsTimestamp: Date,
|
||||
): boolean {
|
||||
const now = new Date();
|
||||
|
||||
for (const query of queries) {
|
||||
const engines = this._engineFactory.getEnginesForCameraIDs(
|
||||
this._cameras,
|
||||
query.cameraIDs,
|
||||
);
|
||||
for (const [engine, cameraIDs] of engines ?? []) {
|
||||
const maxAgeSeconds = engine.getQueryResultMaxAge({
|
||||
...query,
|
||||
cameraIDs: cameraIDs,
|
||||
});
|
||||
if (
|
||||
maxAgeSeconds !== null &&
|
||||
add(resultsTimestamp, { seconds: maxAgeSeconds }) < now
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getMediaSeekTime(
|
||||
hass: HomeAssistant,
|
||||
media: ViewMedia,
|
||||
target: Date,
|
||||
): Promise<number | null> {
|
||||
const startTime = media.getStartTime();
|
||||
const endTime = media.getEndTime();
|
||||
const cameraConfig = this._cameras.get(media.getCameraID());
|
||||
if (
|
||||
!cameraConfig ||
|
||||
!startTime ||
|
||||
!endTime ||
|
||||
target < startTime ||
|
||||
target > endTime
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
return (await engine?.getMediaSeekTime(hass, this._cameras, media, target)) ?? null;
|
||||
}
|
||||
|
||||
protected async _handleQuery<QT extends DataQuery>(
|
||||
hass: HomeAssistant,
|
||||
query: QT | QT[],
|
||||
): Promise<Map<QT, QueryReturnType<QT>>> {
|
||||
const _queries = arrayify(query);
|
||||
const results = new Map<QT, QueryReturnType<QT>>();
|
||||
const queryStartTime = new Date();
|
||||
|
||||
const processEngineQuery = async (
|
||||
engine: CameraManagerEngine,
|
||||
query?: QT,
|
||||
): Promise<void> => {
|
||||
if (!query) {
|
||||
return;
|
||||
}
|
||||
|
||||
let engineResult: Map<QT, QueryReturnType<QT>> | null = null;
|
||||
if (QueryClassifier.isEventQuery(query)) {
|
||||
engineResult = (await engine.getEvents(hass, this._cameras, query)) as Map<
|
||||
QT,
|
||||
QueryReturnType<QT>
|
||||
> | null;
|
||||
} else if (QueryClassifier.isRecordingQuery(query)) {
|
||||
engineResult = (await engine.getRecordings(hass, this._cameras, query)) as Map<
|
||||
QT,
|
||||
QueryReturnType<QT>
|
||||
> | null;
|
||||
} else if (QueryClassifier.isRecordingSegmentsQuery(query)) {
|
||||
engineResult = (await engine.getRecordingSegments(
|
||||
hass,
|
||||
this._cameras,
|
||||
query,
|
||||
)) as Map<QT, QueryReturnType<QT>> | null;
|
||||
}
|
||||
|
||||
engineResult?.forEach((value, key) => results.set(key, value));
|
||||
};
|
||||
|
||||
const processQuery = async (query: QT): Promise<void> => {
|
||||
const engines = this._engineFactory.getEnginesForCameraIDs(
|
||||
this._cameras,
|
||||
query.cameraIDs,
|
||||
);
|
||||
if (!engines) {
|
||||
return;
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(engines.keys()).map((engine) =>
|
||||
processEngineQuery(engine, { ...query, cameraIDs: engines.get(engine) }),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
await Promise.all(_queries.map((query) => processQuery(query)));
|
||||
|
||||
const cachedOutputQueries = sum(
|
||||
Array.from(results.values()).map((result) => Number(result.cached)),
|
||||
);
|
||||
|
||||
log(
|
||||
this._cardWideConfig,
|
||||
'Frigate Card CameraManager request [Input queries:',
|
||||
_queries.length,
|
||||
', Cached output queries:',
|
||||
cachedOutputQueries,
|
||||
', Total output queries:',
|
||||
results.size,
|
||||
', Duration:',
|
||||
`${(new Date().getTime() - queryStartTime.getTime()) / 1000}s,`,
|
||||
', Queries:',
|
||||
_queries,
|
||||
', Results:',
|
||||
results,
|
||||
']',
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
protected _convertQueryResultsToMedia<QT extends DataQuery>(
|
||||
hass: HomeAssistant,
|
||||
results: ResultsMap<QT>,
|
||||
): ViewMedia[] {
|
||||
const mediaArray: ViewMedia[] = [];
|
||||
for (const [query, result] of results.entries()) {
|
||||
const engine = this._engineFactory.getEngine(result.engine);
|
||||
|
||||
if (engine) {
|
||||
let media: ViewMedia[] | null = null;
|
||||
if (
|
||||
QueryClassifier.isEventQuery(query) &&
|
||||
QueryResultClassifier.isEventQueryResult(result)
|
||||
) {
|
||||
media = engine.generateMediaFromEvents(hass, this._cameras, query, result);
|
||||
} else if (
|
||||
QueryClassifier.isRecordingQuery(query) &&
|
||||
QueryResultClassifier.isRecordingQuery(result)
|
||||
) {
|
||||
media = engine.generateMediaFromRecordings(hass, this._cameras, query, result);
|
||||
}
|
||||
if (media) {
|
||||
mediaArray.push(...media);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._sortMedia(mediaArray);
|
||||
}
|
||||
|
||||
protected _sortMedia(mediaArray: ViewMedia[]): ViewMedia[] {
|
||||
return orderBy(
|
||||
// Ensure uniqueness by the ID (if specified), otherwise all elements
|
||||
// are assumed to be unique.
|
||||
uniqBy(mediaArray, (media) => media.getID() ?? media),
|
||||
|
||||
// Sort all items leading with the most recent.
|
||||
(media) => media.getStartTime(),
|
||||
'desc',
|
||||
);
|
||||
}
|
||||
|
||||
public getCameraMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig?: CameraConfig,
|
||||
): CameraManagerCameraMetadata | null {
|
||||
const engine = this._engineFactory.getEngineForCamera(cameraConfig);
|
||||
if (!engine || !cameraConfig) {
|
||||
return null;
|
||||
}
|
||||
return engine.getCameraMetadata(hass, cameraConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
|
||||
interface Range<T extends Date | number> {
|
||||
start: T;
|
||||
end: T;
|
||||
}
|
||||
|
||||
export type DateRange = Range<Date>;
|
||||
|
||||
interface MemoryRangeSetInterface<T> {
|
||||
hasCoverage(range: T): boolean;
|
||||
add(range: T): void;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export class MemoryRangeSet implements MemoryRangeSetInterface<DateRange> {
|
||||
protected _ranges: DateRange[];
|
||||
|
||||
constructor(ranges?: DateRange[]) {
|
||||
this._ranges = ranges ?? [];
|
||||
}
|
||||
|
||||
public hasCoverage(range: DateRange): boolean {
|
||||
return this._ranges.some((cachedRange) =>
|
||||
rangeIsEntirelyContained(cachedRange, range),
|
||||
);
|
||||
}
|
||||
|
||||
public add(range: DateRange): void {
|
||||
this._ranges.push(range);
|
||||
this._ranges = compressRanges(this._ranges);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this._ranges = [];
|
||||
}
|
||||
}
|
||||
|
||||
interface ExpiringRange<T extends Date | number> extends Range<T> {
|
||||
expires: Date;
|
||||
}
|
||||
|
||||
export class ExpiringMemoryRangeSet
|
||||
implements MemoryRangeSetInterface<ExpiringRange<Date>>
|
||||
{
|
||||
protected _ranges: ExpiringRange<Date>[];
|
||||
|
||||
constructor(ranges?: ExpiringRange<Date>[]) {
|
||||
this._ranges = ranges ?? [];
|
||||
}
|
||||
|
||||
public hasCoverage(range: DateRange): boolean {
|
||||
const now = new Date();
|
||||
return this._ranges.some(
|
||||
(cachedRange) =>
|
||||
now < cachedRange.expires && rangeIsEntirelyContained(cachedRange, range),
|
||||
);
|
||||
}
|
||||
|
||||
public add(range: ExpiringRange<Date>): void {
|
||||
this._expireOldRanges();
|
||||
this._ranges.push(range);
|
||||
}
|
||||
|
||||
protected _expireOldRanges(): void {
|
||||
const now = new Date();
|
||||
this._ranges = this._ranges.filter((range) => now < range.expires);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this._ranges = [];
|
||||
}
|
||||
}
|
||||
|
||||
const rangeIsEntirelyContained = (bigger: DateRange, smaller: DateRange): boolean => {
|
||||
return smaller.start >= bigger.start && smaller.end <= bigger.end;
|
||||
};
|
||||
|
||||
export const rangesOverlap = (a: DateRange, b: DateRange): boolean => {
|
||||
return (
|
||||
// a starts within the range of b.
|
||||
(a.start >= b.start && a.start <= b.end) ||
|
||||
// a events within the range of b.
|
||||
(a.end >= b.start && a.end <= b.end) ||
|
||||
// a encompasses the entire range of b.
|
||||
(a.start <= b.start && a.end >= b.end)
|
||||
);
|
||||
};
|
||||
|
||||
export const compressRanges = <T extends Date | number>(
|
||||
ranges: Range<T>[],
|
||||
toleranceSeconds = 0,
|
||||
): Range<T>[] => {
|
||||
const compressedRanges: Range<T>[] = [];
|
||||
ranges = orderBy(ranges, (range) => range.start, 'asc');
|
||||
|
||||
let current: Range<T> | null = null;
|
||||
for (let i = 0; i < ranges.length; ++i) {
|
||||
const item = ranges[i];
|
||||
const itemStartSeconds =
|
||||
item.start instanceof Date ? item.start.getTime() : item.start;
|
||||
|
||||
if (!current) {
|
||||
current = { ...item };
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentEndSeconds =
|
||||
current.end instanceof Date ? current.end.getTime() : (current.end as number);
|
||||
|
||||
if (currentEndSeconds + toleranceSeconds * 1000 >= itemStartSeconds) {
|
||||
if (item.end > current.end) {
|
||||
current.end = item.end;
|
||||
}
|
||||
} else {
|
||||
compressedRanges.push(current);
|
||||
current = { ...item };
|
||||
}
|
||||
}
|
||||
if (current) {
|
||||
compressedRanges.push(current);
|
||||
}
|
||||
|
||||
return compressedRanges;
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import { FrigateEvent, FrigateRecording } from './frigate/types';
|
||||
|
||||
// ====
|
||||
// Base
|
||||
// ====
|
||||
|
||||
export enum QueryType {
|
||||
Event = 'event-query',
|
||||
Recording = 'recording-query',
|
||||
RecordingSegments = 'recording-segments-query',
|
||||
}
|
||||
|
||||
export enum QueryResultsType {
|
||||
Event = 'event-results',
|
||||
Recording = 'recording-results',
|
||||
RecordingSegments = 'recording-segments-results',
|
||||
}
|
||||
|
||||
export enum Engine {
|
||||
Frigate = 'frigate',
|
||||
}
|
||||
|
||||
export interface DataQuery {
|
||||
type: QueryType;
|
||||
cameraIDs: Set<string>;
|
||||
}
|
||||
export type PartialDataQuery = Partial<DataQuery>;
|
||||
|
||||
interface TimeBasedDataQuery {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
interface LimitedDataQuery {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface MediaQuery
|
||||
extends DataQuery,
|
||||
Partial<TimeBasedDataQuery>,
|
||||
Partial<LimitedDataQuery> {
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface QueryResults {
|
||||
type: QueryResultsType;
|
||||
engine: Engine;
|
||||
expiry?: Date;
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
// Generic recording segment type (inspired by Frigate recording segments).
|
||||
export interface RecordingSegment {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type QueryReturnType<QT> = QT extends EventQuery
|
||||
? EventQueryResults
|
||||
: QT extends RecordingQuery
|
||||
? RecordingQueryResults
|
||||
: QT extends RecordingSegmentsQuery
|
||||
? RecordingSegmentsQueryResults
|
||||
: never;
|
||||
export type PartialQueryConcreteType<PQT> = PQT extends PartialEventQuery
|
||||
? EventQuery
|
||||
: PQT extends PartialRecordingQuery
|
||||
? RecordingQuery
|
||||
: PQT extends PartialRecordingSegmentsQuery
|
||||
? RecordingSegmentsQuery
|
||||
: never;
|
||||
|
||||
export type ResultsMap<QT> = Map<QT, QueryReturnType<QT>>;
|
||||
export type EventQueryResultsMap = ResultsMap<EventQuery>;
|
||||
export type RecordingQueryResultsMap = ResultsMap<RecordingQuery>;
|
||||
export type RecordingSegmentsQueryResultsMap = ResultsMap<RecordingSegmentsQuery>;
|
||||
|
||||
export interface MediaMetadata {
|
||||
where?: Set<string>;
|
||||
what?: Set<string>;
|
||||
days?: Set<string>;
|
||||
}
|
||||
|
||||
interface BaseCapabilities {
|
||||
canFavoriteEvents: boolean;
|
||||
canFavoriteRecordings: boolean;
|
||||
}
|
||||
|
||||
export type CameraManagerCapabilities = BaseCapabilities;
|
||||
export type CameraManagerEngineCapabilities = BaseCapabilities;
|
||||
export interface CameraManagerMediaCapabilities {
|
||||
canFavorite: boolean;
|
||||
}
|
||||
|
||||
export interface CameraManagerCameraMetadata {
|
||||
title: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
// ===========
|
||||
// Event Query
|
||||
// ===========
|
||||
|
||||
export interface EventQuery extends MediaQuery {
|
||||
type: QueryType.Event;
|
||||
|
||||
// Frigate equivalent: has_snapshot
|
||||
hasSnapshot?: boolean;
|
||||
|
||||
// Frigate equivalent: has_clip
|
||||
hasClip?: boolean;
|
||||
|
||||
// Frigate equivalent: label
|
||||
what?: Set<string>;
|
||||
|
||||
// Frigate equivalent: zone
|
||||
where?: Set<string>;
|
||||
}
|
||||
export type PartialEventQuery = Partial<EventQuery>;
|
||||
|
||||
export interface EventQueryResults extends QueryResults {
|
||||
type: QueryResultsType.Event;
|
||||
}
|
||||
|
||||
// ===============
|
||||
// Recording Query
|
||||
// ===============
|
||||
|
||||
export interface RecordingQuery extends MediaQuery {
|
||||
type: QueryType.Recording;
|
||||
}
|
||||
export type PartialRecordingQuery = Partial<RecordingQuery>;
|
||||
|
||||
export interface RecordingQueryResults extends QueryResults {
|
||||
type: QueryResultsType.Recording;
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Recording Segments Query
|
||||
// ========================
|
||||
|
||||
export interface RecordingSegmentsQuery extends DataQuery, TimeBasedDataQuery {
|
||||
type: QueryType.RecordingSegments;
|
||||
}
|
||||
export type PartialRecordingSegmentsQuery = Partial<RecordingSegmentsQuery>;
|
||||
|
||||
export interface RecordingSegmentsQueryResults extends QueryResults {
|
||||
type: QueryResultsType.RecordingSegments;
|
||||
segments: RecordingSegment[];
|
||||
}
|
||||
|
||||
// ========================
|
||||
// Frigate concrete results
|
||||
// ========================
|
||||
|
||||
export interface FrigateEventQueryResults extends EventQueryResults {
|
||||
engine: Engine.Frigate;
|
||||
instanceID: string;
|
||||
events: FrigateEvent[];
|
||||
}
|
||||
|
||||
export interface FrigateRecordingQueryResults extends RecordingQueryResults {
|
||||
engine: Engine.Frigate;
|
||||
instanceID: string;
|
||||
recordings: FrigateRecording[];
|
||||
}
|
||||
|
||||
export interface FrigateRecordingSegmentsQueryResults
|
||||
extends RecordingSegmentsQueryResults {
|
||||
engine: Engine.Frigate;
|
||||
instanceID: string;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import endOfMinute from 'date-fns/endOfMinute';
|
||||
import { DateRange } from './range';
|
||||
|
||||
export const convertRangeToCacheFriendlyTimes = (
|
||||
range: DateRange,
|
||||
options?: {
|
||||
endCap?: boolean;
|
||||
},
|
||||
): DateRange => {
|
||||
const widthSeconds = (range.end.getTime() - range.start.getTime()) / 1000;
|
||||
let cacheableStart: Date;
|
||||
let cacheableEnd: Date;
|
||||
|
||||
if (widthSeconds <= 60 * 60) {
|
||||
cacheableStart = startOfHour(range.start);
|
||||
cacheableEnd = endOfHour(range.end);
|
||||
} else {
|
||||
cacheableStart = startOfDay(range.start);
|
||||
cacheableEnd = endOfDay(range.end);
|
||||
}
|
||||
|
||||
if (options?.endCap) {
|
||||
cacheableEnd = endOfMinute(capEndDate(cacheableEnd));
|
||||
}
|
||||
|
||||
return {
|
||||
start: cacheableStart,
|
||||
end: cacheableEnd,
|
||||
};
|
||||
};
|
||||
|
||||
export const capEndDate = (end: Date): Date => {
|
||||
const now = new Date();
|
||||
return end > now ? now : end;
|
||||
};
|
||||
@@ -21,7 +21,7 @@ class ConditionStateRequestEvent extends Event {
|
||||
public conditionState?: ConditionState;
|
||||
}
|
||||
|
||||
export function evaluateCondition(
|
||||
function evaluateCondition(
|
||||
condition?: Readonly<FrigateCardCondition>,
|
||||
state?: Readonly<ConditionState>,
|
||||
): boolean {
|
||||
@@ -180,7 +180,8 @@ export class CardConditionManager {
|
||||
* Trigger the callback.
|
||||
* @param _ Ignored parameter.
|
||||
*/
|
||||
protected _triggerChange(_): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected _triggerChange(_: MediaQueryListEvent): void {
|
||||
this._callback();
|
||||
}
|
||||
|
||||
|
||||
+88
-88
@@ -53,8 +53,6 @@ import {
|
||||
FrigateCardView,
|
||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||
MediaLoadedInfo,
|
||||
MEDIA_TYPE_IMAGE,
|
||||
MEDIA_TYPE_VIDEO,
|
||||
MESSAGE_TYPE_PRIORITIES,
|
||||
MenuButton,
|
||||
Message,
|
||||
@@ -69,7 +67,7 @@ import {
|
||||
getActionConfigGivenAction,
|
||||
} from './utils/action.js';
|
||||
import { contentsChanged, errorToConsole } from './utils/basic.js';
|
||||
import { getCameraIcon, getCameraID, getCameraTitle } from './utils/camera.js';
|
||||
import { getCameraID } from './utils/camera.js';
|
||||
import {
|
||||
getEntityIcon,
|
||||
getEntityTitle,
|
||||
@@ -80,7 +78,6 @@ import {
|
||||
isTriggeredState,
|
||||
sideLoadHomeAssistantElements,
|
||||
} from './utils/ha';
|
||||
import { getEventID } from './utils/ha/browse-media.js';
|
||||
import { DeviceList, getAllDevices } from './utils/ha/device-registry.js';
|
||||
import {
|
||||
ExtendedEntityCache,
|
||||
@@ -91,11 +88,13 @@ import {
|
||||
import { ResolvedMediaCache } from './utils/ha/resolved-media.js';
|
||||
import { supportsFeature } from './utils/ha/update.js';
|
||||
import { isValidMediaLoadedInfo } from './utils/media-info.js';
|
||||
import { View } from './view.js';
|
||||
import { View } from './view/view.js';
|
||||
import pkg from '../package.json';
|
||||
import { ViewContext } from 'view';
|
||||
import { DataManager } from './utils/data-manager.js';
|
||||
import { CameraManager } from './camera-manager/manager.js';
|
||||
import { setLowPerformanceProfile, setPerformanceCSSStyles } from './performance.js';
|
||||
import { CameraManagerEngineFactory } from './camera-manager/engine-factory.js';
|
||||
import { log } from './utils/debug.js';
|
||||
|
||||
/** A note on media callbacks:
|
||||
*
|
||||
@@ -144,11 +143,13 @@ console.info(
|
||||
documentationURL: REPO_URL,
|
||||
});
|
||||
|
||||
type InitializedType = 'initialized' | 'initializing';
|
||||
|
||||
/**
|
||||
* Main FrigateCard class.
|
||||
*/
|
||||
@customElement('frigate-card')
|
||||
export class FrigateCard extends LitElement {
|
||||
class FrigateCard extends LitElement {
|
||||
@state()
|
||||
protected _hass?: ExtendedHomeAssistant;
|
||||
|
||||
@@ -204,16 +205,14 @@ export class FrigateCard extends LitElement {
|
||||
// A cache of resolved media URLs/mimetypes for use in the whole card.
|
||||
protected _resolvedMediaCache = new ResolvedMediaCache();
|
||||
|
||||
// Shared timeline data manager (for main timeline view and mini-timelines).
|
||||
protected _dataManager?: DataManager;
|
||||
protected _cameraManager?: CameraManager;
|
||||
|
||||
// The mouse handler may be called continually, throttle it to at most once
|
||||
// per second for performance reasons.
|
||||
protected _boundMouseHandler = throttle(this._mouseHandler.bind(this), 1 * 1000);
|
||||
|
||||
// Whether the card has been successfully initialized.
|
||||
protected _loadedHAElements = false;
|
||||
protected _loadedLanguages = false;
|
||||
protected _initialized?: InitializedType;
|
||||
|
||||
protected _triggers: Map<string, Date> = new Map();
|
||||
protected _untriggerTimerID: number | null = null;
|
||||
@@ -404,12 +403,16 @@ export class FrigateCard extends LitElement {
|
||||
const action = createFrigateCardCustomAction('camera_select', {
|
||||
camera: camera,
|
||||
});
|
||||
const metadata = this._hass
|
||||
? this._cameraManager?.getCameraMetadata(this._hass, config) ?? undefined
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
icon: getCameraIcon(this._hass, config),
|
||||
icon: metadata?.icon,
|
||||
entity: config.camera_entity,
|
||||
state_color: true,
|
||||
title: getCameraTitle(this._hass, config),
|
||||
title: metadata?.title,
|
||||
selected: this._view?.camera === camera,
|
||||
...(action && { tap_action: action }),
|
||||
};
|
||||
@@ -530,7 +533,8 @@ export class FrigateCard extends LitElement {
|
||||
|
||||
if (
|
||||
!this._isBeingCasted() &&
|
||||
(this._view?.isViewerView() || (this._view?.is('timeline') && !!this._view?.media))
|
||||
(this._view?.isViewerView() ||
|
||||
(this._view?.is('timeline') && !!this._view?.queryResults?.hasSelectedResult()))
|
||||
) {
|
||||
buttons.push({
|
||||
icon: 'mdi:download',
|
||||
@@ -989,6 +993,7 @@ export class FrigateCard extends LitElement {
|
||||
this._config = config;
|
||||
this._cardWideConfig = {
|
||||
performance: config.performance,
|
||||
debug: config.debug,
|
||||
};
|
||||
|
||||
this._overriddenConfig = undefined;
|
||||
@@ -1016,13 +1021,16 @@ export class FrigateCard extends LitElement {
|
||||
}
|
||||
|
||||
protected _changeView(args?: { view?: View; resetMessage?: boolean }): void {
|
||||
log(this._cardWideConfig, `Frigate Card view change: `, args?.view ?? '[default]');
|
||||
const changeView = (view: View): void => {
|
||||
if (View.isMediaChange(this._view, view)) {
|
||||
this._currentMediaLoadedInfo = null;
|
||||
}
|
||||
if (this._view?.view !== view.view) {
|
||||
this._resetMainScroll();
|
||||
}
|
||||
this._view = view;
|
||||
this._generateConditionState();
|
||||
this._resetMainScroll();
|
||||
};
|
||||
|
||||
if (args?.resetMessage ?? true) {
|
||||
@@ -1099,18 +1107,18 @@ export class FrigateCard extends LitElement {
|
||||
* Called before each update.
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
// Side load the necessary elements if not already initialized (do not need
|
||||
// to block for the loading to complete).
|
||||
if (!this._loadedHAElements) {
|
||||
sideLoadHomeAssistantElements().then((success) => {
|
||||
if (success) {
|
||||
this._loadedHAElements = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this._cameras && (changedProps.has('_config') || changedProps.has('_cameras'))) {
|
||||
this._dataManager = new DataManager(this._cameras);
|
||||
if (
|
||||
this._cameras &&
|
||||
this._cardWideConfig &&
|
||||
(changedProps.has('_config') ||
|
||||
changedProps.has('_cameras') ||
|
||||
changedProps.has('_cardWideConfig'))
|
||||
) {
|
||||
this._cameraManager = new CameraManager(
|
||||
new CameraManagerEngineFactory(this._cardWideConfig),
|
||||
this._cameras,
|
||||
this._cardWideConfig,
|
||||
);
|
||||
}
|
||||
|
||||
if (changedProps.has('_cardWideConfig')) {
|
||||
@@ -1237,6 +1245,12 @@ export class FrigateCard extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the card.
|
||||
*/
|
||||
protected async _initialize(): Promise<void> {
|
||||
await Promise.all([sideLoadHomeAssistantElements(), loadLanguages()]);
|
||||
}
|
||||
/**
|
||||
* Determine whether the element should be updated.
|
||||
* @param changedProps The changed properties if any.
|
||||
@@ -1244,11 +1258,13 @@ export class FrigateCard extends LitElement {
|
||||
*/
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
// Load the relevant languages. Cannot do anything until then.
|
||||
if (!this._loadedLanguages) {
|
||||
loadLanguages().then(() => {
|
||||
this._loadedLanguages = true;
|
||||
this.requestUpdate();
|
||||
});
|
||||
if (this._initialized !== 'initialized') {
|
||||
if (this._initialized !== 'initializing') {
|
||||
this._initialize().then(() => {
|
||||
this._initialized = 'initialized';
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1318,12 +1334,9 @@ export class FrigateCard extends LitElement {
|
||||
// Should not occur.
|
||||
return;
|
||||
}
|
||||
const media = this._view.queryResults?.getSelectedResult();
|
||||
|
||||
if (
|
||||
!this._view.media ||
|
||||
(this._view.media.media_content_type !== MEDIA_TYPE_VIDEO &&
|
||||
this._view.media.media_content_type !== MEDIA_TYPE_IMAGE)
|
||||
) {
|
||||
if (!media) {
|
||||
this._setMessageAndUpdate({
|
||||
message: localize('error.download_no_media'),
|
||||
type: 'error',
|
||||
@@ -1336,35 +1349,8 @@ export class FrigateCard extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
let path: string;
|
||||
if (this._view.media.frigate?.event) {
|
||||
const event_id = getEventID(this._view.media);
|
||||
if (!event_id) {
|
||||
this._setMessageAndUpdate({
|
||||
message: localize('error.download_no_event_id'),
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
path =
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/notifications/${event_id}/` +
|
||||
`${
|
||||
this._view.media.media_content_type === MEDIA_TYPE_VIDEO
|
||||
? 'clip.mp4'
|
||||
: 'snapshot.jpg'
|
||||
}` +
|
||||
`?download=true`;
|
||||
} else if (this._view.media.frigate?.recording) {
|
||||
const recording = this._view.media.frigate.recording;
|
||||
path =
|
||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||
`/recording/${cameraConfig.frigate.camera_name}` +
|
||||
`/start/${recording.start_time}` +
|
||||
`/end/${recording.end_time}` +
|
||||
`?download=true`;
|
||||
} else {
|
||||
const path = this._cameraManager?.getMediaDownloadPath(media);
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1412,30 +1398,40 @@ export class FrigateCard extends LitElement {
|
||||
* @returns
|
||||
*/
|
||||
protected _mediaPlayerAction(mediaPlayer: string, action: 'play' | 'stop'): void {
|
||||
if (!['play', 'stop'].includes(action)) {
|
||||
if (
|
||||
!['play', 'stop'].includes(action) ||
|
||||
!this._view ||
|
||||
!this._hass ||
|
||||
!this._cameraManager
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let media_content_id: string;
|
||||
let media_content_type: string;
|
||||
const extra = {};
|
||||
const cameraConfig = this._getSelectedCameraConfig();
|
||||
const cameraEntity = cameraConfig?.camera_entity ?? null;
|
||||
let media_content_id: string | null = null;
|
||||
let media_content_type: string | null = null;
|
||||
let title: string | null = null;
|
||||
let thumbnail: string | null = null;
|
||||
|
||||
if (this._view?.isViewerView() && this._view.media) {
|
||||
media_content_id = this._view.media.media_content_id;
|
||||
media_content_type = this._view.media.media_content_type;
|
||||
extra['thumb'] = this._view.media.thumbnail;
|
||||
extra['title'] = this._view.media.title;
|
||||
const cameraConfig = this._getSelectedCameraConfig();
|
||||
if (!cameraConfig) {
|
||||
return;
|
||||
}
|
||||
const cameraEntity = cameraConfig.camera_entity ?? null;
|
||||
const media = this._view.queryResults?.getSelectedResult();
|
||||
|
||||
if (this._view.isViewerView() && media && this._cameras) {
|
||||
media_content_id = media.getContentID();
|
||||
media_content_type = media.getContentType();
|
||||
title = media.getTitle();
|
||||
thumbnail = media.getThumbnail();
|
||||
} else if (this._view?.is('live') && cameraEntity) {
|
||||
if (this._hass?.states && cameraEntity in this._hass.states) {
|
||||
extra['thumb'] =
|
||||
this._hass.states[cameraEntity].attributes.entity_picture ?? null;
|
||||
}
|
||||
extra['title'] = getCameraTitle(this._hass, cameraConfig);
|
||||
media_content_id = `media-source://camera/${cameraEntity}`;
|
||||
media_content_type = 'application/vnd.apple.mpegurl';
|
||||
} else {
|
||||
title = this._cameraManager.getCameraMetadata(this._hass, cameraConfig)?.title ?? null;
|
||||
thumbnail = this._hass?.states[cameraEntity]?.attributes?.entity_picture ?? null;
|
||||
}
|
||||
|
||||
if (!media_content_id || !media_content_type) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1444,7 +1440,10 @@ export class FrigateCard extends LitElement {
|
||||
entity_id: mediaPlayer,
|
||||
media_content_id: media_content_id,
|
||||
media_content_type: media_content_type,
|
||||
extra: extra,
|
||||
extra: {
|
||||
...(title && { title: title }),
|
||||
...(thumbnail && { thumb: thumbnail }),
|
||||
},
|
||||
});
|
||||
} else if (action === 'stop') {
|
||||
this._hass?.callService('media_player', 'media_stop', {
|
||||
@@ -1576,6 +1575,7 @@ export class FrigateCard extends LitElement {
|
||||
date: new Date(),
|
||||
frigate_version: Object.fromEntries(frigateVersionMap),
|
||||
lang: getLanguage(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
git: {
|
||||
...(pkg['gitVersion'] && { build_version: pkg['gitVersion'] }),
|
||||
...(pkg['buildDate'] && { build_date: pkg['buildDate'] }),
|
||||
@@ -2037,7 +2037,7 @@ export class FrigateCard extends LitElement {
|
||||
.view=${this._view}
|
||||
.cameras=${this._cameras}
|
||||
.galleryConfig=${this._getConfig().event_gallery}
|
||||
.dataManager=${this._dataManager}
|
||||
.cameraManager=${this._cameraManager}
|
||||
.cardWideConfig=${this._cardWideConfig}
|
||||
>
|
||||
</frigate-card-gallery>`
|
||||
@@ -2049,7 +2049,7 @@ export class FrigateCard extends LitElement {
|
||||
.cameras=${this._cameras}
|
||||
.viewerConfig=${this._getConfig().media_viewer}
|
||||
.resolvedMediaCache=${this._resolvedMediaCache}
|
||||
.dataManager=${this._dataManager}
|
||||
.cameraManager=${this._cameraManager}
|
||||
.cardWideConfig=${this._cardWideConfig}
|
||||
>
|
||||
</frigate-card-viewer>`
|
||||
@@ -2060,7 +2060,7 @@ export class FrigateCard extends LitElement {
|
||||
.view=${this._view}
|
||||
.cameras=${this._cameras}
|
||||
.timelineConfig=${this._getConfig().timeline}
|
||||
.dataManager=${this._dataManager}
|
||||
.cameraManager=${this._cameraManager}
|
||||
>
|
||||
</frigate-card-timeline>`
|
||||
: ``}
|
||||
@@ -2081,7 +2081,7 @@ export class FrigateCard extends LitElement {
|
||||
.conditionState=${this._conditionState}
|
||||
.liveOverrides=${getOverridesByKey(this._getConfig().overrides, 'live')}
|
||||
.cameras=${this._cameras}
|
||||
.dataManager=${this._dataManager}
|
||||
.cameraManager=${this._cameraManager}
|
||||
.cardWideConfig=${this._cardWideConfig}
|
||||
class="${classMap(liveClasses)}"
|
||||
>
|
||||
|
||||
+17
-55
@@ -41,15 +41,12 @@ export class FrigateCardCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public carouselPlugins?: EmblaCarouselPlugins;
|
||||
|
||||
@property({ attribute: false })
|
||||
public selected = 0;
|
||||
|
||||
@property({ attribute: true })
|
||||
public transitionEffect?: TransitionEffect;
|
||||
|
||||
// An override to the startIndex, used to preserve the current carousel
|
||||
// position after the carousel is destroyed (so it can be restored if
|
||||
// recreated).
|
||||
// See: https://github.com/dermotduffy/frigate-hass-card/issues/775
|
||||
protected _savedStartIndex: number | null = null;
|
||||
|
||||
protected _refSlot: Ref<HTMLSlotElement> = createRef();
|
||||
|
||||
protected _carousel?: EmblaCarouselType;
|
||||
@@ -81,7 +78,7 @@ export class FrigateCardCarousel extends LitElement {
|
||||
// Destroy the carousel when the component is disconnected, which forces the
|
||||
// plugins (which may have registered event handlers) to also be destroyed.
|
||||
// The carousel will automatically reconstruct if the component is re-rendered.
|
||||
this._destroyCarousel({ savePosition: true });
|
||||
this._destroyCarousel();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -96,42 +93,10 @@ export class FrigateCardCarousel extends LitElement {
|
||||
'carouselPlugins',
|
||||
] as const;
|
||||
if (destroyProperties.some((prop) => changedProps.has(prop))) {
|
||||
this._destroyCarousel({ savePosition: true });
|
||||
this._destroyCarousel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a particular slide.
|
||||
* @param index Slide number.
|
||||
*/
|
||||
public carouselScrollTo(index: number): void {
|
||||
const scroll = () =>
|
||||
this._carousel?.scrollTo(index, this.transitionEffect === 'none');
|
||||
// This ensures scrolling can work on initial render when the carousel may
|
||||
// not yet exist.
|
||||
if (this._carousel) {
|
||||
scroll();
|
||||
} else {
|
||||
this.updateComplete.then(() => {
|
||||
scroll();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to the previous slide.
|
||||
*/
|
||||
public carouselScrollPrevious(): void {
|
||||
this._carousel?.scrollPrev(this.transitionEffect === 'none');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to the next slide.
|
||||
*/
|
||||
public carouselScrollNext(): void {
|
||||
this._carousel?.scrollNext(this.transitionEffect === 'none');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the selected slide.
|
||||
* @returns A CarouselSelect object (index & element).
|
||||
@@ -174,11 +139,10 @@ export class FrigateCardCarousel extends LitElement {
|
||||
window.requestAnimationFrame(() => {
|
||||
this._carousel?.reInit({ ...options });
|
||||
});
|
||||
}
|
||||
const selected = this.getCarouselSelected();
|
||||
};
|
||||
|
||||
carouselReInit({
|
||||
...(selected && { startIndex: selected.index }),
|
||||
startIndex: this.selected,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,6 +175,10 @@ export class FrigateCardCarousel extends LitElement {
|
||||
if (!this._carousel) {
|
||||
this._initCarousel();
|
||||
}
|
||||
|
||||
if (changedProperties.has('selected')) {
|
||||
this._carousel?.scrollTo(this.selected, this.transitionEffect === 'none');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,9 +186,7 @@ export class FrigateCardCarousel extends LitElement {
|
||||
* @param options If `savePosition` is set the existing carousel position
|
||||
* will be saved so it can be restored if the carousel is recreated.
|
||||
*/
|
||||
protected _destroyCarousel(options?: { savePosition: boolean }): void {
|
||||
this._savedStartIndex =
|
||||
(options?.savePosition ? this._carousel?.selectedScrollSnap() : null) ?? null;
|
||||
protected _destroyCarousel(): void {
|
||||
if (this._carousel) {
|
||||
this._carousel.destroy();
|
||||
}
|
||||
@@ -248,8 +214,8 @@ export class FrigateCardCarousel extends LitElement {
|
||||
{
|
||||
axis: this.direction == 'horizontal' ? 'x' : 'y',
|
||||
speed: 20,
|
||||
startIndex: this.selected,
|
||||
...this.carouselOptions,
|
||||
...(this._savedStartIndex !== null && { startIndex: this._savedStartIndex }),
|
||||
},
|
||||
this.carouselPlugins,
|
||||
);
|
||||
@@ -262,7 +228,7 @@ export class FrigateCardCarousel extends LitElement {
|
||||
// Make sure every select causes a refresh to allow for re-paint of the
|
||||
// next/previous controls.
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
this._carousel.on('init', selectSlide);
|
||||
this._carousel.on('select', selectSlide);
|
||||
@@ -294,18 +260,14 @@ export class FrigateCardCarousel extends LitElement {
|
||||
protected _slotChanged(): void {
|
||||
// Cannot just re-init, because the slide elements themselves may have
|
||||
// changed, and only a carousel init can pass in new (slotted) children. If
|
||||
// the slides themselves change, any position the user has set is assumed to
|
||||
// be abandoned and so the startIndex is reset to whatever the carousel was
|
||||
// originally configured with.
|
||||
this._destroyCarousel({ savePosition: false });
|
||||
this._destroyCarousel();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const slides = this._refSlot.value?.assignedElements({ flatten: true }) || [];
|
||||
const currentSlide = (this._carousel?.selectedScrollSnap() ?? this.carouselOptions?.startIndex) ?? 0;
|
||||
const showPrevious = this.carouselOptions?.loop || currentSlide > 0;
|
||||
const showNext = this.carouselOptions?.loop || currentSlide + 1 < slides.length;
|
||||
const showPrevious = this.carouselOptions?.loop || this.selected > 0;
|
||||
const showNext = this.carouselOptions?.loop || this.selected + 1 < slides.length;
|
||||
|
||||
return html` <div class="embla">
|
||||
${showPrevious ? html`<slot name="previous"></slot>` : ``}
|
||||
|
||||
@@ -15,6 +15,11 @@ import drawerStyle from '../scss/drawer.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action';
|
||||
import { isHoverableDevice } from '../utils/basic';
|
||||
|
||||
export interface DrawerIcons {
|
||||
open?: string;
|
||||
closed?: string;
|
||||
}
|
||||
|
||||
@customElement('frigate-card-drawer')
|
||||
export class FrigateCardDrawer extends LitElement {
|
||||
@property({ attribute: true, reflect: true })
|
||||
@@ -26,6 +31,9 @@ export class FrigateCardDrawer extends LitElement {
|
||||
@property({ type: Boolean, reflect: true, attribute: true })
|
||||
public open = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public icons?: DrawerIcons;
|
||||
|
||||
// The 'empty' attribute is used in the styling to change the drawer
|
||||
// visibility and that of all descendants if there is no content. Styling is
|
||||
// used rather than display or hidden in order to ensure the contents continue
|
||||
@@ -111,7 +119,9 @@ export class FrigateCardDrawer extends LitElement {
|
||||
>
|
||||
<ha-icon
|
||||
class="control"
|
||||
icon="${this.open ? 'mdi:menu-open' : 'mdi:menu'}"
|
||||
icon="${this.open
|
||||
? this.icons?.open ?? 'mdi:menu-open'
|
||||
: this.icons?.closed ?? 'mdi:menu'}"
|
||||
@mouseenter=${() => {
|
||||
// Only open the drawer on mousenter when the device
|
||||
// supports hover (otherwise iOS may end up passing on
|
||||
@@ -126,7 +136,7 @@ export class FrigateCardDrawer extends LitElement {
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
<slot ${ref(this._refSlot)} @slotchange=${this._slotChanged.bind(this)}></slot>
|
||||
<slot ${ref(this._refSlot)} @slotchange=${() => this._slotChanged()}></slot>
|
||||
</side-drawer>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const defaultOptions: OptionsType = {
|
||||
breakpoints: {},
|
||||
};
|
||||
|
||||
export type AutoMediaOptionsType = Partial<OptionsType>
|
||||
type AutoMediaOptionsType = Partial<OptionsType>
|
||||
|
||||
export type AutoMediaType = CreatePluginType<
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CreatePluginType } from 'embla-carousel/components/Plugins';
|
||||
import EmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel';
|
||||
import { LazyUnloadCondition } from '../../types';
|
||||
|
||||
export type OptionsType = CreateOptionsType<{
|
||||
type OptionsType = CreateOptionsType<{
|
||||
// Number of slides to lazyload left/right of selected (0 == only selected
|
||||
// slide).
|
||||
lazyLoadCount?: number;
|
||||
@@ -13,15 +13,15 @@ export type OptionsType = CreateOptionsType<{
|
||||
lazyUnloadCallback?: (index: number, slide: HTMLElement) => void;
|
||||
}>;
|
||||
|
||||
export const defaultOptions: OptionsType = {
|
||||
const defaultOptions: OptionsType = {
|
||||
active: true,
|
||||
breakpoints: {},
|
||||
lazyLoadCount: 0,
|
||||
};
|
||||
|
||||
export type LazyloadOptionsType = Partial<OptionsType>;
|
||||
type LazyloadOptionsType = Partial<OptionsType>;
|
||||
|
||||
export type LazyloadType = CreatePluginType<
|
||||
type LazyloadType = CreatePluginType<
|
||||
{
|
||||
hasLazyloaded(index: number): boolean;
|
||||
},
|
||||
|
||||
+175
-129
@@ -7,7 +7,7 @@ import {
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import galleryStyle from '../scss/gallery.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
@@ -19,27 +19,29 @@ import {
|
||||
} from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import {
|
||||
fetchChildMediaAndDispatchViewChange,
|
||||
fetchLatestMediaAndDispatchViewChange,
|
||||
getFullDependentBrowseMediaQueryParametersOrDispatchError,
|
||||
} from '../utils/ha/browse-media';
|
||||
import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js';
|
||||
import { DataManager } from '../utils/data-manager.js';
|
||||
import { View } from '../view.js';
|
||||
changeViewToRecentEventsForCameraAndDependents,
|
||||
changeViewToRecentRecordingForCameraAndDependents,
|
||||
} from '../utils/media-to-view.js';
|
||||
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { renderProgressIndicator } from './message.js';
|
||||
import './thumbnail.js';
|
||||
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
|
||||
import { createRef, Ref } from 'lit/directives/ref.js';
|
||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
||||
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types';
|
||||
import { MediaQueriesResults } from '../view/media-queries-results';
|
||||
import { errorToConsole } from '../utils/basic';
|
||||
import './media-filter';
|
||||
import "./surround-basic";
|
||||
|
||||
interface GalleryViewContext {
|
||||
// Keep track of the previous view to allow returning to a higher-level folder.
|
||||
previous?: View;
|
||||
}
|
||||
const GALLERY_MEDIA_CHUNK_SIZE = 100;
|
||||
|
||||
declare module 'view' {
|
||||
interface ViewContext {
|
||||
gallery?: GalleryViewContext;
|
||||
}
|
||||
}
|
||||
const GALLERY_MEDIA_FILTER_MENU_ICONS = {
|
||||
closed: 'mdi:filter-cog-outline',
|
||||
open: 'mdi:filter-cog',
|
||||
};
|
||||
|
||||
@customElement('frigate-card-gallery')
|
||||
export class FrigateCardGallery extends LitElement {
|
||||
@@ -56,7 +58,7 @@ export class FrigateCardGallery extends LitElement {
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public dataManager?: DataManager;
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
@@ -66,62 +68,75 @@ export class FrigateCardGallery extends LitElement {
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
const mediaType = this.view?.getMediaType();
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.cameras ||
|
||||
!this.view.isGalleryView() ||
|
||||
!mediaType ||
|
||||
!this.dataManager
|
||||
!this.cameraManager
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.view.target) {
|
||||
if (mediaType === 'recordings') {
|
||||
if (!this.view.query) {
|
||||
if (this.view.is('recordings')) {
|
||||
changeViewToRecentRecordingForCameraAndDependents(
|
||||
this,
|
||||
this.hass,
|
||||
this.dataManager,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
);
|
||||
} else {
|
||||
const mediaType = this.view.is('snapshots')
|
||||
? 'snapshots'
|
||||
: this.view.is('clips')
|
||||
? 'clips'
|
||||
: null;
|
||||
changeViewToRecentEventsForCameraAndDependents(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
targetView: 'recordings',
|
||||
...(mediaType && { mediaType: mediaType }),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const browseMediaQueryParameters =
|
||||
getFullDependentBrowseMediaQueryParametersOrDispatchError(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameras,
|
||||
this.view.camera,
|
||||
mediaType,
|
||||
);
|
||||
|
||||
if (!browseMediaQueryParameters) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchLatestMediaAndDispatchViewChange(
|
||||
this,
|
||||
this.hass,
|
||||
this.view,
|
||||
browseMediaQueryParameters,
|
||||
);
|
||||
}
|
||||
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
||||
}
|
||||
|
||||
return html`
|
||||
<frigate-card-gallery-core
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.galleryConfig=${this.galleryConfig}
|
||||
.cameras=${this.cameras}
|
||||
<frigate-card-surround-basic
|
||||
.drawerIcons=${{
|
||||
...(this.galleryConfig &&
|
||||
this.galleryConfig.controls.filter.mode !== 'none' && {
|
||||
[this.galleryConfig.controls.filter.mode]: GALLERY_MEDIA_FILTER_MENU_ICONS,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
</frigate-card-gallery-core>
|
||||
${this.galleryConfig && this.galleryConfig.controls.filter.mode !== 'none'
|
||||
? html` <frigate-card-media-filter
|
||||
.hass=${this.hass}
|
||||
.cameras=${this.cameras}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.view=${this.view}
|
||||
.mediaLimit=${GALLERY_MEDIA_CHUNK_SIZE}
|
||||
slot=${this.galleryConfig.controls.filter.mode}
|
||||
>
|
||||
</frigate-card-media-filter>`
|
||||
: ''}
|
||||
<frigate-card-gallery-core
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.galleryConfig=${this.galleryConfig}
|
||||
.cameras=${this.cameras}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
</frigate-card-gallery-core>
|
||||
</frigate-card-surround-basic>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -153,11 +168,25 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
protected _intersectionObserver: IntersectionObserver;
|
||||
protected _resizeObserver: ResizeObserver;
|
||||
protected _refLoader: Ref<HTMLElement> = createRef();
|
||||
|
||||
@state()
|
||||
protected _showExtensionLoader = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._resizeObserver = new ResizeObserver(this._resizeHandler.bind(this));
|
||||
this._intersectionObserver = new IntersectionObserver(
|
||||
this._intersectionHandler.bind(this),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,6 +195,10 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._resizeObserver.observe(this);
|
||||
|
||||
// Request update in order to ensure the intersection observer reconnects
|
||||
// with the loader sentinel.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,6 +206,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
*/
|
||||
disconnectedCallback(): void {
|
||||
this._resizeObserver.disconnect();
|
||||
this._intersectionObserver.disconnect();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
@@ -201,16 +235,55 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
this._setColumnCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the back arrow should be displayed.
|
||||
* @returns `true` if the back arrow should be displayed, `false` otherwise.
|
||||
*/
|
||||
protected _showBackArrow(): boolean {
|
||||
return (
|
||||
!!this.view?.context?.gallery?.previous &&
|
||||
!!this.view.context.gallery.previous.target &&
|
||||
this.view.context.gallery.previous.view === this.view.view
|
||||
);
|
||||
protected async _intersectionHandler(
|
||||
entries: IntersectionObserverEntry[],
|
||||
): Promise<void> {
|
||||
if (!this.cameraManager || !this.hass || !this.view) {
|
||||
return;
|
||||
}
|
||||
if (entries.every((entry) => !entry.isIntersecting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._showExtensionLoader = false;
|
||||
|
||||
const query = this.view?.query;
|
||||
const rawQueries = query?.getQueries() ?? null;
|
||||
const existingMedia = this.view.queryResults?.getResults();
|
||||
if (!query || !rawQueries || !existingMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
let extension: ExtendedMediaQueryResult<MediaQuery> | null;
|
||||
try {
|
||||
extension = await this.cameraManager.extendMediaQueries<MediaQuery>(
|
||||
this.hass,
|
||||
rawQueries,
|
||||
existingMedia,
|
||||
'earlier',
|
||||
GALLERY_MEDIA_CHUNK_SIZE,
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (extension) {
|
||||
const newMediaQueries = MediaQueriesClassifier.areEventQueries(query)
|
||||
? new EventMediaQueries(extension.queries as EventQuery[])
|
||||
: MediaQueriesClassifier.areRecordingQueries(query)
|
||||
? new RecordingMediaQueries(extension.queries as RecordingQuery[])
|
||||
: null;
|
||||
|
||||
if (newMediaQueries) {
|
||||
this.view
|
||||
?.evolve({
|
||||
query: newMediaQueries,
|
||||
queryResults: new MediaQueriesResults(extension.results),
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,6 +305,9 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (changedProps.has('view')) {
|
||||
this._showExtensionLoader = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,11 +315,12 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
const results = this.view?.queryResults?.getResults();
|
||||
|
||||
if (
|
||||
!results ||
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
!this.view.isGalleryView() ||
|
||||
!this.cameras
|
||||
) {
|
||||
@@ -251,80 +328,49 @@ export class FrigateCardGalleryCore extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
${this._showBackArrow()
|
||||
? html` <ha-card
|
||||
@click=${(ev) => {
|
||||
if (this.view && this.view.context?.gallery?.previous) {
|
||||
this.view.context.gallery.previous.dispatchChangeEvent(this);
|
||||
${results.map(
|
||||
(media, index) =>
|
||||
html`<frigate-card-thumbnail
|
||||
.hass=${this.hass}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.media=${media}
|
||||
.cameraConfig=${this.cameras?.get(media.getCameraID())}
|
||||
.view=${this.view}
|
||||
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
|
||||
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_favorite_control}
|
||||
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_timeline_control}
|
||||
@click=${(ev: Event) => {
|
||||
if (this.view) {
|
||||
this.view
|
||||
.evolve({
|
||||
view: 'media',
|
||||
queryResults: this.view.queryResults?.clone().selectResult(index),
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
outlined=""
|
||||
>
|
||||
<ha-icon .icon=${'mdi:arrow-left'}></ha-icon>
|
||||
</ha-card>`
|
||||
: ''}
|
||||
${this.view.target.children.map(
|
||||
(child, index) =>
|
||||
html`
|
||||
${child.can_expand
|
||||
? html`
|
||||
<ha-card
|
||||
@click=${(ev) => {
|
||||
if (this.hass && this.view) {
|
||||
fetchChildMediaAndDispatchViewChange(
|
||||
this,
|
||||
this.hass,
|
||||
this.view,
|
||||
child,
|
||||
{
|
||||
gallery: {
|
||||
previous: this.view,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
outlined=""
|
||||
>
|
||||
<div>${child.title}</div>
|
||||
</ha-card>
|
||||
`
|
||||
: html`<frigate-card-thumbnail
|
||||
.view=${this.view}
|
||||
.target=${this.view?.target ?? null}
|
||||
.childIndex=${index}
|
||||
.hass=${this.hass}
|
||||
.cameraConfig=${child.frigate?.cameraID
|
||||
? this.cameras?.get(child.frigate.cameraID)
|
||||
: undefined}
|
||||
?details=${!!this.galleryConfig?.controls.thumbnails.show_details}
|
||||
?show_favorite_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_favorite_control}
|
||||
?show_timeline_control=${!!this.galleryConfig?.controls.thumbnails
|
||||
.show_timeline_control}
|
||||
@click=${(ev: Event) => {
|
||||
if (this.view) {
|
||||
const targetView = this.view.getViewerViewForGalleryView();
|
||||
if (targetView) {
|
||||
this.view
|
||||
.evolve({
|
||||
view: targetView,
|
||||
childIndex: index,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
}
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
>
|
||||
</frigate-card-thumbnail>`}
|
||||
`,
|
||||
</frigate-card-thumbnail>`,
|
||||
)}
|
||||
${this._showExtensionLoader
|
||||
? html`${renderProgressIndicator({
|
||||
cardWideConfig: this.cardWideConfig,
|
||||
componentRef: this._refLoader,
|
||||
})}`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
|
||||
public updated(): void {
|
||||
if (this._refLoader.value) {
|
||||
this._intersectionObserver.disconnect();
|
||||
this._intersectionObserver.observe(this._refLoader.value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get styles.
|
||||
*/
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
createMediaLoadedInfo,
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
} from '../utils/media-info.js';
|
||||
import { View } from '../view.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { dispatchErrorMessageEvent } from './message.js';
|
||||
import { contentsChanged } from '../utils/basic.js';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
|
||||
+78
-91
@@ -32,13 +32,11 @@ import {
|
||||
} from '../../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { contentsChanged } from '../../utils/basic.js';
|
||||
import { getCameraIcon, getCameraTitle } from '../../utils/camera.js';
|
||||
import { getFullDependentBrowseMediaQueryParameters } from '../../utils/ha/browse-media.js';
|
||||
import {
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
dispatchMediaUnloadedEvent,
|
||||
} from '../../utils/media-info.js';
|
||||
import { dispatchViewContextChangeEvent, View } from '../../view.js';
|
||||
import { dispatchViewContextChangeEvent, View } from '../../view/view.js';
|
||||
import { AutoMediaPlugin } from './../embla-plugins/automedia.js';
|
||||
import { Lazyload } from './../embla-plugins/lazyload.js';
|
||||
import {
|
||||
@@ -49,10 +47,10 @@ import {
|
||||
import '../next-prev-control.js';
|
||||
import '../title-control.js';
|
||||
import '../surround.js';
|
||||
import { EmblaCarouselPlugins } from '../carousel.js';
|
||||
import { CarouselSelect, EmblaCarouselPlugins } from '../carousel.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js';
|
||||
import { DataManager } from '../../utils/data-manager.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
@@ -88,7 +86,7 @@ export const getStateObjOrDispatchError = (
|
||||
if (stateObj.state === 'unavailable') {
|
||||
dispatchMessageEvent(element, localize('error.live_camera_unavailable'), 'info', {
|
||||
icon: 'mdi:connection',
|
||||
context: getCameraTitle(hass, cameraConfig),
|
||||
context: cameraConfig,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
@@ -116,7 +114,7 @@ export class FrigateCardLive extends LitElement {
|
||||
public liveOverrides?: LiveOverrides;
|
||||
|
||||
@property({ attribute: false })
|
||||
public dataManager?: DataManager;
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
@@ -212,16 +210,6 @@ export class FrigateCardLive extends LitElement {
|
||||
this.conditionState,
|
||||
) as LiveConfig;
|
||||
|
||||
// Does not use getFullDependentBrowseMediaQueryParametersOrDispatchError to
|
||||
// ensure that non-Frigate cameras will work in live view (they will not
|
||||
// have a Frigate camera name).
|
||||
const browseMediaParams = getFullDependentBrowseMediaQueryParameters(
|
||||
this.hass,
|
||||
this.cameras,
|
||||
this.view.camera,
|
||||
config.controls.thumbnails.media,
|
||||
);
|
||||
|
||||
// Notes:
|
||||
// - See use of liveConfig and not config below -- the carousel will
|
||||
// independently override the liveConfig to reflect the camera in the
|
||||
@@ -238,12 +226,11 @@ export class FrigateCardLive extends LitElement {
|
||||
html`<frigate-card-surround
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.fetch=${true}
|
||||
.fetchMedia=${config.controls.thumbnails.media}
|
||||
.thumbnailConfig=${config.controls.thumbnails}
|
||||
.timelineConfig=${config.controls.timeline}
|
||||
.browseMediaParams=${browseMediaParams ?? undefined}
|
||||
.cameras=${this.cameras}
|
||||
.dataManager=${this.dataManager}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.inBackground=${this._inBackground}
|
||||
@frigate-card:message=${(ev: CustomEvent<Message>) => {
|
||||
this._renderKey++;
|
||||
@@ -273,6 +260,7 @@ export class FrigateCardLive extends LitElement {
|
||||
.conditionState=${this.conditionState}
|
||||
.liveOverrides=${this.liveOverrides}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.cameraManager=${this.cameraManager}
|
||||
>
|
||||
</frigate-card-live-carousel>
|
||||
</frigate-card-surround>`,
|
||||
@@ -316,6 +304,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
// Index between camera name and slide number.
|
||||
protected _cameraToSlide: Record<string, number> = {};
|
||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||
@@ -328,30 +319,8 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
super.updated(changedProperties);
|
||||
|
||||
const frigateCardMediaCarousel = this._refMediaCarousel.value;
|
||||
const frigateCardCarousel = frigateCardMediaCarousel?.frigateCardCarousel();
|
||||
|
||||
if (changedProperties.has('view')) {
|
||||
const oldView = changedProperties.get('view') as View | undefined;
|
||||
if (
|
||||
frigateCardCarousel &&
|
||||
this.view?.camera &&
|
||||
(!oldView || this.view?.camera !== oldView.camera)
|
||||
) {
|
||||
const slide: number | undefined = this._cameraToSlide[this.view.camera];
|
||||
if (
|
||||
slide !== undefined &&
|
||||
slide !== frigateCardCarousel.getCarouselSelected()?.index
|
||||
) {
|
||||
frigateCardCarousel.carouselScrollTo(slide);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
frigateCardMediaCarousel &&
|
||||
frigateCardCarousel &&
|
||||
changedProperties.has('inBackground')
|
||||
) {
|
||||
if (frigateCardMediaCarousel && changedProperties.has('inBackground')) {
|
||||
// If this has changed to be in the background (i.e. preloaded but not
|
||||
// visible) take the appropriate play/pause/mute/unmute actions.
|
||||
if (this.inBackground) {
|
||||
@@ -375,16 +344,19 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
protected _getSelectedCameraIndex(): number {
|
||||
if (!this.cameras || !this.view) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(0, Array.from(this.cameras.keys()).indexOf(this.view.camera));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Embla options to use.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
*/
|
||||
protected _getOptions(): EmblaOptionsType {
|
||||
return {
|
||||
startIndex:
|
||||
this.cameras && this.view
|
||||
? Math.max(0, Array.from(this.cameras.keys()).indexOf(this.view.camera))
|
||||
: 0,
|
||||
draggable: this.liveConfig?.draggable,
|
||||
loop: true,
|
||||
};
|
||||
@@ -472,26 +444,26 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
/**
|
||||
* Handle the user selecting a new slide in the carousel.
|
||||
*/
|
||||
protected _setViewHandler(): void {
|
||||
const selectedCameraIndex = this._refMediaCarousel.value
|
||||
?.frigateCardCarousel()
|
||||
?.getCarouselSelected()?.index;
|
||||
if (selectedCameraIndex === undefined || !this.view || !this.cameras) {
|
||||
return;
|
||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
||||
if (this.cameras && ev.detail.index !== this._getSelectedCameraIndex()) {
|
||||
this._setViewCameraID(Array.from(this.cameras.keys())[ev.detail.index]);
|
||||
}
|
||||
}
|
||||
|
||||
this.view
|
||||
.evolve({
|
||||
camera: Array.from(this.cameras.keys())[selectedCameraIndex],
|
||||
|
||||
// Reset the target.
|
||||
target: null,
|
||||
childIndex: null,
|
||||
})
|
||||
// Don't yet fetch thumbnails (they will be fetched when the carousel
|
||||
// settles).
|
||||
.mergeInContext({ thumbnails: { fetch: false } })
|
||||
.dispatchChangeEvent(this);
|
||||
protected _setViewCameraID(cameraID?: string | null): void {
|
||||
if (cameraID) {
|
||||
this.view
|
||||
?.evolve({
|
||||
camera: cameraID,
|
||||
// Reset the query and query results.
|
||||
query: null,
|
||||
queryResults: null,
|
||||
})
|
||||
// Don't yet fetch thumbnails (they will be fetched when the carousel
|
||||
// settles).
|
||||
.mergeInContext({ thumbnails: { fetch: false } })
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -521,7 +493,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
cameraConfig: CameraConfig,
|
||||
slideIndex: number,
|
||||
): TemplateResult | void {
|
||||
if (!this.liveConfig) {
|
||||
if (!this.liveConfig || !this.hass || !this.cameraManager) {
|
||||
return;
|
||||
}
|
||||
// The conditionState object contains the currently live camera, which (in
|
||||
@@ -538,12 +510,14 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
conditionState,
|
||||
) as LiveConfig;
|
||||
|
||||
const cameraMetadata = this.cameraManager.getCameraMetadata(this.hass, cameraConfig);
|
||||
|
||||
return html`
|
||||
<div class="embla__slide">
|
||||
<frigate-card-live-provider
|
||||
?disabled=${this.liveConfig.lazy_load}
|
||||
.cameraConfig=${cameraConfig}
|
||||
.label=${getCameraTitle(this.hass, cameraConfig)}
|
||||
.label=${cameraMetadata?.title ?? ''}
|
||||
.liveConfig=${config}
|
||||
.hass=${this.hass}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
@@ -559,7 +533,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected _getCameraNeighbors(): [CameraConfig | null, CameraConfig | null] {
|
||||
protected _getCameraIDsOfNeighbors(): [string | null, string | null] {
|
||||
if (!this.cameras || !this.view || !this.hass) {
|
||||
return [null, null];
|
||||
}
|
||||
@@ -570,15 +544,10 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
const prev =
|
||||
this.cameras.get(
|
||||
keys[currentIndex > 0 ? currentIndex - 1 : this.cameras.size - 1],
|
||||
) ?? null;
|
||||
const next =
|
||||
this.cameras.get(
|
||||
keys[currentIndex + 1 < this.cameras.size ? currentIndex + 1 : 0],
|
||||
) ?? null;
|
||||
return [prev, next];
|
||||
return [
|
||||
keys[currentIndex > 0 ? currentIndex - 1 : this.cameras.size - 1],
|
||||
keys[currentIndex + 1 < this.cameras.size ? currentIndex + 1 : 0],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -588,7 +557,14 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
protected render(): TemplateResult | void {
|
||||
const [slides, cameraToSlide] = this._getSlides();
|
||||
this._cameraToSlide = cameraToSlide;
|
||||
if (!slides.length || !this.liveConfig || !this.cameras || !this.view) {
|
||||
if (
|
||||
!slides.length ||
|
||||
!this.liveConfig ||
|
||||
!this.cameras ||
|
||||
!this.view ||
|
||||
!this.hass ||
|
||||
!this.cameraManager
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -598,8 +574,20 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
this.conditionState,
|
||||
) as LiveConfig;
|
||||
|
||||
const [prev, next] = this._getCameraNeighbors();
|
||||
const title = getCameraTitle(this.hass, this.cameras.get(this.view.camera));
|
||||
const [prevID, nextID] = this._getCameraIDsOfNeighbors();
|
||||
|
||||
const cameraMetadataPrevious = prevID ? this.cameraManager.getCameraMetadata(
|
||||
this.hass,
|
||||
this.cameras.get(prevID),
|
||||
) : null;
|
||||
const cameraMetadataCurrent = this.cameraManager.getCameraMetadata(
|
||||
this.hass,
|
||||
this.cameras.get(this.view.camera),
|
||||
);
|
||||
const cameraMetadataNext = nextID ? this.cameraManager.getCameraMetadata(
|
||||
this.hass,
|
||||
this.cameras.get(nextID),
|
||||
) : null;
|
||||
|
||||
// Notes on the below:
|
||||
// - guard() is used to avoid reseting the carousel unless the
|
||||
@@ -622,8 +610,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
[this.cameras, this.liveConfig],
|
||||
this._getPlugins.bind(this),
|
||||
) as EmblaCarouselPlugins}
|
||||
.label="${title ? `${localize('common.live')}: ${title}` : ''}"
|
||||
.label="${cameraMetadataCurrent ? `${localize('common.live')}: ${cameraMetadataCurrent.title}` : ''}"
|
||||
.titlePopupConfig=${config.controls.title}
|
||||
.selected=${this._getSelectedCameraIndex()}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||
@frigate-card:carousel:settle=${() => {
|
||||
@@ -636,13 +625,11 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.direction=${'previous'}
|
||||
.controlConfig=${config.controls.next_previous}
|
||||
.label=${getCameraTitle(this.hass, prev)}
|
||||
.icon=${getCameraIcon(this.hass, prev)}
|
||||
?disabled=${prev == null}
|
||||
.label=${cameraMetadataPrevious?.title ?? ''}
|
||||
.icon=${cameraMetadataPrevious?.icon}
|
||||
?disabled=${prevID === null}
|
||||
@click=${(ev) => {
|
||||
this._refMediaCarousel.value
|
||||
?.frigateCardCarousel()
|
||||
?.carouselScrollPrevious();
|
||||
this._setViewCameraID(prevID);
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
>
|
||||
@@ -653,11 +640,11 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.direction=${'next'}
|
||||
.controlConfig=${config.controls.next_previous}
|
||||
.label=${getCameraTitle(this.hass, next)}
|
||||
.icon=${getCameraIcon(this.hass, next)}
|
||||
?disabled=${next == null}
|
||||
.label=${cameraMetadataNext?.title ?? ''}
|
||||
.icon=${cameraMetadataNext?.icon}
|
||||
?disabled=${nextID === null}
|
||||
@click=${(ev) => {
|
||||
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
|
||||
this._setViewCameraID(nextID);
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -28,12 +28,12 @@ const getEmptyImageSrc = (width: number, height: number) =>
|
||||
`data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`;
|
||||
export const IMG_EMPTY = getEmptyImageSrc(16, 9);
|
||||
|
||||
export interface CarouselMediaLoadedInfo {
|
||||
interface CarouselMediaLoadedInfo {
|
||||
slide: number;
|
||||
mediaLoadedInfo: MediaLoadedInfo;
|
||||
}
|
||||
|
||||
export interface CarouselMediaUnloadedInfo {
|
||||
interface CarouselMediaUnloadedInfo {
|
||||
slide: number;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,9 @@ export class FrigateCardMediaCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public carouselPlugins?: EmblaCarouselPlugins;
|
||||
|
||||
@property({ attribute: false, type: Number })
|
||||
public selected = 0;
|
||||
|
||||
@property({ attribute: true })
|
||||
public transitionEffect?: TransitionEffect;
|
||||
|
||||
@@ -418,6 +421,7 @@ export class FrigateCardMediaCarousel extends LitElement {
|
||||
|
||||
return html` <frigate-card-carousel
|
||||
${ref(this._refCarousel)}
|
||||
.selected=${this.selected ?? 0}
|
||||
.carouselOptions=${this.carouselOptions}
|
||||
.carouselPlugins=${this.carouselPlugins}
|
||||
transitionEffect=${ifDefined(this.transitionEffect)}
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
import {
|
||||
CSSResultGroup,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { DateRange } from '../camera-manager/range';
|
||||
import { localize } from '../localize/localize';
|
||||
import mediaFilterStyle from '../scss/media-filter.scss';
|
||||
import { CameraConfig } from '../types';
|
||||
import { createViewForEvents, createViewForRecordings } from '../utils/media-to-view.js';
|
||||
import { errorToConsole, formatDate, prettifyTitle } from '../utils/basic';
|
||||
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
|
||||
import './select';
|
||||
import { FrigateCardSelect, SelectOption, SelectValues } from './select';
|
||||
import uniqWith from 'lodash-es/uniqWith';
|
||||
import sub from 'date-fns/sub';
|
||||
import endOfDay from 'date-fns/endOfDay';
|
||||
import endOfYesterday from 'date-fns/endOfYesterday';
|
||||
import endOfToday from 'date-fns/esm/endOfToday';
|
||||
import startOfToday from 'date-fns/esm/startOfToday';
|
||||
import startOfDay from 'date-fns/startOfDay';
|
||||
import startOfYesterday from 'date-fns/startOfYesterday';
|
||||
import parse from 'date-fns/parse';
|
||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||
import { View } from '../view/view';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import {
|
||||
EventQuery,
|
||||
MediaMetadata,
|
||||
QueryType,
|
||||
RecordingQuery,
|
||||
} from '../camera-manager/types';
|
||||
import format from 'date-fns/format';
|
||||
import endOfMonth from 'date-fns/endOfMonth';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { EventMediaQueries, RecordingMediaQueries } from '../view/media-queries';
|
||||
import './select.js';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
|
||||
interface MediaFilterCoreDefaults {
|
||||
mediaType?: MediaFilterMediaType;
|
||||
cameraIDs?: string[];
|
||||
what?: string[];
|
||||
where?: string[];
|
||||
favorite?: MediaFilterCoreFavoriteSelection;
|
||||
when?: string;
|
||||
}
|
||||
|
||||
export enum MediaFilterCoreFavoriteSelection {
|
||||
Favorite = 'favorite',
|
||||
NotFavorite = 'not-favorite',
|
||||
}
|
||||
|
||||
export enum MediaFilterCoreWhen {
|
||||
Today = 'today',
|
||||
Yesterday = 'yesterday',
|
||||
PastWeek = 'past-week',
|
||||
PastMonth = 'past-month',
|
||||
}
|
||||
|
||||
export enum MediaFilterMediaType {
|
||||
Clips = 'clips',
|
||||
Snapshots = 'snapshots',
|
||||
Recordings = 'recordings',
|
||||
}
|
||||
|
||||
@customElement('frigate-card-media-filter')
|
||||
class FrigateCardMediaFilter extends ScopedRegistryHost(LitElement) {
|
||||
@property({ attribute: false })
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public view?: View;
|
||||
|
||||
@property({ attribute: false })
|
||||
public mediaLimit?: number;
|
||||
|
||||
static elementDefinitions = {
|
||||
'frigate-card-select': FrigateCardSelect,
|
||||
};
|
||||
|
||||
protected _mediaMetadataController?: MediaMetadataController;
|
||||
|
||||
protected _mediaTypeOptions: SelectOption[];
|
||||
protected _cameraOptions?: SelectOption[];
|
||||
protected _whenOptions?: SelectOption[];
|
||||
protected _favoriteOptions: SelectOption[];
|
||||
|
||||
protected _defaults: MediaFilterCoreDefaults | null = null;
|
||||
|
||||
protected _refMediaType: Ref<FrigateCardSelect> = createRef();
|
||||
protected _refCamera: Ref<FrigateCardSelect> = createRef();
|
||||
protected _refWhen: Ref<FrigateCardSelect> = createRef();
|
||||
protected _refWhat: Ref<FrigateCardSelect> = createRef();
|
||||
protected _refWhere: Ref<FrigateCardSelect> = createRef();
|
||||
protected _refFavorite: Ref<FrigateCardSelect> = createRef();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._favoriteOptions = [
|
||||
{
|
||||
value: MediaFilterCoreFavoriteSelection.Favorite,
|
||||
label: localize('media_filter.favorite'),
|
||||
},
|
||||
{
|
||||
value: MediaFilterCoreFavoriteSelection.NotFavorite,
|
||||
label: localize('media_filter.not_favorite'),
|
||||
},
|
||||
];
|
||||
this._mediaTypeOptions = [
|
||||
{
|
||||
value: MediaFilterMediaType.Clips,
|
||||
label: localize('media_filter.media_types.clips'),
|
||||
},
|
||||
{
|
||||
value: MediaFilterMediaType.Snapshots,
|
||||
label: localize('media_filter.media_types.snapshots'),
|
||||
},
|
||||
{
|
||||
value: MediaFilterMediaType.Recordings,
|
||||
label: localize('media_filter.media_types.recordings'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
protected _stringToDateRange(input: string): DateRange {
|
||||
const dates = input.split(',');
|
||||
return {
|
||||
start: parse(dates[0], 'yyyy-MM-dd', new Date()),
|
||||
end: parse(dates[1], 'yyyy-MM-dd', new Date()),
|
||||
};
|
||||
}
|
||||
|
||||
protected _dateRangeToString(when: DateRange): string {
|
||||
return `${formatDate(when.start)},${formatDate(when.end)}`;
|
||||
}
|
||||
|
||||
protected _getWhen(): DateRange | null {
|
||||
const value = this._refWhen.value?.value;
|
||||
if (!value || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const now = new Date();
|
||||
switch (value) {
|
||||
case MediaFilterCoreWhen.Today:
|
||||
return { start: startOfToday(), end: endOfToday() };
|
||||
case MediaFilterCoreWhen.Yesterday:
|
||||
return { start: startOfYesterday(), end: endOfYesterday() };
|
||||
case MediaFilterCoreWhen.PastWeek:
|
||||
return { start: startOfDay(sub(now, { days: 7 })), end: endOfDay(now) };
|
||||
case MediaFilterCoreWhen.PastMonth:
|
||||
return { start: startOfDay(sub(now, { months: 1 })), end: endOfDay(now) };
|
||||
default:
|
||||
return this._stringToDateRange(value);
|
||||
}
|
||||
}
|
||||
|
||||
protected async _valueChangedHandler(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_ev: CustomEvent<{ value: unknown }>,
|
||||
): Promise<void> {
|
||||
if (!this.hass || !this.cameras || !this.cameraManager || !this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getArrayValueAsSet = (val?: SelectValues): Set<string> | 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 null;
|
||||
};
|
||||
|
||||
const cameraIDs =
|
||||
getArrayValueAsSet(this._refCamera.value?.value) ?? new Set(this.cameras.keys());
|
||||
const mediaType = this._refMediaType.value?.value as
|
||||
| MediaFilterMediaType
|
||||
| undefined;
|
||||
const when = this._getWhen();
|
||||
const favorite = this._refFavorite.value?.value
|
||||
? this._refFavorite.value.value === 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.
|
||||
if (
|
||||
mediaType === MediaFilterMediaType.Clips ||
|
||||
mediaType === MediaFilterMediaType.Snapshots
|
||||
) {
|
||||
const where = getArrayValueAsSet(this._refWhere.value?.value);
|
||||
const what = getArrayValueAsSet(this._refWhat.value?.value);
|
||||
|
||||
const queries: EventQuery[] = [
|
||||
{
|
||||
type: QueryType.Event,
|
||||
cameraIDs: cameraIDs,
|
||||
...(what && { what: what }),
|
||||
...(where && { where: where }),
|
||||
...(favorite !== null && { favorite: favorite }),
|
||||
...(when && { start: when.start, end: when.end }),
|
||||
...(this.mediaLimit && { limit: this.mediaLimit }),
|
||||
...(mediaType === MediaFilterMediaType.Clips && { hasClip: true }),
|
||||
...(mediaType === MediaFilterMediaType.Snapshots && {
|
||||
hasSnapshot: true,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
(
|
||||
await createViewForEvents(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
query: new EventMediaQueries(queries),
|
||||
|
||||
// See 'A note on views' above for these two arguments.
|
||||
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
|
||||
targetView: mediaType === MediaFilterMediaType.Clips ? 'clips' : 'snapshots',
|
||||
},
|
||||
)
|
||||
)?.dispatchChangeEvent(this);
|
||||
} else if (mediaType === MediaFilterMediaType.Recordings) {
|
||||
const query: RecordingQuery = {
|
||||
type: QueryType.Recording,
|
||||
cameraIDs: cameraIDs,
|
||||
...(when && { start: when.start, end: when.end }),
|
||||
};
|
||||
|
||||
(
|
||||
await createViewForRecordings(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
query: new RecordingMediaQueries([query]),
|
||||
|
||||
// See 'A note on views' above for these two arguments.
|
||||
...(cameraIDs.size === 1 && { targetCameraID: [...cameraIDs][0] }),
|
||||
targetView: 'recordings',
|
||||
},
|
||||
)
|
||||
)?.dispatchChangeEvent(this);
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('cameras') && this.cameras) {
|
||||
this._cameraOptions = Array.from(this.cameras.entries()).map(
|
||||
([cameraID, cameraConfig]) => ({
|
||||
value: cameraID,
|
||||
label: this.hass
|
||||
? this.cameraManager?.getCameraMetadata(this.hass, cameraConfig)?.title ?? ''
|
||||
: '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (changedProps.has('cameraManager') && this.hass && this.cameraManager) {
|
||||
this._mediaMetadataController = new MediaMetadataController(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
);
|
||||
}
|
||||
|
||||
// Relative time based options are not pre-computed here to ensure relative
|
||||
// dates (e.g. 'today') are always calculated when activated not when
|
||||
// rendered.
|
||||
this._whenOptions = [
|
||||
{
|
||||
value: MediaFilterCoreWhen.Today,
|
||||
label: localize('media_filter.whens.today'),
|
||||
},
|
||||
{
|
||||
value: MediaFilterCoreWhen.Yesterday,
|
||||
label: localize('media_filter.whens.yesterday'),
|
||||
},
|
||||
{
|
||||
value: MediaFilterCoreWhen.PastWeek,
|
||||
label: localize('media_filter.whens.past_week'),
|
||||
},
|
||||
{
|
||||
value: MediaFilterCoreWhen.PastMonth,
|
||||
label: localize('media_filter.whens.past_month'),
|
||||
},
|
||||
...(this._mediaMetadataController?.whenOptions ?? []),
|
||||
];
|
||||
|
||||
if (changedProps.has('view')) {
|
||||
const newDefaults = this._getDefaultsFromView();
|
||||
if (!isEqual(newDefaults, this._defaults)) {
|
||||
this._defaults = newDefaults;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected _getDefaultsFromView(): MediaFilterCoreDefaults | null {
|
||||
const queries = this.view?.query?.getQueries();
|
||||
if (!this.view || !queries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let mediaType: MediaFilterMediaType | undefined;
|
||||
let cameraIDs: string[] | undefined;
|
||||
let what: string[] | undefined;
|
||||
let where: string[] | undefined;
|
||||
let favorite: MediaFilterCoreFavoriteSelection | undefined;
|
||||
|
||||
const cameraIDSets = uniqWith(
|
||||
queries.map((query) => query.cameraIDs),
|
||||
isEqual,
|
||||
);
|
||||
// Special note: If all cameras are selected, this is the same as no
|
||||
// selector at all.
|
||||
if (cameraIDSets.length === 1 && queries[0].cameraIDs.size !== this.cameras?.size) {
|
||||
cameraIDs = [...queries[0].cameraIDs];
|
||||
}
|
||||
|
||||
const favoriteValues = uniqWith(
|
||||
queries.map((query) => query.favorite),
|
||||
isEqual,
|
||||
);
|
||||
if (favoriteValues.length === 1 && queries[0].favorite !== undefined) {
|
||||
favorite = queries[0].favorite
|
||||
? MediaFilterCoreFavoriteSelection.Favorite
|
||||
: MediaFilterCoreFavoriteSelection.NotFavorite;
|
||||
}
|
||||
|
||||
if (MediaQueriesClassifier.areEventQueries(this.view.query)) {
|
||||
const queries = this.view.query.getQueries();
|
||||
if (!queries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const whatSets = uniqWith(
|
||||
queries.map((query) => query.what),
|
||||
isEqual,
|
||||
);
|
||||
if (whatSets.length === 1 && queries[0].what?.size) {
|
||||
what = [...queries[0].what];
|
||||
}
|
||||
const whereSets = uniqWith(
|
||||
queries.map((query) => query.where),
|
||||
isEqual,
|
||||
);
|
||||
if (whereSets.length === 1 && queries[0].where?.size) {
|
||||
where = [...queries[0].where];
|
||||
}
|
||||
} else if (MediaQueriesClassifier.areRecordingQueries(this.view.query)) {
|
||||
mediaType = MediaFilterMediaType.Recordings;
|
||||
}
|
||||
|
||||
return {
|
||||
...(mediaType && { mediaType: mediaType }),
|
||||
...(cameraIDs && { cameraIDs: cameraIDs }),
|
||||
...(what && { what: what }),
|
||||
...(where && { where: where }),
|
||||
...(favorite !== undefined && { favorite: favorite }),
|
||||
};
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this._mediaMetadataController) {
|
||||
return;
|
||||
}
|
||||
|
||||
const areEvents = !!(
|
||||
this.view?.query && MediaQueriesClassifier.areEventQueries(this.view.query)
|
||||
);
|
||||
const areRecordings = !!(
|
||||
this.view?.query && MediaQueriesClassifier.areRecordingQueries(this.view.query)
|
||||
);
|
||||
const managerCapabilities = this.cameraManager?.getCapabilities();
|
||||
|
||||
// Which media controls are shown depends on the view.
|
||||
const showFavoriteControl = areEvents
|
||||
? !!managerCapabilities?.canFavoriteEvents
|
||||
: areRecordings
|
||||
? !!managerCapabilities?.canFavoriteRecordings
|
||||
: false;
|
||||
|
||||
return html` <frigate-card-select
|
||||
${ref(this._refMediaType)}
|
||||
label=${localize('media_filter.media_type')}
|
||||
placeholder=${localize('media_filter.select_media_type')}
|
||||
.options=${this._mediaTypeOptions}
|
||||
.value=${this._defaults?.mediaType}
|
||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-select>
|
||||
<frigate-card-select
|
||||
${ref(this._refWhen)}
|
||||
.label=${localize('media_filter.when')}
|
||||
placeholder=${localize('media_filter.select_when')}
|
||||
.options=${this._whenOptions}
|
||||
.value=${this._defaults?.when}
|
||||
clearable
|
||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-select>
|
||||
<frigate-card-select
|
||||
${ref(this._refCamera)}
|
||||
.label=${localize('media_filter.camera')}
|
||||
placeholder=${localize('media_filter.select_camera')}
|
||||
.options=${this._cameraOptions}
|
||||
.value=${this._defaults?.cameraIDs}
|
||||
clearable
|
||||
multiple
|
||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-select>
|
||||
${areEvents && this._mediaMetadataController.whatOptions.length
|
||||
? html` <frigate-card-select
|
||||
${ref(this._refWhat)}
|
||||
label=${localize('media_filter.what')}
|
||||
placeholder=${localize('media_filter.select_what')}
|
||||
clearable
|
||||
multiple
|
||||
.options=${this._mediaMetadataController.whatOptions}
|
||||
.value=${this._defaults?.what}
|
||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-select>`
|
||||
: ''}
|
||||
${areEvents && this._mediaMetadataController.whereOptions.length
|
||||
? html` <frigate-card-select
|
||||
${ref(this._refWhere)}
|
||||
label=${localize('media_filter.where')}
|
||||
placeholder=${localize('media_filter.select_where')}
|
||||
clearable
|
||||
multiple
|
||||
.options=${this._mediaMetadataController.whereOptions}
|
||||
.value=${this._defaults?.where}
|
||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-select>`
|
||||
: ''}
|
||||
${showFavoriteControl
|
||||
? html`
|
||||
<frigate-card-select
|
||||
${ref(this._refFavorite)}
|
||||
label=${localize('media_filter.favorite')}
|
||||
placeholder=${localize('media_filter.select_favorite')}
|
||||
.options=${this._favoriteOptions}
|
||||
.value=${this._defaults?.favorite}
|
||||
clearable
|
||||
@frigate-card:select:change=${this._valueChangedHandler.bind(this)}
|
||||
>
|
||||
</frigate-card-select>
|
||||
`
|
||||
: ''}`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(mediaFilterStyle);
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaMetadataController implements ReactiveController {
|
||||
protected _host: ReactiveControllerHost;
|
||||
protected _hass: HomeAssistant;
|
||||
protected _cameraManager: CameraManager;
|
||||
|
||||
public whenOptions: SelectOption[] = [];
|
||||
public whatOptions: SelectOption[] = [];
|
||||
public whereOptions: SelectOption[] = [];
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost,
|
||||
hass: HomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
) {
|
||||
this._host = host;
|
||||
this._hass = hass;
|
||||
this._cameraManager = cameraManager;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
protected _dateRangeToString(when: DateRange): string {
|
||||
return `${formatDate(when.start)},${formatDate(when.end)}`;
|
||||
}
|
||||
|
||||
async hostConnected() {
|
||||
let metadata: MediaMetadata | null;
|
||||
try {
|
||||
metadata = await this._cameraManager.getMediaMetadata(this._hass);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
if (!metadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.what) {
|
||||
this.whatOptions = [...metadata.what]
|
||||
.sort()
|
||||
.map((what) => ({ value: what, label: prettifyTitle(what) }));
|
||||
}
|
||||
if (metadata.where) {
|
||||
this.whereOptions = [...metadata.where]
|
||||
.sort()
|
||||
.map((where) => ({ value: where, label: prettifyTitle(where) }));
|
||||
}
|
||||
if (metadata.days) {
|
||||
const yearMonths: Set<string> = new Set();
|
||||
[...metadata.days].forEach((day) => {
|
||||
// An efficient conversion: "2023-01-26" -> "2023-01"
|
||||
yearMonths.add(day.substring(0, 7));
|
||||
});
|
||||
const monthStarts: Date[] = [];
|
||||
yearMonths.forEach((yearMonth) => {
|
||||
monthStarts.push(parse(yearMonth, 'yyyy-MM', new Date()));
|
||||
});
|
||||
this.whenOptions = orderBy(monthStarts, (date) => date.getTime(), 'desc').map(
|
||||
(monthStart) => ({
|
||||
label: format(monthStart, 'MMMM yyyy'),
|
||||
value: this._dateRangeToString({
|
||||
start: monthStart,
|
||||
end: endOfMonth(monthStart),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-media-filter': FrigateCardMediaFilter;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
frigateCardHasAction,
|
||||
getActionConfigGivenAction
|
||||
} from '../utils/action.js';
|
||||
import { FRIGATE_ICON_SVG_PATH } from '../utils/frigate.js';
|
||||
import { FRIGATE_ICON_SVG_PATH } from '../camera-manager/frigate/icon.js';
|
||||
import { refreshDynamicStateParameters } from '../utils/ha';
|
||||
import './submenu.js';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { ref, Ref } from 'lit/directives/ref.js';
|
||||
import { TROUBLESHOOTING_URL } from '../const.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import messageStyle from '../scss/message.scss';
|
||||
@@ -118,9 +119,11 @@ export function renderMessage(message: Message): TemplateResult {
|
||||
export function renderProgressIndicator(options?: {
|
||||
message?: string;
|
||||
cardWideConfig?: CardWideConfig;
|
||||
componentRef?: Ref<HTMLElement>,
|
||||
}): TemplateResult {
|
||||
return html`
|
||||
<frigate-card-progress-indicator
|
||||
${options?.componentRef ? ref(options.componentRef) : ''}
|
||||
.message=${options?.message || ''}
|
||||
.animated=${options?.cardWideConfig?.performance?.features
|
||||
.animated_progress_indicator ?? true}
|
||||
@@ -177,9 +180,11 @@ export function dispatchErrorMessageEvent(
|
||||
*/
|
||||
export function dispatchFrigateCardErrorEvent(
|
||||
element: EventTarget,
|
||||
error: FrigateCardError,
|
||||
error: FrigateCardError | Error,
|
||||
): void {
|
||||
dispatchErrorMessageEvent(element, error.message, { context: error.context });
|
||||
dispatchErrorMessageEvent(element, error.message, {
|
||||
...(error instanceof FrigateCardError && { context: error.context }),
|
||||
});
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import selectStyle from '../scss/select.scss';
|
||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic';
|
||||
import { ScopedRegistryHost } from '@lit-labs/scoped-registry-mixin';
|
||||
import { grSelectElements } from '../scoped-elements/gr-select';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import '../scoped-elements/gr-select';
|
||||
|
||||
export interface SelectOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type SelectValues = string | string[];
|
||||
|
||||
type SelectElement = HTMLElement & {
|
||||
value: SelectValues;
|
||||
};
|
||||
|
||||
export class FrigateCardSelect extends ScopedRegistryHost(LitElement) {
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public options?: SelectOption[];
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public value?: SelectValues;
|
||||
|
||||
@property({ attribute: true })
|
||||
public label?: string;
|
||||
|
||||
@property({ attribute: true })
|
||||
public placeholder?: string;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public multiple?: boolean = false;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public clearable?: boolean = false;
|
||||
|
||||
protected _previouslyReportedValue?: SelectValues;
|
||||
protected _refSelect: Ref<SelectElement> = createRef();
|
||||
|
||||
static elementDefinitions = {
|
||||
...grSelectElements,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected _valueChangedHandler(_ev: CustomEvent<{ value: unknown }>): void {
|
||||
const value: SelectValues | undefined = this._refSelect.value?.value;
|
||||
// The underlying gr-select element is very sensitive and occasionally fires
|
||||
// the change event even if the value has not actually changed. Prevent that
|
||||
// from propagating upwards.
|
||||
if (value !== undefined && !isEqual(this.value, value)) {
|
||||
this.value = value;
|
||||
dispatchFrigateCardEvent(this, 'select:change', value);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
return html` <gr-select
|
||||
${ref(this._refSelect)}
|
||||
label=${this.label ?? ''}
|
||||
placeholder=${this.placeholder ?? ''}
|
||||
size="small"
|
||||
?multiple=${this.multiple}
|
||||
?clearable=${this.clearable}
|
||||
.value=${this.value ?? this._refSelect.value?.value ?? []}
|
||||
@gr-change=${this._valueChangedHandler.bind(this)}
|
||||
>
|
||||
${this.options?.map(
|
||||
(option) =>
|
||||
html`<gr-menu-item value="${option.value ?? ''}"
|
||||
>${option.label}</gr-menu-item
|
||||
>`,
|
||||
)}
|
||||
</gr-select>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(selectStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'frigate-card-select': FrigateCardSelect;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
|
||||
import { FrigateCardDrawer } from './drawer.js';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { DrawerIcons, FrigateCardDrawer } from './drawer.js';
|
||||
|
||||
import './drawer.js';
|
||||
|
||||
@@ -14,6 +13,12 @@ interface FrigateCardDrawerOpen {
|
||||
|
||||
@customElement('frigate-card-surround-basic')
|
||||
export class FrigateCardSurroundBasic extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public drawerIcons?: {
|
||||
left?: DrawerIcons;
|
||||
right?: DrawerIcons;
|
||||
};
|
||||
|
||||
protected _refDrawerLeft: Ref<FrigateCardDrawer> = createRef();
|
||||
protected _refDrawerRight: Ref<FrigateCardDrawer> = createRef();
|
||||
protected _boundDrawerHandler = this._drawerHandler.bind(this);
|
||||
@@ -53,10 +58,18 @@ export class FrigateCardSurroundBasic extends LitElement {
|
||||
protected render(): TemplateResult | void {
|
||||
return html` <slot name="above"></slot>
|
||||
<slot></slot>
|
||||
<frigate-card-drawer ${ref(this._refDrawerLeft)} location="left">
|
||||
<frigate-card-drawer
|
||||
${ref(this._refDrawerLeft)}
|
||||
location="left"
|
||||
.icons=${this.drawerIcons?.left}
|
||||
>
|
||||
<slot name="left"></slot>
|
||||
</frigate-card-drawer>
|
||||
<frigate-card-drawer ${ref(this._refDrawerRight)} location="right">
|
||||
<frigate-card-drawer
|
||||
${ref(this._refDrawerRight)}
|
||||
location="right"
|
||||
.icons=${this.drawerIcons?.right}
|
||||
>
|
||||
<slot name="right"></slot>
|
||||
</frigate-card-drawer>
|
||||
<slot name="below"></slot>`;
|
||||
|
||||
+66
-49
@@ -7,29 +7,21 @@ import {
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
import surroundStyle from '../scss/surround.scss';
|
||||
import {
|
||||
BrowseMediaQueryParameters,
|
||||
CameraConfig,
|
||||
ClipsOrSnapshotsOrAll,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateBrowseMediaSource,
|
||||
FrigateCardError,
|
||||
MiniTimelineControlConfig,
|
||||
ThumbnailsControlConfig,
|
||||
} from '../types.js';
|
||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import {
|
||||
getFirstTrueMediaChildIndex,
|
||||
multipleBrowseMediaQueryMerged,
|
||||
} from '../utils/ha/browse-media';
|
||||
import { DataManager } from '../utils/data-manager';
|
||||
import { View } from '../view.js';
|
||||
import { dispatchFrigateCardErrorEvent } from './message.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { ThumbnailCarouselTap } from './thumbnail-carousel.js';
|
||||
|
||||
import './surround-basic.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { changeViewToRecentEventsForCameraAndDependents } from '../utils/media-to-view';
|
||||
import { getAllDependentCameras } from '../utils/camera.js';
|
||||
|
||||
interface ThumbnailViewContext {
|
||||
// Whether or not to fetch thumbnails.
|
||||
@@ -59,17 +51,17 @@ export class FrigateCardSurround extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public inBackground?: boolean;
|
||||
|
||||
@property({ attribute: false })
|
||||
public fetch = false;
|
||||
|
||||
// If fetchMedia is not specified, no fetching is done.
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public browseMediaParams?: BrowseMediaQueryParameters | BrowseMediaQueryParameters[];
|
||||
public fetchMedia?: ClipsOrSnapshotsOrAll;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public dataManager?: DataManager;
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
protected _cameraIDsForTimeline?: Set<string>;
|
||||
|
||||
/**
|
||||
* Fetch thumbnail media when a target is not specified in the view (e.g. for
|
||||
@@ -79,32 +71,30 @@ export class FrigateCardSurround extends LitElement {
|
||||
*/
|
||||
protected async _fetchMedia(): Promise<void> {
|
||||
if (
|
||||
!this.fetch ||
|
||||
!this.cameras ||
|
||||
!this.cameraManager ||
|
||||
!this.fetchMedia ||
|
||||
this.inBackground ||
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
this.view.target ||
|
||||
this.view.query ||
|
||||
!this.thumbnailConfig ||
|
||||
this.thumbnailConfig.mode === 'none' ||
|
||||
!this.browseMediaParams ||
|
||||
!(this.view.context?.thumbnails?.fetch ?? true)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let parent: FrigateBrowseMediaSource | null;
|
||||
try {
|
||||
parent = await multipleBrowseMediaQueryMerged(this.hass, this.browseMediaParams);
|
||||
} catch (e) {
|
||||
return dispatchFrigateCardErrorEvent(this, e as FrigateCardError);
|
||||
}
|
||||
if (getFirstTrueMediaChildIndex(parent) !== null) {
|
||||
this.view
|
||||
?.evolve({
|
||||
target: parent,
|
||||
childIndex: null,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
await changeViewToRecentEventsForCameraAndDependents(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
targetView: this.view.view,
|
||||
mediaType: this.fetchMedia,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,6 +115,16 @@ export class FrigateCardSurround extends LitElement {
|
||||
import('./timeline.js');
|
||||
}
|
||||
|
||||
// Only reset the timeline cameraIDs when the media materially changes (and
|
||||
// not on every view change, since the view will change frequently when the
|
||||
// user is scrubbing video).
|
||||
if (
|
||||
changedProperties.has('view') &&
|
||||
View.isMediaChange(changedProperties.get('view'), this.view)
|
||||
) {
|
||||
this._cameraIDsForTimeline = this._getCameraIDsForTimeline() ?? undefined;
|
||||
}
|
||||
|
||||
// Once the component will certainly update, dispatch a media request. Only
|
||||
// do so if properties relevant to the request have changed (as per their
|
||||
// hasChanged).
|
||||
@@ -137,12 +137,30 @@ export class FrigateCardSurround extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected _getCameraIDsForTimeline(): Set<string> | null {
|
||||
if (!this.view || !this.cameras) {
|
||||
return null;
|
||||
}
|
||||
if (this.view?.is('live')) {
|
||||
return getAllDependentCameras(this.cameras, this.view.camera);
|
||||
}
|
||||
if (this.view.isViewerView()) {
|
||||
return new Set(
|
||||
this.view.query
|
||||
?.getQueries()
|
||||
?.map((query) => [...query.cameraIDs])
|
||||
.flat(),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.hass || !this.view || !this.thumbnailConfig) {
|
||||
if (!this.hass || !this.view || !this.thumbnailConfig || !this.cameras) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,25 +188,21 @@ export class FrigateCardSurround extends LitElement {
|
||||
slot=${this.thumbnailConfig.mode}
|
||||
.hass=${this.hass}
|
||||
.config=${this.thumbnailConfig}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.view=${this.view}
|
||||
.target=${this.view.target}
|
||||
.cameras=${this.cameras}
|
||||
selected=${ifDefined(this.view.childIndex ?? undefined)}
|
||||
.selected=${this.view.queryResults?.getSelectedIndex() ?? undefined}
|
||||
@frigate-card:view:change=${(ev: CustomEvent) => changeDrawer(ev, 'close')}
|
||||
@frigate-card:thumbnail-carousel:tap=${(
|
||||
ev: CustomEvent<ThumbnailCarouselTap>,
|
||||
) => {
|
||||
const child: FrigateBrowseMediaSource | null =
|
||||
ev.detail.target?.children?.[ev.detail.childIndex] ?? null;
|
||||
if (child) {
|
||||
const media = ev.detail.queryResults.getSelectedResult();
|
||||
if (media) {
|
||||
this.view
|
||||
?.evolve({
|
||||
view: this.view.is('recording') ? 'recording' : 'media',
|
||||
target: ev.detail.target,
|
||||
childIndex: ev.detail.childIndex,
|
||||
...(child.frigate?.cameraID && {
|
||||
camera: child.frigate?.cameraID,
|
||||
}),
|
||||
view: 'media',
|
||||
queryResults: ev.detail.queryResults,
|
||||
...(media.getCameraID() && { camera: media.getCameraID() }),
|
||||
})
|
||||
.removeContext('timeline')
|
||||
// Send the view change from the source of the tap event, so
|
||||
@@ -200,17 +214,20 @@ export class FrigateCardSurround extends LitElement {
|
||||
>
|
||||
</frigate-card-thumbnail-carousel>`
|
||||
: ''}
|
||||
${this.timelineConfig?.mode && this.timelineConfig.mode !== 'none' && !this.inBackground
|
||||
${this.timelineConfig?.mode &&
|
||||
this.timelineConfig.mode !== 'none' &&
|
||||
!this.inBackground
|
||||
? html` <frigate-card-timeline-core
|
||||
slot=${this.timelineConfig.mode}
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.cameras=${this.cameras}
|
||||
.cameraIDs=${this._cameraIDsForTimeline}
|
||||
.mini=${true}
|
||||
.timelineConfig=${this.timelineConfig}
|
||||
.thumbnailDetails=${this.thumbnailConfig?.show_details}
|
||||
.thumbnailSize=${this.thumbnailConfig?.size}
|
||||
.dataManager=${this.dataManager}
|
||||
.cameraManager=${this.cameraManager}
|
||||
>
|
||||
</frigate-card-timeline-core>`
|
||||
: ''}
|
||||
|
||||
@@ -15,22 +15,20 @@ import thumbnailCarouselStyle from '../scss/thumbnail-carousel.scss';
|
||||
import {
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateBrowseMediaSource,
|
||||
ThumbnailsControlConfig,
|
||||
} from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { contentsChanged, dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { isTrueMedia } from '../utils/ha/browse-media';
|
||||
import { View } from '../view.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { MediaQueriesResults } from "../view/media-queries-results";
|
||||
import { FrigateCardCarousel } from './carousel.js';
|
||||
import './thumbnail.js';
|
||||
import './carousel.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
|
||||
export interface ThumbnailCarouselTap {
|
||||
slideIndex: number;
|
||||
target: FrigateBrowseMediaSource;
|
||||
childIndex: number;
|
||||
queryResults: MediaQueriesResults;
|
||||
}
|
||||
|
||||
@customElement('frigate-card-thumbnail-carousel')
|
||||
@@ -41,14 +39,12 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
// Use contentsChanged here to avoid the carousel rebuilding and resetting in
|
||||
// front of the user, unless the contents have actually changed.
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public target?: FrigateBrowseMediaSource | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
protected _refCarousel: Ref<FrigateCardCarousel> = createRef();
|
||||
|
||||
// Thumbnail carousels can expand (e.g. drawer-based carousels after the main
|
||||
@@ -59,10 +55,14 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public config?: ThumbnailsControlConfig;
|
||||
|
||||
@property({ attribute: true, type: Number, reflect: true })
|
||||
public selected?: number;
|
||||
@property({ attribute: false })
|
||||
public selected? = 0;
|
||||
|
||||
protected _carouselOptions?: EmblaOptionsType = {
|
||||
containScroll: 'keepSnaps',
|
||||
dragFree: true,
|
||||
};
|
||||
|
||||
protected _carouselOptions?: EmblaOptionsType;
|
||||
protected _carouselPlugins: EmblaPluginType[] = [
|
||||
WheelGesturesPlugin({
|
||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||
@@ -99,31 +99,20 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Embla options to use.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
*/
|
||||
protected _getOptions(): EmblaOptionsType {
|
||||
return {
|
||||
containScroll: 'keepSnaps',
|
||||
dragFree: true,
|
||||
startIndex: this.selected ?? 0,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Get slides to include in the render.
|
||||
* @returns The slides to include in the render.
|
||||
*/
|
||||
protected _getSlides(): TemplateResult[] {
|
||||
if (!this.target || !this.target.children || !this.target.children.length) {
|
||||
if (!this.view?.query || !this.view.queryResults?.hasResults()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const slides: TemplateResult[] = [];
|
||||
for (let i = 0; i < this.target.children.length; ++i) {
|
||||
const thumbnail = this._renderThumbnail(this.target, i, slides.length);
|
||||
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
|
||||
const thumbnail = this._renderThumbnail(i);
|
||||
if (thumbnail) {
|
||||
slides.push(thumbnail);
|
||||
slides[i] = thumbnail;
|
||||
}
|
||||
}
|
||||
return slides;
|
||||
@@ -152,30 +141,6 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
||||
this.selected === undefined ? '1.0' : '0.4',
|
||||
);
|
||||
}
|
||||
|
||||
if (!this._carouselOptions) {
|
||||
// Want to set the initial carousel options just before the first render
|
||||
// in order to get the startIndex correct in the options. It is not safe
|
||||
// to rely on carouselScrollTo() post update, since the nested carousel
|
||||
// may not yet be actual rendered/created.
|
||||
this._carouselOptions = this._getOptions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The updated lifecycle callback for this element.
|
||||
* @param changedProperties The properties that were changed in this render.
|
||||
*/
|
||||
updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('selected')) {
|
||||
this.updateComplete.then(() => {
|
||||
if (this.selected !== undefined) {
|
||||
this._refCarousel.value?.carouselScrollTo(this.selected);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,45 +148,41 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
||||
* @param mediaToRender The media item to render.
|
||||
* @returns A template or void if the item could not be rendered.
|
||||
*/
|
||||
protected _renderThumbnail(
|
||||
parent: FrigateBrowseMediaSource,
|
||||
childIndex: number,
|
||||
slideIndex: number,
|
||||
): TemplateResult | void {
|
||||
if (
|
||||
!parent.children ||
|
||||
!parent.children.length ||
|
||||
!isTrueMedia(parent.children[childIndex])
|
||||
) {
|
||||
protected _renderThumbnail(index: number): TemplateResult | void {
|
||||
const media = this.view?.queryResults?.getResult(index) ?? null;
|
||||
const cameraConfig = media ? this.cameras?.get(media.getCameraID()) : null;
|
||||
if (!media || !cameraConfig || !this.view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const classes = {
|
||||
embla__slide: true,
|
||||
'slide-selected': this.selected === childIndex,
|
||||
'slide-selected': this.selected === index,
|
||||
};
|
||||
|
||||
const cameraConfig = this.view?.camera ? this.cameras?.get(this.view.camera) : null;
|
||||
const seekTarget = this.view?.context?.mediaViewer?.seek;
|
||||
return html` <frigate-card-thumbnail
|
||||
class="${classMap(classes)}"
|
||||
.cameraManager=${this.cameraManager}
|
||||
.hass=${this.hass}
|
||||
.media=${media}
|
||||
.cameraConfig=${cameraConfig}
|
||||
.view=${this.view}
|
||||
.target=${parent}
|
||||
.childIndex=${childIndex}
|
||||
.mediaSeek=${this.view?.context?.mediaViewer?.seek.get(childIndex)}
|
||||
.cameraConfig=${cameraConfig ?? undefined}
|
||||
?details=${this.config?.show_details}
|
||||
.seek=${seekTarget && media.includesTime(seekTarget) ? seekTarget : undefined}
|
||||
?details=${!!this.config?.show_details}
|
||||
?show_favorite_control=${this.config?.show_favorite_control}
|
||||
?show_timeline_control=${this.config?.show_timeline_control}
|
||||
class="${classMap(classes)}"
|
||||
@click=${(ev) => {
|
||||
if (this._refCarousel.value?.carouselClickAllowed()) {
|
||||
@click=${(ev: Event) => {
|
||||
if (
|
||||
this.view &&
|
||||
this.view.queryResults &&
|
||||
this._refCarousel.value?.carouselClickAllowed()
|
||||
) {
|
||||
dispatchFrigateCardEvent<ThumbnailCarouselTap>(
|
||||
this,
|
||||
'thumbnail-carousel:tap',
|
||||
{
|
||||
slideIndex: slideIndex,
|
||||
target: parent,
|
||||
childIndex: childIndex,
|
||||
queryResults: this.view.queryResults.clone().selectResult(index),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -257,6 +218,7 @@ export class FrigateCardThumbnailCarousel extends LitElement {
|
||||
return html`<frigate-card-carousel
|
||||
${ref(this._refCarousel)}
|
||||
direction=${ifDefined(this._getDirection())}
|
||||
.selected=${this.selected ?? 0}
|
||||
.carouselOptions=${this._carouselOptions}
|
||||
.carouselPlugins=${this._carouselPlugins}
|
||||
>
|
||||
|
||||
+168
-151
@@ -1,32 +1,31 @@
|
||||
import format from 'date-fns/format';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import { CSSResult, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||
import {
|
||||
CSSResult,
|
||||
html,
|
||||
LitElement,
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
|
||||
import { localize } from '../localize/localize.js';
|
||||
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
|
||||
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
|
||||
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
|
||||
import thumbnailStyle from '../scss/thumbnail.scss';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { errorToConsole, prettifyTitle } from '../utils/basic.js';
|
||||
import { getCameraTitle } from '../utils/camera.js';
|
||||
import { retainEvent } from '../utils/frigate.js';
|
||||
import { getEventDurationString } from '../utils/frigate.js';
|
||||
import { errorToConsole, getDurationString, prettifyTitle } from '../utils/basic.js';
|
||||
import { renderTask } from '../utils/task.js';
|
||||
import { createFetchThumbnailTask } from '../utils/thumbnail.js';
|
||||
import { View } from '../view.js';
|
||||
import type { MediaSeek } from './viewer.js';
|
||||
import { TaskStatus } from '@lit-labs/task';
|
||||
import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumbnail.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { Task, TaskStatus } from '@lit-labs/task';
|
||||
|
||||
import type { CameraConfig, ExtendedHomeAssistant } from '../types.js';
|
||||
import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { ViewMediaClassifier } from '../view/media-classifier.js';
|
||||
|
||||
import type {
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateBrowseMediaSource,
|
||||
FrigateEvent,
|
||||
FrigateRecording,
|
||||
} from '../types.js';
|
||||
// The minimum width of a thumbnail with details enabled.
|
||||
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||
|
||||
@@ -38,12 +37,7 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
protected _embedThumbnailTask = createFetchThumbnailTask(
|
||||
this,
|
||||
() => this.hass,
|
||||
() => this.thumbnail,
|
||||
false,
|
||||
);
|
||||
protected _embedThumbnailTask?: Task<FetchThumbnailTaskArgs, string | null>;
|
||||
|
||||
// Only load thumbnails on view in case there is a very large number of them.
|
||||
protected _intersectionObserver: IntersectionObserver;
|
||||
@@ -71,20 +65,38 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
||||
this._intersectionObserver.disconnect();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('thumbnail')) {
|
||||
this._embedThumbnailTask = createFetchThumbnailTask(
|
||||
this,
|
||||
() => this.hass,
|
||||
() => this.thumbnail,
|
||||
false,
|
||||
);
|
||||
// Reset the observer so the initial intersection handler call will set
|
||||
// the visibility correctly.
|
||||
this._intersectionObserver.unobserve(this);
|
||||
this._intersectionObserver.observe(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the live view intersects with the viewport.
|
||||
* @param entries The IntersectionObserverEntry entries (should be only 1).
|
||||
*/
|
||||
protected _intersectionHandler(entries: IntersectionObserverEntry[]): void {
|
||||
if (
|
||||
this._embedThumbnailTask.status === TaskStatus.INITIAL &&
|
||||
this._embedThumbnailTask?.status === TaskStatus.INITIAL &&
|
||||
entries.some((entry) => entry.isIntersecting)
|
||||
) {
|
||||
this._embedThumbnailTask.run();
|
||||
this._embedThumbnailTask?.run();
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this._embedThumbnailTask) {
|
||||
return;
|
||||
}
|
||||
const imageOff = html`<ha-icon
|
||||
icon="mdi:image-off"
|
||||
title=${localize('thumbnail.no_thumbnail')}
|
||||
@@ -133,36 +145,48 @@ export class FrigateCardThumbnailFeatureRecording extends LitElement {
|
||||
@customElement('frigate-card-thumbnail-details-event')
|
||||
export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public event?: FrigateEvent;
|
||||
public media?: EventViewMedia;
|
||||
|
||||
@property({ attribute: false })
|
||||
public mediaSeek?: MediaSeek;
|
||||
public seek?: Date;
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.event) {
|
||||
if (!this.media) {
|
||||
return;
|
||||
}
|
||||
const score = (this.event.top_score * 100).toFixed(2) + '%';
|
||||
return html`<div class="left">
|
||||
<div class="larger">${prettifyTitle(this.event.label)}</div>
|
||||
<div>
|
||||
<span class="heading">${localize('event.start')}:</span>
|
||||
<span>${format(fromUnixTime(this.event.start_time), 'HH:mm:ss')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="heading">${localize('event.duration')}:</span>
|
||||
<span>${getEventDurationString(this.event)}</span>
|
||||
</div>
|
||||
${this.mediaSeek
|
||||
const score = this.media.getScore();
|
||||
const startTime = this.media.getStartTime();
|
||||
const endTime = this.media.getEndTime();
|
||||
const what = this.media.getWhat();
|
||||
|
||||
return html` <div class="left">
|
||||
${what ? html`<div class="larger">${prettifyTitle(what.join(', '))}</div>` : ``}
|
||||
${startTime
|
||||
? html` <div>
|
||||
<span class="heading">${localize('event.start')}:</span>
|
||||
<span>${format(startTime, 'HH:mm:ss')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="heading">${localize('event.duration')}:</span>
|
||||
<span
|
||||
>${endTime
|
||||
? getDurationString(startTime, endTime)
|
||||
: localize('event.in_progress')}</span
|
||||
>
|
||||
</div>`
|
||||
: ``}
|
||||
${this.seek
|
||||
? html` <div>
|
||||
<span class="heading">${localize('event.seek')}</span>
|
||||
<span>${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')}</span>
|
||||
<span>${format(this.seek, 'HH:mm:ss')}</span>
|
||||
</div>`
|
||||
: html``}
|
||||
</div>
|
||||
<div class="right">
|
||||
<span class="larger">${score}</span>
|
||||
</div>`;
|
||||
${score
|
||||
? html`<div class="right">
|
||||
<span class="larger">${(score * 100).toFixed(2) + '%'}</span>
|
||||
</div>`
|
||||
: ``}`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResult {
|
||||
@@ -173,28 +197,34 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
||||
@customElement('frigate-card-thumbnail-details-recording')
|
||||
export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public recording?: FrigateRecording;
|
||||
public media?: RecordingViewMedia;
|
||||
|
||||
@property({ attribute: false })
|
||||
public mediaSeek?: MediaSeek;
|
||||
public seek?: Date;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraTitle?: string;
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.recording) {
|
||||
if (!this.media) {
|
||||
return;
|
||||
}
|
||||
const eventCount = this.media.getEventCount();
|
||||
return html`<div class="left">
|
||||
<div class="larger">${prettifyTitle(this.recording.camera) || ''}</div>
|
||||
${this.mediaSeek
|
||||
<div class="larger">${this.cameraTitle ?? ''}</div>
|
||||
${this.seek
|
||||
? html` <div>
|
||||
<span class="heading">${localize('recording.seek')}</span>
|
||||
<span>${format(fromUnixTime(this.mediaSeek.seekTime), 'HH:mm:ss')}</span>
|
||||
<span>${format(this.seek, 'HH:mm:ss')}</span>
|
||||
</div>`
|
||||
: html``}
|
||||
</div>
|
||||
<div class="right">
|
||||
<span class="larger">${this.recording.events}</span>
|
||||
<span>${localize('recording.events')}</span>
|
||||
</div>`;
|
||||
${eventCount !== null
|
||||
? html`<div class="right">
|
||||
<span class="larger">${eventCount}</span>
|
||||
<span>${localize('recording.events')}</span>
|
||||
</div>`
|
||||
: ``}`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResult {
|
||||
@@ -204,6 +234,20 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
||||
|
||||
@customElement('frigate-card-thumbnail')
|
||||
export class FrigateCardThumbnail extends LitElement {
|
||||
// HomeAssistant object may be required for thumbnail signing (for Frigate
|
||||
// events).
|
||||
@property({ attribute: false })
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: true })
|
||||
public media?: ViewMedia;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public details = false;
|
||||
|
||||
@@ -213,160 +257,133 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public show_timeline_control = false;
|
||||
|
||||
// ======================
|
||||
// Target-based interface
|
||||
// ======================
|
||||
@property({ attribute: false })
|
||||
public target?: FrigateBrowseMediaSource | null;
|
||||
public seek?: Date;
|
||||
|
||||
@property({ attribute: false })
|
||||
public childIndex?: number;
|
||||
|
||||
@property({ attribute: false })
|
||||
public mediaSeek?: MediaSeek;
|
||||
|
||||
// ===================================================
|
||||
// Raw interface (can override target-based interface)
|
||||
// ===================================================
|
||||
@property({ attribute: true })
|
||||
public thumbnail?: string;
|
||||
|
||||
@property({ attribute: true })
|
||||
public label?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public event?: FrigateEvent;
|
||||
|
||||
// ================================
|
||||
// Optional parameters for controls
|
||||
// ================================
|
||||
@property({ attribute: false })
|
||||
public view?: Readonly<View>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public hass?: ExtendedHomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraConfig?: CameraConfig;
|
||||
|
||||
/**
|
||||
* Render the element.
|
||||
* @returns A template to display to the user.
|
||||
*/
|
||||
protected render(): TemplateResult | void {
|
||||
let event: FrigateEvent | null = null;
|
||||
let recording: FrigateRecording | null = null;
|
||||
let thumbnail: string | null = null;
|
||||
let label: string | null = null;
|
||||
|
||||
// Take the event / thumbnail / label from the data-bound media (if specified).
|
||||
if (this.target && this.target.children && this.childIndex !== undefined) {
|
||||
const media = this.target.children[this.childIndex];
|
||||
event = media.frigate?.event ?? null;
|
||||
recording = media.frigate?.recording ?? null;
|
||||
thumbnail = media.thumbnail;
|
||||
label = media.title;
|
||||
}
|
||||
|
||||
// Always give the overrides preference (if specified).
|
||||
if (this.event) {
|
||||
event = this.event;
|
||||
}
|
||||
thumbnail = this.thumbnail ? this.thumbnail : thumbnail;
|
||||
label = this.label ? this.label : label;
|
||||
|
||||
if (!event && !recording) {
|
||||
if (!this.media || !this.cameraConfig || !this.cameraManager || !this.hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
const thumbnail = this.media.getThumbnail();
|
||||
const title = this.media.getTitle() ?? '';
|
||||
|
||||
const starClasses = {
|
||||
star: true,
|
||||
starred: !!event?.retain_indefinitely,
|
||||
starred: !!this.media?.isFavorite(),
|
||||
};
|
||||
|
||||
const clientID = this.cameraConfig?.frigate.client_id;
|
||||
return html` ${event
|
||||
const shouldShowTimelineControl =
|
||||
this.show_timeline_control &&
|
||||
this.view &&
|
||||
(!ViewMediaClassifier.isRecording(this.media) ||
|
||||
// Only show timeline control if the recording has a start & end time.
|
||||
(this.media.getStartTime() && this.media.getEndTime()));
|
||||
|
||||
const shouldShowFavoriteControl =
|
||||
this.show_favorite_control &&
|
||||
this.media &&
|
||||
this.hass &&
|
||||
this.cameraManager?.getMediaCapabilities(this.media)?.canFavorite;
|
||||
|
||||
const cameraTitle = this.cameraManager.getCameraMetadata(this.hass, this.cameraConfig)?.title;
|
||||
|
||||
return html` ${ViewMediaClassifier.isEvent(this.media)
|
||||
? html`<frigate-card-thumbnail-feature-event
|
||||
aria-label="${label ?? ''}"
|
||||
title="${label ?? ''}"
|
||||
aria-label="${title ?? ''}"
|
||||
title=${title}
|
||||
.hass=${this.hass}
|
||||
.thumbnail=${thumbnail ?? undefined}
|
||||
.label=${label ?? undefined}
|
||||
></frigate-card-thumbnail-feature-event>`
|
||||
: recording
|
||||
: ViewMediaClassifier.isRecording(this.media)
|
||||
? html`<frigate-card-thumbnail-feature-recording
|
||||
aria-label="${label ?? ''}"
|
||||
title="${label ?? ''}"
|
||||
.cameraTitle=${this.details || !this.cameraConfig || !this.hass
|
||||
? undefined
|
||||
: getCameraTitle(this.hass, this.cameraConfig)}
|
||||
.date=${recording ? fromUnixTime(recording.start_time) : undefined}
|
||||
aria-label="${title ?? ''}"
|
||||
title="${title ?? ''}"
|
||||
.cameraTitle=${this.details ? undefined : cameraTitle}
|
||||
.date=${this.media.getStartTime() ?? undefined}
|
||||
></frigate-card-thumbnail-feature-recording>`
|
||||
: html``}
|
||||
${this.show_favorite_control && event && this.hass && clientID
|
||||
${shouldShowFavoriteControl
|
||||
? html` <ha-icon
|
||||
class="${classMap(starClasses)}"
|
||||
icon=${event?.retain_indefinitely ? 'mdi:star' : 'mdi:star-outline'}
|
||||
icon=${this.media.isFavorite() ? 'mdi:star' : 'mdi:star-outline'}
|
||||
title=${localize('thumbnail.retain_indefinitely')}
|
||||
@click=${(ev: Event) => {
|
||||
@click=${async (ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (event && this.hass && clientID) {
|
||||
retainEvent(this.hass, clientID, event.id, !event.retain_indefinitely)
|
||||
.then(() => {
|
||||
if (event) {
|
||||
event.retain_indefinitely = !event.retain_indefinitely;
|
||||
this.requestUpdate();
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
errorToConsole(e);
|
||||
});
|
||||
if (this.hass && this.media) {
|
||||
try {
|
||||
await this.cameraManager?.favoriteMedia(
|
||||
this.hass,
|
||||
this.media,
|
||||
!this.media?.isFavorite(),
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
this.requestUpdate();
|
||||
}
|
||||
}}
|
||||
/></ha-icon>`
|
||||
: ``}
|
||||
${this.details && event
|
||||
${this.details && ViewMediaClassifier.isEvent(this.media)
|
||||
? html`<frigate-card-thumbnail-details-event
|
||||
.event=${event ?? undefined}
|
||||
.mediaSeek=${this.mediaSeek}
|
||||
.media=${this.media ?? undefined}
|
||||
.seek=${this.seek}
|
||||
></frigate-card-thumbnail-details-event>`
|
||||
: this.details && recording
|
||||
: this.details && ViewMediaClassifier.isRecording(this.media)
|
||||
? html`<frigate-card-thumbnail-details-recording
|
||||
.recording=${recording ?? undefined}
|
||||
.mediaSeek=${this.mediaSeek}
|
||||
.media=${this.media ?? undefined}
|
||||
.cameraTitle=${cameraTitle}
|
||||
.seek=${this.seek}
|
||||
></frigate-card-thumbnail-details-recording>`
|
||||
: html``}
|
||||
${this.show_timeline_control
|
||||
${shouldShowTimelineControl
|
||||
? html`<ha-icon
|
||||
class="timeline"
|
||||
icon="mdi:target"
|
||||
title=${localize('thumbnail.timeline')}
|
||||
@click=${(ev: Event) => {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
if (event) {
|
||||
if (!this.view || !this.media) {
|
||||
return;
|
||||
}
|
||||
if (ViewMediaClassifier.isEvent(this.media)) {
|
||||
this.view
|
||||
?.evolve({
|
||||
.evolve({
|
||||
view: 'timeline',
|
||||
target: this.target,
|
||||
childIndex: this.childIndex ?? null,
|
||||
queryResults: this.view.queryResults
|
||||
?.clone()
|
||||
.selectResultIfFound((media) => media === this.media),
|
||||
})
|
||||
.removeContext('timeline')
|
||||
.dispatchChangeEvent(this);
|
||||
} else if (recording) {
|
||||
} else if (ViewMediaClassifier.isRecording(this.media)) {
|
||||
const startTime = this.media.getStartTime();
|
||||
const endTime = this.media.getStartTime();
|
||||
if (!startTime || !endTime) {
|
||||
return;
|
||||
}
|
||||
// Specifically reset the media target/childIndex, as we cannot
|
||||
// 'select' an hour in the timeline rather we set the window to
|
||||
// matching values.
|
||||
this.view
|
||||
?.evolve({
|
||||
view: 'timeline',
|
||||
target: null,
|
||||
childIndex: null,
|
||||
query: null,
|
||||
})
|
||||
.mergeInContext({
|
||||
timeline: {
|
||||
window: {
|
||||
start: fromUnixTime(recording.start_time),
|
||||
end: fromUnixTime(recording.end_time),
|
||||
start: startTime,
|
||||
end: endTime,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
+506
-469
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,8 @@ import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import timelineStyle from '../scss/timeline.scss';
|
||||
import { CameraConfig, ExtendedHomeAssistant, TimelineConfig } from '../types';
|
||||
import { DataManager } from '../utils/data-manager';
|
||||
import { View } from '../view';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { View } from '../view/view';
|
||||
import './surround.js';
|
||||
import './timeline-core.js';
|
||||
|
||||
@@ -27,7 +27,7 @@ export class FrigateCardTimeline extends LitElement {
|
||||
public timelineConfig?: TimelineConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public dataManager?: DataManager;
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
/**
|
||||
* Master render method.
|
||||
@@ -42,8 +42,8 @@ export class FrigateCardTimeline extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.thumbnailConfig=${this.timelineConfig.controls.thumbnails}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cameras=${this.cameras}
|
||||
.fetch=${false}
|
||||
>
|
||||
<frigate-card-timeline-core
|
||||
.hass=${this.hass}
|
||||
@@ -52,7 +52,7 @@ export class FrigateCardTimeline extends LitElement {
|
||||
.timelineConfig=${this.timelineConfig}
|
||||
.thumbnailDetails=${this.timelineConfig.controls.thumbnails.show_details}
|
||||
.thumbnailSize=${this.timelineConfig.controls.thumbnails.size}
|
||||
.dataManager=${this.dataManager}
|
||||
.cameraManager=${this.cameraManager}
|
||||
>
|
||||
</frigate-card-timeline-core>
|
||||
</frigate-card-surround>`;
|
||||
|
||||
+250
-366
@@ -1,5 +1,5 @@
|
||||
import { Task } from '@lit-labs/task';
|
||||
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
|
||||
import { EmblaPluginType } from 'embla-carousel';
|
||||
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
|
||||
import {
|
||||
CSSResultGroup,
|
||||
@@ -12,34 +12,25 @@ import {
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { renderProgressIndicator } from '../components/message.js';
|
||||
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
|
||||
import viewerStyle from '../scss/viewer.scss';
|
||||
import viewerCarouselStyle from '../scss/viewer-carousel.scss';
|
||||
import {
|
||||
BrowseMediaNeighbors,
|
||||
BrowseMediaQueryParameters,
|
||||
CameraConfig,
|
||||
CardWideConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateBrowseMediaSource,
|
||||
frigateCardConfigDefaults,
|
||||
FrigateCardMediaPlayer,
|
||||
MediaLoadedInfo,
|
||||
ResolvedMedia,
|
||||
TransitionEffect,
|
||||
ViewerConfig,
|
||||
} from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
import { contentsChanged } from '../utils/basic.js';
|
||||
import {
|
||||
fetchLatestMediaAndDispatchViewChange,
|
||||
getEventStartTime,
|
||||
getFullDependentBrowseMediaQueryParametersOrDispatchError,
|
||||
isTrueMedia,
|
||||
multipleBrowseMediaQueryMerged,
|
||||
overrideMultiBrowseMediaQueryParameters,
|
||||
} from '../utils/ha/browse-media.js';
|
||||
import { contentsChanged, errorToConsole } from '../utils/basic.js';
|
||||
import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js';
|
||||
import { View } from '../view.js';
|
||||
import { View } from '../view/view.js';
|
||||
import { MediaQueriesClassifier } from '../view/media-queries-classifier';
|
||||
import { AutoMediaPlugin } from './embla-plugins/automedia.js';
|
||||
import { Lazyload } from './embla-plugins/lazyload.js';
|
||||
import {
|
||||
@@ -55,20 +46,19 @@ import '../patches/ha-hls-player';
|
||||
import './surround.js';
|
||||
import { renderTask } from '../utils/task.js';
|
||||
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
|
||||
import { DataManager } from '../utils/data-manager.js';
|
||||
import { changeViewToRecentRecordingForCameraAndDependents } from '../utils/media-to-view.js';
|
||||
|
||||
export interface MediaSeek {
|
||||
// Specifies the point at which this recording should be played, the
|
||||
// seek_time is the date of the desired play point (for display purposes
|
||||
// usually), and seek_seconds is the number of seconds to seek into the video
|
||||
// stream to reach that point.
|
||||
seekTime: number;
|
||||
seekSeconds: number;
|
||||
}
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import {
|
||||
changeViewToRecentEventsForCameraAndDependents,
|
||||
changeViewToRecentRecordingForCameraAndDependents,
|
||||
} from '../utils/media-to-view.js';
|
||||
import { ViewMedia } from '../view/media.js';
|
||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
||||
|
||||
export interface MediaViewerViewContext {
|
||||
seek: Map<number, MediaSeek>;
|
||||
seek?: Date;
|
||||
}
|
||||
|
||||
declare module 'view' {
|
||||
@@ -95,7 +85,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
public resolvedMediaCache?: ResolvedMediaCache;
|
||||
|
||||
@property({ attribute: false })
|
||||
public dataManager?: DataManager;
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
@@ -110,26 +100,18 @@ export class FrigateCardViewer extends LitElement {
|
||||
!this.view ||
|
||||
!this.cameras ||
|
||||
!this.viewerConfig ||
|
||||
!this.dataManager
|
||||
!this.cameraManager
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const browseMediaQueryParameters =
|
||||
getFullDependentBrowseMediaQueryParametersOrDispatchError(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameras,
|
||||
this.view.camera,
|
||||
);
|
||||
|
||||
if (!this.view.target) {
|
||||
// If the target is not specified, the view must tell us which mediaType
|
||||
// to search for. When the target *is* specified, the view is not required
|
||||
// to indicate the media type (e.g. the mixed 'events' view from the
|
||||
if (!this.view.queryResults?.hasResults()) {
|
||||
// If the query is not specified, the view must tell us which mediaType to
|
||||
// search for. When the query *is* specified, the view is not required to
|
||||
// indicate the media type (e.g. the mixed 'media' view from the
|
||||
// timeline).
|
||||
const mediaType = this.view.getMediaType();
|
||||
if (!browseMediaQueryParameters || !mediaType) {
|
||||
const mediaType = this.view.getDefaultMediaType();
|
||||
if (!mediaType) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,7 +119,7 @@ export class FrigateCardViewer extends LitElement {
|
||||
changeViewToRecentRecordingForCameraAndDependents(
|
||||
this,
|
||||
this.hass,
|
||||
this.dataManager,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
{
|
||||
@@ -145,13 +127,16 @@ export class FrigateCardViewer extends LitElement {
|
||||
},
|
||||
);
|
||||
} else {
|
||||
fetchLatestMediaAndDispatchViewChange(
|
||||
changeViewToRecentEventsForCameraAndDependents(
|
||||
this,
|
||||
this.hass,
|
||||
this.cameraManager,
|
||||
this.cameras,
|
||||
this.view,
|
||||
overrideMultiBrowseMediaQueryParameters(browseMediaQueryParameters, {
|
||||
{
|
||||
targetView: 'media',
|
||||
mediaType: mediaType,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
return renderProgressIndicator({ cardWideConfig: this.cardWideConfig });
|
||||
@@ -160,18 +145,18 @@ export class FrigateCardViewer extends LitElement {
|
||||
return html` <frigate-card-surround
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.fetch=${false}
|
||||
.thumbnailConfig=${this.viewerConfig.controls.thumbnails}
|
||||
.timelineConfig=${this.viewerConfig.controls.timeline}
|
||||
.dataManager=${this.dataManager}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cameras=${this.cameras}
|
||||
>
|
||||
<frigate-card-viewer-carousel
|
||||
.hass=${this.hass}
|
||||
.view=${this.view}
|
||||
.cameras=${this.cameras}
|
||||
.viewerConfig=${this.viewerConfig}
|
||||
.browseMediaQueryParameters=${browseMediaQueryParameters}
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
</frigate-card-viewer-carousel>
|
||||
@@ -204,46 +189,52 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public viewerConfig?: ViewerConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
public resolvedMediaCache?: ResolvedMediaCache;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameras?: Map<string, CameraConfig>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraManager?: CameraManager;
|
||||
|
||||
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
|
||||
|
||||
// Mapping of slide # to FrigateBrowseMediaSource child #.
|
||||
// (Folders are not media items that can be rendered).
|
||||
protected _slideToChild: Record<number, number> = {};
|
||||
|
||||
protected _carouselOptions?: EmblaOptionsType;
|
||||
protected _carouselPlugins?: EmblaPluginType[];
|
||||
|
||||
// A task to resolve target media if lazy loading is disabled.
|
||||
protected _mediaResolutionTask = new Task<
|
||||
[FrigateBrowseMediaSource | null | undefined],
|
||||
[ViewerConfig | undefined, Map<string, CameraConfig> | undefined, View | undefined],
|
||||
void
|
||||
>(
|
||||
this,
|
||||
async ([target]: (FrigateBrowseMediaSource | null | undefined)[]): Promise<void> => {
|
||||
for (
|
||||
let i = 0;
|
||||
!this.viewerConfig?.lazy_load &&
|
||||
this.hass &&
|
||||
target &&
|
||||
target.children &&
|
||||
i < (target.children || []).length;
|
||||
++i
|
||||
async ([viewerConfig, cameras, view]: [
|
||||
ViewerConfig | undefined,
|
||||
Map<string, CameraConfig> | undefined,
|
||||
View | undefined,
|
||||
]): Promise<void> => {
|
||||
if (
|
||||
!this.hass ||
|
||||
!viewerConfig?.lazy_load ||
|
||||
!cameras ||
|
||||
!view ||
|
||||
!view.queryResults?.hasResults()
|
||||
) {
|
||||
if (isTrueMedia(target.children[i])) {
|
||||
await resolveMedia(this.hass, target.children[i], this.resolvedMediaCache);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const promises: Promise<ResolvedMedia | null>[] = [];
|
||||
view.queryResults?.getResults()?.forEach((media: ViewMedia) => {
|
||||
const mediaContentID = media.getContentID();
|
||||
if (this.hass && mediaContentID) {
|
||||
promises.push(
|
||||
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache),
|
||||
);
|
||||
}
|
||||
});
|
||||
await Promise.all(promises);
|
||||
},
|
||||
() => [this.view?.target],
|
||||
() => [this.viewerConfig, this.cameras, this.view],
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -251,50 +242,17 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
* @param changedProperties The properties that were changed in this render.
|
||||
*/
|
||||
updated(changedProperties: PropertyValues): void {
|
||||
const frigateCardCarousel = this._refMediaCarousel.value?.frigateCardCarousel();
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (frigateCardCarousel && changedProperties.has('view')) {
|
||||
if (changedProperties.has('view')) {
|
||||
const oldView = changedProperties.get('view') as View | undefined;
|
||||
if (oldView) {
|
||||
if (
|
||||
oldView.target === this.view?.target &&
|
||||
oldView.childIndex !== this.view.childIndex
|
||||
) {
|
||||
const slide = this._getSlideForChild(this.view.childIndex);
|
||||
if (
|
||||
slide !== null &&
|
||||
slide !== frigateCardCarousel.getCarouselSelected()?.index
|
||||
) {
|
||||
// If the media target is the same as already loaded, but isn't of
|
||||
// the selected slide, scroll to that slide.
|
||||
frigateCardCarousel.carouselScrollTo(slide);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seek into the video if the seek time has changed (this is also called
|
||||
// on media load, since the media may or may not have been loaded at
|
||||
// this point).
|
||||
if (this.view?.context?.mediaViewer !== oldView?.context?.mediaViewer) {
|
||||
this._recordingSeekHandler();
|
||||
this._seekHandler();
|
||||
}
|
||||
}
|
||||
super.updated(changedProperties);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the slide number given a media child number.
|
||||
* @param childIndex The child index (relative to `view.target`)
|
||||
* @returns A number or null if the child is not found.
|
||||
*/
|
||||
protected _getSlideForChild(childIndex: number | null | undefined): number | null {
|
||||
if (childIndex === undefined || childIndex === null) {
|
||||
return null;
|
||||
}
|
||||
const slideIndex = Object.keys(this._slideToChild).find(
|
||||
(key) => this._slideToChild[key] === childIndex,
|
||||
);
|
||||
return slideIndex !== undefined ? Number(slideIndex) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,18 +266,6 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Embla options to use.
|
||||
* @returns An EmblaOptionsType object or undefined for no options.
|
||||
*/
|
||||
protected _getOptions(): EmblaOptionsType {
|
||||
return {
|
||||
// Start the carousel on the selected child number.
|
||||
startIndex: this._getSlideForChild(this.view?.childIndex) ?? 0,
|
||||
draggable: this.viewerConfig?.draggable ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The the HLS player on a slide (or current slide if not provided.)
|
||||
* @param slide An optional slide.
|
||||
@@ -344,10 +290,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
protected _getPlugins(): EmblaPluginType[] {
|
||||
return [
|
||||
// Only enable wheel plugin if there is more than one media item.
|
||||
...(this.view &&
|
||||
this.view.target &&
|
||||
this.view.target.children &&
|
||||
this.view.target.children.length > 1
|
||||
...(this.view?.queryResults?.getResultsCount() ?? 0 > 1
|
||||
? [
|
||||
WheelGesturesPlugin({
|
||||
// Whether the carousel is vertical or horizontal, interpret y-axis wheel
|
||||
@@ -384,154 +327,107 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
* @returns A BrowseMediaNeighbors with indices and objects of true media
|
||||
* neighbors.
|
||||
*/
|
||||
protected _getMediaNeighbors(): BrowseMediaNeighbors | null {
|
||||
if (
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
this.view.childIndex === null
|
||||
) {
|
||||
return null;
|
||||
protected _getMediaNeighbors(): [ViewMedia | null, ViewMedia | null] {
|
||||
const selectedIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
|
||||
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
|
||||
if (!this.view || !this.view.queryResults || selectedIndex === null) {
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
// Work backwards from the index to get the previous real media.
|
||||
let prevIndex: number | null = null;
|
||||
for (let i = this.view.childIndex - 1; i >= 0; i--) {
|
||||
const media = this.view.target.children[i];
|
||||
if (media && isTrueMedia(media)) {
|
||||
prevIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Work forwards from the index to get the next real media.
|
||||
let nextIndex: number | null = null;
|
||||
for (let i = this.view.childIndex + 1; i < this.view.target.children.length; i++) {
|
||||
const media = this.view.target.children[i];
|
||||
if (media && isTrueMedia(media)) {
|
||||
nextIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
previousIndex: prevIndex,
|
||||
previous: prevIndex != null ? this.view.target.children[prevIndex] : null,
|
||||
nextIndex: nextIndex,
|
||||
next: nextIndex != null ? this.view.target.children[nextIndex] : null,
|
||||
};
|
||||
const previous: ViewMedia | null =
|
||||
selectedIndex > 0 ? this.view.queryResults.getResult(selectedIndex - 1) : null;
|
||||
const next: ViewMedia | null =
|
||||
selectedIndex + 1 < resultCount
|
||||
? this.view.queryResults.getResult(selectedIndex + 1)
|
||||
: null;
|
||||
return [previous, next];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a clip view that matches a given snapshot. Includes clips within the
|
||||
* same range as the current view.
|
||||
* @param snapshot The snapshot to find a matching clip for.
|
||||
* @returns The view that would show the matching clip.
|
||||
* Dispatch a clip view that matches the current (snapshot) query.
|
||||
* @param index The index of the selected media.
|
||||
*/
|
||||
protected async _findRelatedClipView(
|
||||
snapshot: FrigateBrowseMediaSource,
|
||||
): Promise<View | null> {
|
||||
protected async _dispatchRelatedClipView(index: number): Promise<void> {
|
||||
const media = this.view?.queryResults?.getResult(index);
|
||||
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
!this.view.target.children.length ||
|
||||
!this.browseMediaQueryParameters
|
||||
!this.cameraManager ||
|
||||
!media ||
|
||||
// If this specific media item has no clip, then do nothing (even if all
|
||||
// the other media items do).
|
||||
!ViewMediaClassifier.isEvent(media) ||
|
||||
// If the event certainly has no clip, don't bother going further. If
|
||||
// we're not sure for this camera type (i.e. hasClip() === null) the query
|
||||
// will proceed anyway.
|
||||
media.hasClip() === false ||
|
||||
!MediaQueriesClassifier.areEventQueries(this.view.query)
|
||||
) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshotStartTime = getEventStartTime(snapshot);
|
||||
if (!snapshotStartTime) {
|
||||
return null;
|
||||
// Convert the query to a clips equivalent.
|
||||
const clipQuery = this.view.query.clone();
|
||||
clipQuery.convertToClipsQueries();
|
||||
|
||||
const queries = clipQuery.getQueries();
|
||||
if (!queries) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Heuristic: At this point, the user has a particular snapshot that they
|
||||
// are interested in and want to see a related clip, yet the viewer code
|
||||
// does not know the exact search criteria that led to that snapshot (e.g.
|
||||
// it could be a 10-deep folder in the gallery). To give the user to ability
|
||||
// to 'navigate' in the clips view once they change into that mode, this
|
||||
// heuristic finds the earliest and latest snapshot that the user is
|
||||
// currently viewing and mirrors that range into the clips view. Then,
|
||||
// within the results see if there's a clip that matches the same time as
|
||||
// the snapshot.
|
||||
let earliest: number | null = null;
|
||||
let latest: number | null = null;
|
||||
for (let i = 0; i < this.view.target.children.length; i++) {
|
||||
const child = this.view.target.children[i];
|
||||
if (!isTrueMedia(child)) {
|
||||
continue;
|
||||
}
|
||||
const startTime = getEventStartTime(child);
|
||||
|
||||
if (startTime && (earliest === null || startTime < earliest)) {
|
||||
earliest = startTime;
|
||||
}
|
||||
if (startTime && (latest === null || startTime > latest)) {
|
||||
latest = startTime;
|
||||
}
|
||||
}
|
||||
if (!earliest || !latest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let clips: FrigateBrowseMediaSource | null;
|
||||
|
||||
const params = overrideMultiBrowseMediaQueryParameters(
|
||||
this.browseMediaQueryParameters,
|
||||
{
|
||||
mediaType: 'clips',
|
||||
before: latest,
|
||||
after: earliest,
|
||||
},
|
||||
);
|
||||
|
||||
let mediaArray: ViewMedia[] | null;
|
||||
try {
|
||||
clips = await multipleBrowseMediaQueryMerged(this.hass, params);
|
||||
mediaArray = await this.cameraManager.executeMediaQueries(this.hass, queries);
|
||||
} catch (e) {
|
||||
// This is best effort.
|
||||
return null;
|
||||
errorToConsole(e as Error);
|
||||
return;
|
||||
}
|
||||
if (!mediaArray) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clips || !clips.children || !clips.children.length) {
|
||||
return null;
|
||||
const results = new MediaQueriesResults(mediaArray);
|
||||
results.selectResultIfFound((clipMedia) => clipMedia.getID() === media.getID());
|
||||
if (!results.hasSelectedResult()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < clips.children.length; i++) {
|
||||
const child = clips.children[i];
|
||||
if (!isTrueMedia(child)) {
|
||||
continue;
|
||||
}
|
||||
const clipStartTime = getEventStartTime(child);
|
||||
if (clipStartTime && clipStartTime === snapshotStartTime) {
|
||||
return this.view.evolve({
|
||||
view: 'clip',
|
||||
target: clips,
|
||||
childIndex: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
this.view
|
||||
.evolve({
|
||||
view: 'media',
|
||||
query: clipQuery,
|
||||
queryResults: results,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the user selecting a new slide in the carousel.
|
||||
*/
|
||||
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
|
||||
if (!this._refMediaCarousel.value || !this.view) {
|
||||
// The slide may already be selected on load, so don't dispatch a new view
|
||||
// unless necessary.
|
||||
if (ev.detail.index !== this.view?.queryResults?.getSelectedIndex()) {
|
||||
this._setViewSelectedIndex(ev.detail.index);
|
||||
}
|
||||
}
|
||||
|
||||
protected _setViewSelectedIndex(index: number): void {
|
||||
const newResults = this.view?.queryResults?.clone().selectResult(index);
|
||||
if (!newResults) {
|
||||
return;
|
||||
}
|
||||
const cameraID = newResults.getSelectedResult()?.getCameraID();
|
||||
|
||||
// Update the childIndex in the view.
|
||||
const childIndex = this._slideToChild[ev.detail.index];
|
||||
if (childIndex !== undefined) {
|
||||
this.view
|
||||
.evolve({
|
||||
childIndex: childIndex,
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
this.view
|
||||
?.evolve({
|
||||
queryResults: newResults,
|
||||
|
||||
// Always change the camera to the owner of the selected media.
|
||||
...(cameraID && { camera: cameraID }),
|
||||
})
|
||||
.dispatchChangeEvent(this);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -539,11 +435,11 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
* default location will be the Chromecast receiver, not HA).
|
||||
* @param url The media URL
|
||||
*/
|
||||
protected _canonicalizeHAURL(url?: string): string | undefined {
|
||||
protected _canonicalizeHAURL(url?: string): string | null {
|
||||
if (this.hass && url && url.startsWith('/')) {
|
||||
return this.hass.hassUrl(url);
|
||||
}
|
||||
return url;
|
||||
return url ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -551,44 +447,38 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
* @param index The index of the slide to lazy load.
|
||||
* @param slide The slide to lazy load.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
protected _lazyloadSlide(index: number, slide: HTMLElement): void {
|
||||
const childIndex: number | undefined = this._slideToChild[index];
|
||||
|
||||
if (
|
||||
childIndex === undefined ||
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
!isTrueMedia(this.view.target.children[childIndex])
|
||||
) {
|
||||
if (!this.hass || !this.view || !this.view.query || !this.cameras) {
|
||||
return;
|
||||
}
|
||||
|
||||
resolveMedia(
|
||||
this.hass,
|
||||
this.view.target.children[childIndex],
|
||||
this.resolvedMediaCache,
|
||||
).then((resolvedMedia) => {
|
||||
if (!resolvedMedia) {
|
||||
return;
|
||||
}
|
||||
const media = this.view.queryResults?.getResult(index);
|
||||
const mediaContentID = media ? media.getContentID() : null;
|
||||
if (!mediaContentID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshots.
|
||||
const img = slide.querySelector('img') as HTMLImageElement;
|
||||
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(
|
||||
(resolvedMedia) => {
|
||||
if (!resolvedMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Frigate >= 0.9.0+ clips.
|
||||
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
|
||||
url: string;
|
||||
};
|
||||
// Snapshots.
|
||||
const img = slide.querySelector('img') as HTMLImageElement;
|
||||
|
||||
if (img) {
|
||||
img.src = this._canonicalizeHAURL(resolvedMedia.url) || '';
|
||||
} else if (hls_player) {
|
||||
hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) || '';
|
||||
}
|
||||
});
|
||||
// Frigate >= 0.9.0+ clips.
|
||||
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
|
||||
url: string;
|
||||
};
|
||||
|
||||
if (img) {
|
||||
img.src = this._canonicalizeHAURL(resolvedMedia.url) ?? '';
|
||||
} else if (hls_player) {
|
||||
hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) ?? '';
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -596,21 +486,18 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
* @returns The slides to include in the render.
|
||||
*/
|
||||
protected _getSlides(): TemplateResult[] {
|
||||
if (
|
||||
!this.view ||
|
||||
!this.view.target ||
|
||||
!this.view.target.children ||
|
||||
!this.view.target.children.length
|
||||
) {
|
||||
if (!this.view || !this.view.queryResults) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const slides: TemplateResult[] = [];
|
||||
for (let i = 0; i < this.view.target.children?.length; ++i) {
|
||||
const slide = this._renderMediaItem(this.view.target.children[i], slides.length);
|
||||
|
||||
if (slide) {
|
||||
slides.push(slide);
|
||||
for (let i = 0; i < this.view.queryResults.getResultsCount(); ++i) {
|
||||
const media = this.view.queryResults.getResult(i);
|
||||
if (media) {
|
||||
const slide = this._renderMediaItem(media, i);
|
||||
if (slide) {
|
||||
slides[i] = slide;
|
||||
}
|
||||
}
|
||||
}
|
||||
return slides;
|
||||
@@ -620,8 +507,12 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
* Determine if all the media in the carousel are resolved.
|
||||
*/
|
||||
protected _isMediaFullyResolved(): boolean {
|
||||
for (const child of this.view?.target?.children || []) {
|
||||
if (!this.resolvedMediaCache?.has(child.media_content_id)) {
|
||||
if (!this.resolvedMediaCache || !this.cameras) {
|
||||
return false;
|
||||
}
|
||||
for (const media of this.view?.queryResults?.getResults() ?? []) {
|
||||
const mediaContentID = media.getContentID();
|
||||
if (mediaContentID && !this.resolvedMediaCache.has(mediaContentID)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -633,32 +524,9 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
* @param changedProps The changed properties
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
// Pre-populate a map between real media slides and view child indicies.
|
||||
if (changedProps.has('view')) {
|
||||
this._slideToChild = {};
|
||||
let i = 0;
|
||||
(this.view?.target?.children ?? []).forEach((child, index) => {
|
||||
if (isTrueMedia(child) && ['video', 'image'].includes(child.media_content_type)) {
|
||||
this._slideToChild[i++] = index;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (changedProps.has('viewerConfig')) {
|
||||
updateElementStyleFromMediaLayoutConfig(this, this.viewerConfig?.layout);
|
||||
}
|
||||
if (!this._carouselOptions || changedProps.has('viewerConfig')) {
|
||||
this._carouselOptions = this._getOptions();
|
||||
}
|
||||
if (
|
||||
!this._carouselPlugins ||
|
||||
changedProps.has('viewerConfig') ||
|
||||
(changedProps.has('view') &&
|
||||
this.view?.target?.children?.length !==
|
||||
changedProps.get('view')?.target?.children?.length)
|
||||
) {
|
||||
this._carouselPlugins = this._getPlugins();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -680,49 +548,70 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
* @returns A template to display to the user.
|
||||
*/
|
||||
protected _render(): TemplateResult | void {
|
||||
const slides = this._getSlides();
|
||||
if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) {
|
||||
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
|
||||
icon: 'mdi:multimedia',
|
||||
});
|
||||
}
|
||||
|
||||
if (!slides.length || !this.view?.media) {
|
||||
const media = this.view?.queryResults?.getSelectedResult();
|
||||
if (!media || !this.cameras) {
|
||||
return;
|
||||
}
|
||||
|
||||
const neighbors = this._getMediaNeighbors();
|
||||
const [prev, next] = [neighbors?.previous, neighbors?.next];
|
||||
const [prev, next] = this._getMediaNeighbors();
|
||||
|
||||
const scroll = (direction: 'previous' | 'next'): void => {
|
||||
const currentIndex = this.view?.queryResults?.getSelectedIndex() ?? null;
|
||||
if (!this.view || !this.view?.queryResults || currentIndex === null) {
|
||||
return;
|
||||
}
|
||||
const newIndex = direction === 'previous' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (newIndex >= 0 && newIndex < this.view.queryResults.getResultsCount()) {
|
||||
this._setViewSelectedIndex(newIndex);
|
||||
}
|
||||
};
|
||||
|
||||
return html` <frigate-card-media-carousel
|
||||
${ref(this._refMediaCarousel)}
|
||||
.carouselOptions=${this._carouselOptions}
|
||||
.carouselPlugins=${this._carouselPlugins}
|
||||
.label="${this.view.media.title}"
|
||||
.carouselOptions=${guard([this.viewerConfig], () => ({
|
||||
draggable: this.viewerConfig?.draggable ?? true,
|
||||
}))}
|
||||
.carouselPlugins=${guard(
|
||||
[this.viewerConfig, this.view?.queryResults?.getResults()],
|
||||
this._getPlugins.bind(this),
|
||||
)}
|
||||
.label=${media.getTitle() ?? undefined}
|
||||
.titlePopupConfig=${this.viewerConfig?.controls.title}
|
||||
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
|
||||
transitionEffect=${this._getTransitionEffect()}
|
||||
@frigate-card:media-carousel:select=${this._setViewHandler.bind(this)}
|
||||
@frigate-card:media:loaded=${this._recordingSeekHandler.bind(this)}
|
||||
@frigate-card:media:loaded=${this._seekHandler.bind(this)}
|
||||
>
|
||||
<frigate-card-next-previous-control
|
||||
slot="previous"
|
||||
.hass=${this.hass}
|
||||
.direction=${'previous'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${prev && prev.thumbnail ? prev.thumbnail : undefined}
|
||||
.label=${prev ? prev.title : ''}
|
||||
.thumbnail=${prev?.getThumbnail() ?? undefined}
|
||||
.label=${prev?.getTitle() ?? ''}
|
||||
?disabled=${!prev}
|
||||
@click=${(ev) => {
|
||||
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollPrevious();
|
||||
scroll('previous');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
${slides}
|
||||
${guard(this.view?.queryResults?.getResults(), () => this._getSlides())}
|
||||
<frigate-card-next-previous-control
|
||||
slot="next"
|
||||
.hass=${this.hass}
|
||||
.direction=${'next'}
|
||||
.controlConfig=${this.viewerConfig?.controls.next_previous}
|
||||
.thumbnail=${next && next.thumbnail ? next.thumbnail : undefined}
|
||||
.label=${next ? next.title : ''}
|
||||
.thumbnail=${next?.getThumbnail() ?? undefined}
|
||||
.label=${next?.getTitle() ?? ''}
|
||||
?disabled=${!next}
|
||||
@click=${(ev) => {
|
||||
this._refMediaCarousel.value?.frigateCardCarousel()?.carouselScrollNext();
|
||||
scroll('next');
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
}}
|
||||
></frigate-card-next-previous-control>
|
||||
@@ -732,71 +621,70 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
/**
|
||||
* Fire a media show event when a slide is selected.
|
||||
*/
|
||||
protected _recordingSeekHandler(): void {
|
||||
protected async _seekHandler(): Promise<void> {
|
||||
const seek = this.view?.context?.mediaViewer?.seek;
|
||||
const media = this.view?.queryResults?.getSelectedResult();
|
||||
if (!this.hass || !media || !seek) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seekTime =
|
||||
(await this.cameraManager?.getMediaSeekTime(this.hass, media, seek)) ?? null;
|
||||
const player = this._getPlayer();
|
||||
const childIndex = this.view?.childIndex ?? null;
|
||||
const seek =
|
||||
childIndex !== null ? this.view?.context?.mediaViewer?.seek.get(childIndex) : null;
|
||||
if (player && seek) {
|
||||
player.seek(seek.seekSeconds);
|
||||
if (player && seekTime !== null) {
|
||||
player.seek(seekTime);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single media item in the viewer carousel.
|
||||
* @param mediaToRender The FrigateBrowseMediaSource to render.
|
||||
* @param slideIndex The index of the slide to render.
|
||||
* @param media The ViewMedia to render.
|
||||
* @param index The (slide|queryResult) index of the item to render.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected _renderMediaItem(
|
||||
mediaToRender: FrigateBrowseMediaSource,
|
||||
slideIndex: number,
|
||||
): TemplateResult | void {
|
||||
protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null {
|
||||
// Skip folders as they cannot be rendered by this viewer.
|
||||
if (
|
||||
!this.hass ||
|
||||
!this.view ||
|
||||
!this.viewerConfig ||
|
||||
!isTrueMedia(mediaToRender) ||
|
||||
!['video', 'image'].includes(mediaToRender.media_content_type)
|
||||
) {
|
||||
return;
|
||||
if (!this.hass || !this.view || !this.viewerConfig || !this.cameras) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lazyLoad = this.viewerConfig.lazy_load;
|
||||
const resolvedMedia = this.resolvedMediaCache?.get(mediaToRender.media_content_id);
|
||||
const mediaContentID = media.getContentID();
|
||||
const resolvedMedia = mediaContentID
|
||||
? this.resolvedMediaCache?.get(mediaContentID)
|
||||
: null;
|
||||
if (!resolvedMedia && !lazyLoad) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
// The media is attached to the player as '.media' which is used in
|
||||
// `_selectSlideMediaShowHandler` (and not used by the player itself).
|
||||
return html`
|
||||
<div class="embla__slide">
|
||||
${mediaToRender.media_content_type === 'video'
|
||||
${ViewMediaClassifier.isVideo(media)
|
||||
? html`<frigate-card-ha-hls-player
|
||||
allow-exoplayer
|
||||
aria-label="${mediaToRender.title}"
|
||||
aria-label="${media.getTitle() ?? ''}"
|
||||
?autoplay=${false}
|
||||
controls
|
||||
muted
|
||||
playsinline
|
||||
title="${mediaToRender.title}"
|
||||
title="${media.getTitle() ?? ''}"
|
||||
url=${ifDefined(
|
||||
lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url),
|
||||
lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url) ?? '',
|
||||
)}
|
||||
.hass=${this.hass}
|
||||
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
|
||||
wrapMediaLoadedEventForCarousel(slideIndex, e);
|
||||
wrapMediaLoadedEventForCarousel(index, e);
|
||||
}}
|
||||
>
|
||||
</frigate-card-ha-hls-player>`
|
||||
: html`<img
|
||||
aria-label="${mediaToRender.title}"
|
||||
aria-label="${media.getTitle() ?? ''}"
|
||||
src=${ifDefined(
|
||||
lazyLoad ? IMG_EMPTY : this._canonicalizeHAURL(resolvedMedia?.url),
|
||||
lazyLoad ? IMG_EMPTY : this._canonicalizeHAURL(resolvedMedia?.url) ?? '',
|
||||
)}
|
||||
title="${mediaToRender.title}"
|
||||
title="${media.getTitle() ?? ''}"
|
||||
@click=${() => {
|
||||
if (
|
||||
this._refMediaCarousel.value
|
||||
@@ -804,11 +692,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
?.carouselClickAllowed() &&
|
||||
this.viewerConfig?.snapshot_click_plays_clip
|
||||
) {
|
||||
this._findRelatedClipView(mediaToRender).then((view) => {
|
||||
if (view) {
|
||||
view.dispatchChangeEvent(this);
|
||||
}
|
||||
});
|
||||
this._dispatchRelatedClipView(index);
|
||||
}
|
||||
}}
|
||||
@load="${(e: Event) => {
|
||||
@@ -822,9 +706,9 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
// images in media-carousel.ts). Here we need to only call the
|
||||
// media load handler on a 'real' load.
|
||||
!lazyLoad ||
|
||||
lazyloadPlugin?.hasLazyloaded(slideIndex)
|
||||
lazyloadPlugin?.hasLazyloaded(index)
|
||||
) {
|
||||
wrapRawMediaLoadedEventForCarousel(slideIndex, e);
|
||||
wrapRawMediaLoadedEventForCarousel(index, e);
|
||||
}
|
||||
}}"
|
||||
/>`}
|
||||
|
||||
+2
-2
@@ -117,7 +117,7 @@ export const isConfigUpgradeable = function (obj: RawFrigateCardConfig): boolean
|
||||
* @param obj Configuration object.
|
||||
* @returns `true` if the configuration was modified.
|
||||
*/
|
||||
export const trimConfig = function (obj: RawFrigateCardConfig): boolean {
|
||||
const trimConfig = function (obj: RawFrigateCardConfig): boolean {
|
||||
const keys = Object.keys(obj);
|
||||
let modified = false;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
@@ -221,7 +221,7 @@ const deleteProperty = function (_value: unknown): number | null | undefined {
|
||||
* @param transform An optional transform for the value.
|
||||
* @returns `true` if the configuration was modified.
|
||||
*/
|
||||
export const moveConfigValue = (
|
||||
const moveConfigValue = (
|
||||
obj: RawFrigateCardConfig,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
|
||||
+9
-8
@@ -35,7 +35,7 @@ export const CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES =
|
||||
|
||||
export const CONF_ELEMENTS = 'elements' as const;
|
||||
|
||||
export const CONF_VIEW = 'view' as const;
|
||||
const CONF_VIEW = 'view' as const;
|
||||
export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const;
|
||||
export const CONF_VIEW_DARK_MODE = `${CONF_VIEW}.dark_mode` as const;
|
||||
export const CONF_VIEW_DEFAULT = `${CONF_VIEW}.default` as const;
|
||||
@@ -53,7 +53,9 @@ export const CONF_VIEW_SCAN_UNTRIGGER_RESET =
|
||||
export const CONF_VIEW_SCAN_UNTRIGGER_SECONDS =
|
||||
`${CONF_VIEW_SCAN}.untrigger_seconds` as const;
|
||||
|
||||
export const CONF_EVENT_GALLERY = 'event_gallery' as const;
|
||||
const CONF_EVENT_GALLERY = 'event_gallery' as const;
|
||||
export const CONF_EVENT_GALLERY_CONTROLS_FILTER_MODE =
|
||||
`${CONF_EVENT_GALLERY}.controls.filter.mode` as const;
|
||||
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS =
|
||||
`${CONF_EVENT_GALLERY}.controls.thumbnails.show_details` as const;
|
||||
export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL =
|
||||
@@ -109,7 +111,7 @@ export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_X =
|
||||
export const CONF_MEDIA_VIEWER_LAYOUT_POSITION_Y =
|
||||
`${CONF_MEDIA_VIEWER}.layout.position.y` as const;
|
||||
|
||||
export const CONF_LIVE = 'live' as const;
|
||||
const CONF_LIVE = 'live' as const;
|
||||
export const CONF_LIVE_AUTO_PLAY = `${CONF_LIVE}.auto_play` as const;
|
||||
export const CONF_LIVE_AUTO_PAUSE = `${CONF_LIVE}.auto_pause` as const;
|
||||
export const CONF_LIVE_AUTO_MUTE = `${CONF_LIVE}.auto_mute` as const;
|
||||
@@ -149,7 +151,6 @@ export const CONF_LIVE_LAYOUT_FIT = `${CONF_LIVE}.layout.fit` as const;
|
||||
export const CONF_LIVE_LAYOUT_POSITION_X = `${CONF_LIVE}.layout.position.x` as const;
|
||||
export const CONF_LIVE_LAYOUT_POSITION_Y = `${CONF_LIVE}.layout.position.y` as const;
|
||||
export const CONF_LIVE_DRAGGABLE = `${CONF_LIVE}.draggable` as const;
|
||||
export const CONF_LIVE_JSMPEG = `${CONF_LIVE}.jsmpeg` as const;
|
||||
export const CONF_LIVE_LAZY_LOAD = `${CONF_LIVE}.lazy_load` as const;
|
||||
export const CONF_LIVE_LAZY_UNLOAD = `${CONF_LIVE}.lazy_unload` as const;
|
||||
export const CONF_LIVE_PRELOAD = `${CONF_LIVE}.preload` as const;
|
||||
@@ -158,7 +159,7 @@ export const CONF_LIVE_SHOW_IMAGE_DURING_LOAD =
|
||||
`${CONF_LIVE}.show_image_during_load` as const;
|
||||
export const CONF_LIVE_WEBRTC_CARD = `${CONF_LIVE}.webrtc_card` as const;
|
||||
|
||||
export const CONF_IMAGE = 'image' as const;
|
||||
const CONF_IMAGE = 'image' as const;
|
||||
export const CONF_IMAGE_LAYOUT_FIT = `${CONF_IMAGE}.layout.fit` as const;
|
||||
export const CONF_IMAGE_LAYOUT_POSITION_X = `${CONF_IMAGE}.layout.position.x` as const;
|
||||
export const CONF_IMAGE_LAYOUT_POSITION_Y = `${CONF_IMAGE}.layout.position.y` as const;
|
||||
@@ -166,7 +167,7 @@ export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const;
|
||||
export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const;
|
||||
export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const;
|
||||
|
||||
export const CONF_TIMELINE = 'timeline' as const;
|
||||
const CONF_TIMELINE = 'timeline' as const;
|
||||
export const CONF_TIMELINE_WINDOW_SECONDS = `${CONF_TIMELINE}.window_seconds` as const;
|
||||
export const CONF_TIMELINE_CLUSTERING_THRESHOLD =
|
||||
`${CONF_TIMELINE}.clustering_threshold` as const;
|
||||
@@ -203,14 +204,14 @@ export const CONF_MENU_BUTTONS_MEDIA_PLAYER =
|
||||
export const CONF_MENU_BUTTONS_SNAPSHOTS = `${CONF_MENU}.buttons.snapshots` as const;
|
||||
export const CONF_MENU_BUTTONS_TIMELINE = `${CONF_MENU}.buttons.timeline` as const;
|
||||
|
||||
export const CONF_DIMENSIONS = 'dimensions' as const;
|
||||
const CONF_DIMENSIONS = 'dimensions' as const;
|
||||
export const CONF_DIMENSIONS_ASPECT_RATIO = `${CONF_DIMENSIONS}.aspect_ratio` as const;
|
||||
export const CONF_DIMENSIONS_ASPECT_RATIO_MODE =
|
||||
`${CONF_DIMENSIONS}.aspect_ratio_mode` as const;
|
||||
|
||||
export const CONF_OVERRIDES = 'overrides' as const;
|
||||
|
||||
export const CONF_PERFORMANCE = 'performance' as const;
|
||||
const CONF_PERFORMANCE = 'performance' as const;
|
||||
export const CONF_PERFORMANCE_FEATURES_ANIMATED_PROGRESS_INDICATOR = `${CONF_PERFORMANCE}.features.animated_progress_indicator`;
|
||||
export const CONF_PERFORMANCE_PROFILE = `${CONF_PERFORMANCE}.profile`;
|
||||
export const CONF_PERFORMANCE_STYLE_BOX_SHADOW = `${CONF_PERFORMANCE}.style.box_shadow`;
|
||||
|
||||
+91
-14
@@ -32,6 +32,7 @@ import {
|
||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
|
||||
CONF_DIMENSIONS_ASPECT_RATIO,
|
||||
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
|
||||
CONF_EVENT_GALLERY_CONTROLS_FILTER_MODE,
|
||||
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
@@ -139,10 +140,14 @@ import {
|
||||
THUMBNAIL_WIDTH_MAX,
|
||||
THUMBNAIL_WIDTH_MIN,
|
||||
} from './types.js';
|
||||
import { arrayMove } from './utils/basic.js';
|
||||
import { getCameraID, getCameraTitle } from './utils/camera.js';
|
||||
import { FRIGATE_ICON_SVG_PATH } from './utils/frigate.js';
|
||||
import { getEntitiesFromHASS, sideLoadHomeAssistantElements } from './utils/ha';
|
||||
import { arrayMove, prettifyTitle } from './utils/basic.js';
|
||||
import { getCameraID } from './utils/camera.js';
|
||||
import { FRIGATE_ICON_SVG_PATH } from './camera-manager/frigate/icon.js';
|
||||
import {
|
||||
getEntitiesFromHASS,
|
||||
getEntityTitle,
|
||||
sideLoadHomeAssistantElements,
|
||||
} from './utils/ha';
|
||||
import { setLowPerformanceProfile } from './performance.js';
|
||||
|
||||
const MENU_BUTTONS = 'buttons';
|
||||
@@ -152,6 +157,7 @@ const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
|
||||
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
|
||||
const MENU_CAMERAS_WEBRTC = 'cameras.webrtc';
|
||||
const MENU_EVENT_GALLERY_CONTROLS_THUMBNAILS = 'event_gallery.controls.thumbnails';
|
||||
const MENU_EVENT_GALLERY_CONTROLS_FILTER = 'event_gallery.controls.filter';
|
||||
const MENU_IMAGE_LAYOUT = 'image.layout';
|
||||
const MENU_LIVE_CONTROLS = 'live.controls';
|
||||
const MENU_LIVE_CONTROLS_NEXT_PREVIOUS = 'live.controls.next_previous';
|
||||
@@ -279,6 +285,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
{ value: 'current', label: localize('config.view.views.current') },
|
||||
];
|
||||
|
||||
protected _filterModes: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'none',
|
||||
label: localize('config.common.controls.filter.modes.none'),
|
||||
},
|
||||
{
|
||||
value: 'left',
|
||||
label: localize('config.common.controls.filter.modes.left'),
|
||||
},
|
||||
{
|
||||
value: 'right',
|
||||
label: localize('config.common.controls.filter.modes.right'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _menuStyles: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'none', label: localize('config.menu.styles.none') },
|
||||
@@ -690,8 +712,29 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
cameraIndex: number,
|
||||
cameraConfig: RawFrigateCardConfig,
|
||||
): string {
|
||||
// Attempt to render a recognizable name for the camera, starting with the
|
||||
// most likely to be useful and working our ways towards the least useful.
|
||||
// This is only used for the editor since the card itself can use the
|
||||
// cameraManager.
|
||||
return (
|
||||
getCameraTitle(this.hass, cameraConfig) ||
|
||||
(typeof cameraConfig?.title === 'string' && cameraConfig.title) ||
|
||||
(typeof cameraConfig?.camera_entity === 'string'
|
||||
? getEntityTitle(this.hass, cameraConfig.camera_entity)
|
||||
: '') ||
|
||||
(typeof cameraConfig?.webrtc_card === 'object' &&
|
||||
cameraConfig.webrtc_card &&
|
||||
typeof cameraConfig.webrtc_card['entity'] === 'string' &&
|
||||
cameraConfig.webrtc_card['entity']) ||
|
||||
// Usage of engine specific logic here is allowed as an exception, since
|
||||
// the camera manager cannot be started with an unparsed and unloaded
|
||||
// config.
|
||||
(typeof cameraConfig?.frigate === 'object' &&
|
||||
cameraConfig.frigate &&
|
||||
typeof cameraConfig?.frigate['camera_name'] === 'string' &&
|
||||
cameraConfig.frigate['camera_name']
|
||||
? prettifyTitle(cameraConfig.frigate['camera_name'])
|
||||
: '') ||
|
||||
(typeof cameraConfig?.id === 'string' && cameraConfig.id) ||
|
||||
localize('editor.camera') + ' #' + cameraIndex
|
||||
);
|
||||
}
|
||||
@@ -1007,6 +1050,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
configPathShowDetails: string,
|
||||
configPathShowFavoriteControl: string,
|
||||
configPathShowTimelineControl: string,
|
||||
defaults: {
|
||||
show_details: boolean;
|
||||
show_favorite_control: boolean;
|
||||
show_timeline_control: boolean;
|
||||
},
|
||||
options?: {
|
||||
configPathMedia?: string;
|
||||
configPathMode?: string;
|
||||
@@ -1041,23 +1089,19 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
max: THUMBNAIL_WIDTH_MAX,
|
||||
label: localize('config.common.controls.thumbnails.size'),
|
||||
})}
|
||||
${this._renderSwitch(
|
||||
configPathShowDetails,
|
||||
this._defaults.live.controls.thumbnails.show_details,
|
||||
{
|
||||
label: localize('config.common.controls.thumbnails.show_details'),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(configPathShowDetails, defaults.show_details, {
|
||||
label: localize('config.common.controls.thumbnails.show_details'),
|
||||
})}
|
||||
${this._renderSwitch(
|
||||
configPathShowFavoriteControl,
|
||||
this._defaults.live.controls.thumbnails.show_favorite_control,
|
||||
defaults.show_favorite_control,
|
||||
{
|
||||
label: localize('config.common.controls.thumbnails.show_favorite_control'),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
configPathShowTimelineControl,
|
||||
this._defaults.live.controls.thumbnails.show_timeline_control,
|
||||
defaults.show_timeline_control,
|
||||
{
|
||||
label: localize('config.common.controls.thumbnails.show_timeline_control'),
|
||||
},
|
||||
@@ -1066,6 +1110,31 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the thumbnails controls.
|
||||
* @param domain The submenu domain.
|
||||
* @param configPathMode Filter mode config path.
|
||||
* @returns A rendered template.
|
||||
*/
|
||||
protected _renderFilterControls(
|
||||
domain: string,
|
||||
configPathMode: string,
|
||||
): TemplateResult | void {
|
||||
return this._putInSubmenu(
|
||||
domain,
|
||||
true,
|
||||
'config.common.controls.filter.editor_label',
|
||||
{ name: 'mdi:filter-cog' },
|
||||
html`
|
||||
${configPathMode
|
||||
? html`${this._renderOptionSelector(configPathMode, this._filterModes, {
|
||||
label: localize('config.common.controls.filter.mode'),
|
||||
})}`
|
||||
: html``}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the titles controls.
|
||||
* @param domain The submenu domain.
|
||||
@@ -1570,6 +1639,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_LIVE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
this._defaults.live.controls.thumbnails,
|
||||
{
|
||||
configPathMedia: CONF_LIVE_CONTROLS_THUMBNAILS_MEDIA,
|
||||
configPathMode: CONF_LIVE_CONTROLS_THUMBNAILS_MODE,
|
||||
@@ -1617,6 +1687,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
this._defaults.event_gallery.controls.thumbnails,
|
||||
)}
|
||||
${this._renderFilterControls(
|
||||
MENU_EVENT_GALLERY_CONTROLS_FILTER,
|
||||
CONF_EVENT_GALLERY_CONTROLS_FILTER_MODE,
|
||||
)}
|
||||
</div>`
|
||||
: ''}
|
||||
@@ -1675,6 +1750,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
this._defaults.media_viewer.controls.thumbnails,
|
||||
{
|
||||
configPathMode: CONF_MEDIA_VIEWER_CONTROLS_THUMBNAILS_MODE,
|
||||
},
|
||||
@@ -1735,6 +1811,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_DETAILS,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_FAVORITE_CONTROL,
|
||||
CONF_TIMELINE_CONTROLS_THUMBNAILS_SHOW_TIMELINE_CONTROL,
|
||||
this._defaults.timeline.controls.thumbnails,
|
||||
{
|
||||
configPathMode: CONF_TIMELINE_CONTROLS_THUMBNAILS_MODE,
|
||||
},
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
"frigate_card": "Frigate card",
|
||||
"frigate_card_description": "A Lovelace card for use with Frigate",
|
||||
"live": "Live",
|
||||
"no_clip": "No recent clip",
|
||||
"no_clips": "No clips",
|
||||
"no_snapshot": "No recent snapshot",
|
||||
"no_snapshots": "No snapshots",
|
||||
"no_recording": "No recent recording",
|
||||
"no_media": "No media to display",
|
||||
"recordings": "Recordings",
|
||||
"version": "Version"
|
||||
},
|
||||
@@ -83,6 +79,15 @@
|
||||
"window_seconds": "The default length of the timeline view in seconds"
|
||||
},
|
||||
"controls": {
|
||||
"filter": {
|
||||
"editor_label": "Media Filter",
|
||||
"mode": "Filter mode",
|
||||
"modes": {
|
||||
"none": "No media filter",
|
||||
"left": "Media filter in a drawer to the left",
|
||||
"right": "Media filter in a drawer to the right"
|
||||
}
|
||||
},
|
||||
"next_previous": {
|
||||
"editor_label": "Next & Previous",
|
||||
"size": "Next & previous control size in pixels",
|
||||
@@ -333,7 +338,6 @@
|
||||
"could_not_render_elements": "Could not render picture elements",
|
||||
"could_not_resolve": "Could not resolve media URL",
|
||||
"diagnostics": "Card diagnostics. Please review for confidential information prior to sharing",
|
||||
"download_no_event_id": "Could not extract Frigate event id from media",
|
||||
"download_no_media": "No media to download",
|
||||
"download_sign_failed": "Could not sign media URL for download",
|
||||
"duplicate_camera_id": "Duplicate Frigate camera id for the following camera, use the 'id' parameter to uniquely identify cameras",
|
||||
@@ -369,6 +373,33 @@
|
||||
"start": "Start",
|
||||
"seek": "Seek"
|
||||
},
|
||||
"media_filter": {
|
||||
"all": "All",
|
||||
"camera": "Camera",
|
||||
"favorite": "Favorite",
|
||||
"media_type": "Media Type",
|
||||
"media_types": {
|
||||
"clips": "Clips",
|
||||
"snapshots": "Snapshots",
|
||||
"recordings": "Recordings"
|
||||
},
|
||||
"not_favorite": "Not Favorite",
|
||||
"select_camera": "Select camera...",
|
||||
"select_favorite": "Select favorite...",
|
||||
"select_media_type": "Select media type...",
|
||||
"select_what": "Select what...",
|
||||
"select_when": "Select when...",
|
||||
"select_where": "Select where...",
|
||||
"what": "What",
|
||||
"when": "When",
|
||||
"whens": {
|
||||
"past_month": "Past Month",
|
||||
"past_week": "Past Week",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
},
|
||||
"where": "Where"
|
||||
},
|
||||
"recording": {
|
||||
"events": "Events",
|
||||
"seek": "Seek"
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"frigate_card": "Frigate card",
|
||||
"frigate_card_description": "Una scheda Lovelace per l'uso con Frigate",
|
||||
"live": "Live",
|
||||
"no_clip": "Nessuna clip recente",
|
||||
"no_clips": "Nessun clip",
|
||||
"no_snapshot": "Nessuna istantanea recente",
|
||||
"no_snapshots": "Nessuna istantanea",
|
||||
"no_media": "",
|
||||
"recordings": "Registrazioni",
|
||||
"version": "Versione"
|
||||
},
|
||||
@@ -81,6 +78,15 @@
|
||||
"window_seconds": "La lunghezza predefinita della vista della sequenza temporale in secondi"
|
||||
},
|
||||
"controls": {
|
||||
"filter": {
|
||||
"editor_label": "",
|
||||
"mode": "",
|
||||
"modes": {
|
||||
"none": "",
|
||||
"left": "",
|
||||
"right": ""
|
||||
}
|
||||
},
|
||||
"next_previous": {
|
||||
"editor_label": "",
|
||||
"size": "Successiva e Precedenti dimensioni di controllo nei pixel",
|
||||
@@ -303,7 +309,6 @@
|
||||
"could_not_render_elements": "Impossibile renderizzare gli elementi dell'immagine",
|
||||
"could_not_resolve": "Impossibile risolvere l'URL dei media",
|
||||
"diagnostics": "Diagnostica delle carte.Si prega di rivedere per informazioni riservate prima di condividere",
|
||||
"download_no_event_id": "Impossibile estrarre l'evento ID tramite media",
|
||||
"download_no_media": "Nessun media da scaricare",
|
||||
"download_sign_failed": "Impossibile firmare URL multimediale per il download",
|
||||
"duplicate_camera_id": "Duplicato ID dellla telecamera Frigate, utilizzare il parametro 'ID' per identificare in modo univoco le telecamere",
|
||||
@@ -339,6 +344,33 @@
|
||||
"start": "Avvia",
|
||||
"seek": "Cercare"
|
||||
},
|
||||
"media_filter": {
|
||||
"all": "",
|
||||
"camera": "",
|
||||
"favorite": "",
|
||||
"media_type": "",
|
||||
"media_types": {
|
||||
"clips": "",
|
||||
"snapshots": "",
|
||||
"recordings": ""
|
||||
},
|
||||
"not_favorite": "",
|
||||
"select_camera": "",
|
||||
"select_favorite": "",
|
||||
"select_media_type": "",
|
||||
"select_what": "",
|
||||
"select_when": "",
|
||||
"select_where": "",
|
||||
"what": "",
|
||||
"when": "",
|
||||
"whens": {
|
||||
"past_month": "",
|
||||
"past_week": "",
|
||||
"today": "",
|
||||
"yesterday": ""
|
||||
},
|
||||
"where": ""
|
||||
},
|
||||
"recording": {
|
||||
"events": "Eventi",
|
||||
"seek": "Cercare"
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"frigate_card": "Cartão Frigate",
|
||||
"frigate_card_description": "Um cartão da Lovelace para usar com Frigate",
|
||||
"live": "Ao Vivo",
|
||||
"no_clip": "Sem clip recente",
|
||||
"no_clips": "Sem clips",
|
||||
"no_snapshot": "Sem snapshot recente",
|
||||
"no_snapshots": "Sem snapshots",
|
||||
"no_media": "",
|
||||
"recordings": "Gravações",
|
||||
"version": "Versão"
|
||||
},
|
||||
@@ -81,6 +78,15 @@
|
||||
"window_seconds": "A duração padrão da visualização da linha do tempo em segundos"
|
||||
},
|
||||
"controls": {
|
||||
"filter": {
|
||||
"editor_label": "",
|
||||
"mode": "",
|
||||
"modes": {
|
||||
"none": "",
|
||||
"left": "",
|
||||
"right": ""
|
||||
}
|
||||
},
|
||||
"next_previous": {
|
||||
"editor_label": "",
|
||||
"size": "Tamanho de controle próximo e anterior",
|
||||
@@ -303,7 +309,6 @@
|
||||
"could_not_render_elements": "Não foi possível renderizar os elementos da imagem",
|
||||
"could_not_resolve": "Não foi possível resolver o URL de mídia",
|
||||
"diagnostics": "Diagnósticos do cartão. Revise as informações confidenciais antes de compartilhar",
|
||||
"download_no_event_id": "Não foi possível extrair o Frigate ID do evento da mídia",
|
||||
"download_no_media": "Nenhuma mídia para download",
|
||||
"download_sign_failed": "Não foi possível assinar o URL de mídia para download",
|
||||
"duplicate_camera_id": "Duplique o ID da câmera Frigate para a câmera a seguir, use o parâmetro 'id' para identificar exclusivamente as câmeras",
|
||||
@@ -339,6 +344,33 @@
|
||||
"start": "Início",
|
||||
"seek": "Procurar"
|
||||
},
|
||||
"media_filter": {
|
||||
"all": "",
|
||||
"camera": "",
|
||||
"favorite": "",
|
||||
"media_type": "",
|
||||
"media_types": {
|
||||
"clips": "",
|
||||
"snapshots": "",
|
||||
"recordings": ""
|
||||
},
|
||||
"not_favorite": "",
|
||||
"select_camera": "",
|
||||
"select_favorite": "",
|
||||
"select_media_type": "",
|
||||
"select_what": "",
|
||||
"select_when": "",
|
||||
"select_where": "",
|
||||
"what": "",
|
||||
"when": "",
|
||||
"whens": {
|
||||
"past_month": "",
|
||||
"past_week": "",
|
||||
"today": "",
|
||||
"yesterday": ""
|
||||
},
|
||||
"where": ""
|
||||
},
|
||||
"recording": {
|
||||
"events": "Eventos",
|
||||
"seek": "Procurar"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { GrSelect } from '@graphiteds/core/components/gr-select';
|
||||
import { GrMenuItem } from '@graphiteds/core/components/gr-menu-item';
|
||||
|
||||
// It was difficult to find a multi-select web component that matches these criteria:
|
||||
// - Open source.
|
||||
// - Supports being in a ScopedRegistry out of the box (i.e. does not auto-register with customElements).
|
||||
// - Looks attractive / compatible with mostly Material elements.
|
||||
// - Styleable
|
||||
// - Does not bloat output size considerably.
|
||||
|
||||
// Web components evaluated (https://open-wc.org/guides/community/component-libraries/):
|
||||
// - Material: No multiselect component.
|
||||
// - Freshwords/@crayon: Considerable bloat in output due to i18n translations
|
||||
// that are used by _other_ components.
|
||||
// - Carbon Design System: Workable, but less moderm / Material-like.
|
||||
// - UI5: Auto-registers globally.
|
||||
// - Vaadin: Auto-registers globally.
|
||||
// - Liquid: Not open source.
|
||||
// - [Many others]: No multiselect component.
|
||||
|
||||
export const grSelectElements = {
|
||||
'gr-select': GrSelect,
|
||||
'gr-menu-item': GrMenuItem,
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
.dotdotdot:after {
|
||||
@keyframes dots {
|
||||
0%,
|
||||
20% {
|
||||
content: '.';
|
||||
}
|
||||
40% {
|
||||
content: '..';
|
||||
}
|
||||
60% {
|
||||
content: '...';
|
||||
}
|
||||
90%,
|
||||
100% {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
animation: dots 2s linear infinite;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 3em;
|
||||
}
|
||||
+5
-31
@@ -14,7 +14,7 @@
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
grid-auto-rows: min-content;
|
||||
gap: var(--frigate-card-gallery-gap);
|
||||
}
|
||||
|
||||
@@ -23,36 +23,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
:host ha-card {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
color: var(--primary-text-color, white);
|
||||
border: 1px solid var(--primary-color);
|
||||
border-radius: var(--frigate-card-css-border-radius, var(--ha-card-border-radius, 4px));
|
||||
|
||||
// Folder background color should match the thumbnail element background
|
||||
// color.
|
||||
background-color: var(--primary-background-color, black);
|
||||
padding: 10px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
|
||||
// Some themes override these values (with high CSS specificity) which is
|
||||
// intended for full cards rather than internal ones. Since we use multiple of
|
||||
// these for folders, we cannot use an ID and need to resort to !important.
|
||||
margin: 0px !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
transition: transform 0.2s linear;
|
||||
}
|
||||
ha-card:hover {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
ha-card,
|
||||
frigate-card-thumbnail {
|
||||
height: 100%;
|
||||
max-height: var(--frigate-card-thumbnail-size);
|
||||
@@ -60,3 +30,7 @@ frigate-card-thumbnail {
|
||||
frigate-card-thumbnail:not([details]) {
|
||||
width: 100%;
|
||||
}
|
||||
frigate-card-progress-indicator {
|
||||
// Progress indicator spans the grid.
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
:host {
|
||||
display: flex;
|
||||
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
|
||||
// Hide scrollbar: Firefox
|
||||
scrollbar-width: none;
|
||||
// Hide scrollbar: IE and Edge
|
||||
-ms-overflow-style: none;
|
||||
|
||||
height: 100%;
|
||||
width: 300px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
:host::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
frigate-card-select {
|
||||
padding: 5px;
|
||||
}
|
||||
+3
-25
@@ -1,3 +1,5 @@
|
||||
@use 'dotdotdot.scss';
|
||||
|
||||
:host {
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
@@ -56,28 +58,4 @@ div.message div.icon {
|
||||
|
||||
.message ha-icon, ha-circular-progress {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dotdotdot:after {
|
||||
@keyframes dots {
|
||||
0%,
|
||||
20% {
|
||||
content: '.';
|
||||
}
|
||||
40% {
|
||||
content: '..';
|
||||
}
|
||||
60% {
|
||||
content: '...';
|
||||
}
|
||||
90%,
|
||||
100% {
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
|
||||
animation: dots 2s linear infinite;
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 3em;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@use '@graphiteds/core/css/core.css';
|
||||
|
||||
:host {
|
||||
// The graphite css (above) loads variables into :root, which is lost in the
|
||||
// shadow DOM, so copy them into the host.
|
||||
@extend :root;
|
||||
}
|
||||
+88
-249
@@ -27,6 +27,7 @@ export const THUMBNAIL_WIDTH_MIN = 75;
|
||||
*/
|
||||
|
||||
export type ClipsOrSnapshots = 'clips' | 'snapshots';
|
||||
export type ClipsOrSnapshotsOrAll = 'clips' | 'snapshots' | 'all';
|
||||
|
||||
export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
|
||||
'live',
|
||||
@@ -63,7 +64,7 @@ const FRIGATE_MENU_STYLES = [
|
||||
const FRIGATE_MENU_POSITIONS = ['left', 'right', 'top', 'bottom'] as const;
|
||||
const FRIGATE_MENU_ALIGNMENTS = FRIGATE_MENU_POSITIONS;
|
||||
|
||||
export const FRIGATE_MENU_PRIORITY_DEFAULT = 50;
|
||||
const FRIGATE_MENU_PRIORITY_DEFAULT = 50;
|
||||
export const FRIGATE_MENU_PRIORITY_MAX = 100;
|
||||
|
||||
const LIVE_PROVIDERS = ['auto', 'image', 'ha', 'frigate-jsmpeg', 'webrtc-card'] as const;
|
||||
@@ -130,7 +131,7 @@ const actionBaseSchema = z.object({
|
||||
// HA accepts either a boolean or a ConfirmationRestrictionConfig object.
|
||||
// `custom-card-helpers` currently only supports the latter. For maximum
|
||||
// compatibility, this card supports what HA supports.
|
||||
export interface ExtendedConfirmationRestrictionConfig {
|
||||
interface ExtendedConfirmationRestrictionConfig {
|
||||
confirmation?: boolean | ConfirmationRestrictionConfig;
|
||||
}
|
||||
|
||||
@@ -379,7 +380,7 @@ const customSchema = z
|
||||
/**
|
||||
* Camera configuration section
|
||||
*/
|
||||
export const cameraConfigDefault = {
|
||||
const cameraConfigDefault = {
|
||||
live_provider: 'auto' as const,
|
||||
frigate: {
|
||||
client_id: 'frigate' as const,
|
||||
@@ -459,12 +460,12 @@ const menuBaseSchema = z.object({
|
||||
icon: z.string().optional(),
|
||||
});
|
||||
|
||||
export const menuIconSchema = menuBaseSchema.merge(iconSchema).extend({
|
||||
const menuIconSchema = menuBaseSchema.merge(iconSchema).extend({
|
||||
type: z.literal('custom:frigate-card-menu-icon'),
|
||||
});
|
||||
export type MenuIcon = z.infer<typeof menuIconSchema>;
|
||||
|
||||
export const menuStateIconSchema = menuBaseSchema
|
||||
const menuStateIconSchema = menuBaseSchema
|
||||
.merge(stateIconSchema)
|
||||
.extend({
|
||||
type: z.literal('custom:frigate-card-menu-state-icon'),
|
||||
@@ -572,7 +573,6 @@ const pictureElementSchema = z.union([
|
||||
conditionalSchema,
|
||||
customSchema,
|
||||
]);
|
||||
export type PictureElement = z.infer<typeof pictureElementSchema>;
|
||||
|
||||
const pictureElementsSchema = pictureElementSchema.array().optional();
|
||||
export type PictureElements = z.infer<typeof pictureElementsSchema>;
|
||||
@@ -642,7 +642,7 @@ const viewConfigSchema = z
|
||||
* Image view configuration section.
|
||||
*/
|
||||
|
||||
export const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const;
|
||||
const IMAGE_MODES = ['screensaver', 'camera', 'url'] as const;
|
||||
const imageConfigDefault = {
|
||||
mode: 'url' as const,
|
||||
refresh_seconds: 0,
|
||||
@@ -661,13 +661,30 @@ export type ImageViewConfig = z.infer<typeof imageConfigSchema>;
|
||||
/**
|
||||
* Thumbnail controls configuration section.
|
||||
*/
|
||||
const thumbnailControlsDefaults = {
|
||||
mode: 'right' as const,
|
||||
size: 100,
|
||||
show_details: true,
|
||||
show_favorite_control: true,
|
||||
show_timeline_control: true,
|
||||
};
|
||||
|
||||
const thumbnailsControlSchema = z.object({
|
||||
mode: z.enum(['none', 'above', 'below', 'left', 'right']),
|
||||
size: z.number().min(THUMBNAIL_WIDTH_MIN).max(THUMBNAIL_WIDTH_MAX).optional(),
|
||||
show_details: z.boolean().optional(),
|
||||
show_favorite_control: z.boolean().optional(),
|
||||
show_timeline_control: z.boolean().optional(),
|
||||
mode: z
|
||||
.enum(['none', 'above', 'below', 'left', 'right'])
|
||||
.default(thumbnailControlsDefaults.mode),
|
||||
size: z
|
||||
.number()
|
||||
.min(THUMBNAIL_WIDTH_MIN)
|
||||
.max(THUMBNAIL_WIDTH_MAX)
|
||||
.default(thumbnailControlsDefaults.size),
|
||||
show_details: z.boolean().default(thumbnailControlsDefaults.show_details),
|
||||
show_favorite_control: z
|
||||
.boolean()
|
||||
.default(thumbnailControlsDefaults.show_favorite_control),
|
||||
show_timeline_control: z
|
||||
.boolean()
|
||||
.default(thumbnailControlsDefaults.show_timeline_control),
|
||||
});
|
||||
export type ThumbnailsControlConfig = z.infer<typeof thumbnailsControlSchema>;
|
||||
|
||||
@@ -683,8 +700,6 @@ const timelineCoreConfigDefault = {
|
||||
};
|
||||
|
||||
const timelineMediaSchema = z.enum(['all', 'clips', 'snapshots']);
|
||||
export type TimelineMedia = z.infer<typeof timelineMediaSchema>;
|
||||
|
||||
const timelineCoreConfigSchema = z.object({
|
||||
clustering_threshold: z
|
||||
.number()
|
||||
@@ -752,6 +767,11 @@ const liveImageConfigDefault = {
|
||||
refresh_seconds: 1,
|
||||
};
|
||||
|
||||
const liveThumbnailControlsDefaults = {
|
||||
...thumbnailControlsDefaults,
|
||||
media: 'all' as const,
|
||||
};
|
||||
|
||||
const liveConfigDefault = {
|
||||
auto_play: 'all' as const,
|
||||
auto_pause: 'never' as const,
|
||||
@@ -769,14 +789,7 @@ const liveConfigDefault = {
|
||||
size: 48,
|
||||
style: 'chevrons' as const,
|
||||
},
|
||||
thumbnails: {
|
||||
media: 'clips' as const,
|
||||
size: 100,
|
||||
show_details: true,
|
||||
show_favorite_control: true,
|
||||
show_timeline_control: true,
|
||||
mode: 'left' as const,
|
||||
},
|
||||
thumbnails: liveThumbnailControlsDefaults,
|
||||
timeline: miniTimelineConfigDefault,
|
||||
title: {
|
||||
mode: 'popup-bottom-right' as const,
|
||||
@@ -785,6 +798,12 @@ const liveConfigDefault = {
|
||||
},
|
||||
};
|
||||
|
||||
const livethumbnailsControlSchema = thumbnailsControlSchema.extend({
|
||||
media: z
|
||||
.enum(['all', 'clips', 'snapshots'])
|
||||
.default(liveConfigDefault.controls.thumbnails.media),
|
||||
});
|
||||
|
||||
const liveImageConfigSchema = z.object({
|
||||
refresh_seconds: z.number().min(0).default(liveConfigDefault.image.refresh_seconds),
|
||||
});
|
||||
@@ -834,30 +853,9 @@ const liveOverridableConfigSchema = z
|
||||
),
|
||||
})
|
||||
.default(liveConfigDefault.controls.next_previous),
|
||||
thumbnails: thumbnailsControlSchema
|
||||
.extend({
|
||||
mode: thumbnailsControlSchema.shape.mode.default(
|
||||
liveConfigDefault.controls.thumbnails.mode,
|
||||
),
|
||||
size: thumbnailsControlSchema.shape.size.default(
|
||||
liveConfigDefault.controls.thumbnails.size,
|
||||
),
|
||||
show_details: thumbnailsControlSchema.shape.show_details.default(
|
||||
liveConfigDefault.controls.thumbnails.show_details,
|
||||
),
|
||||
show_favorite_control:
|
||||
thumbnailsControlSchema.shape.show_favorite_control.default(
|
||||
liveConfigDefault.controls.thumbnails.show_favorite_control,
|
||||
),
|
||||
show_timeline_control:
|
||||
thumbnailsControlSchema.shape.show_timeline_control.default(
|
||||
liveConfigDefault.controls.thumbnails.show_timeline_control,
|
||||
),
|
||||
media: z
|
||||
.enum(['clips', 'snapshots'])
|
||||
.default(liveConfigDefault.controls.thumbnails.media),
|
||||
})
|
||||
.default(liveConfigDefault.controls.thumbnails),
|
||||
thumbnails: livethumbnailsControlSchema.default(
|
||||
liveConfigDefault.controls.thumbnails,
|
||||
),
|
||||
timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline),
|
||||
title: titleControlConfigSchema
|
||||
.extend({
|
||||
@@ -994,13 +992,7 @@ const viewerConfigDefault = {
|
||||
size: 48,
|
||||
style: 'thumbnails' as const,
|
||||
},
|
||||
thumbnails: {
|
||||
size: 100,
|
||||
show_details: true,
|
||||
show_favorite_control: true,
|
||||
show_timeline_control: true,
|
||||
mode: 'left' as const,
|
||||
},
|
||||
thumbnails: thumbnailControlsDefaults,
|
||||
timeline: miniTimelineConfigDefault,
|
||||
title: {
|
||||
mode: 'popup-bottom-right' as const,
|
||||
@@ -1016,9 +1008,6 @@ const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.ex
|
||||
viewerConfigDefault.controls.next_previous.size,
|
||||
),
|
||||
});
|
||||
export type ViewerNextPreviousControlConfig = z.infer<
|
||||
typeof viewerNextPreviousControlConfigSchema
|
||||
>;
|
||||
|
||||
const viewerConfigSchema = z
|
||||
.object({
|
||||
@@ -1047,27 +1036,9 @@ const viewerConfigSchema = z
|
||||
next_previous: viewerNextPreviousControlConfigSchema.default(
|
||||
viewerConfigDefault.controls.next_previous,
|
||||
),
|
||||
thumbnails: thumbnailsControlSchema
|
||||
.extend({
|
||||
mode: thumbnailsControlSchema.shape.mode.default(
|
||||
viewerConfigDefault.controls.thumbnails.mode,
|
||||
),
|
||||
size: thumbnailsControlSchema.shape.size.default(
|
||||
viewerConfigDefault.controls.thumbnails.size,
|
||||
),
|
||||
show_details: thumbnailsControlSchema.shape.show_details.default(
|
||||
viewerConfigDefault.controls.thumbnails.show_details,
|
||||
),
|
||||
show_favorite_control:
|
||||
thumbnailsControlSchema.shape.show_favorite_control.default(
|
||||
viewerConfigDefault.controls.thumbnails.show_favorite_control,
|
||||
),
|
||||
show_timeline_control:
|
||||
thumbnailsControlSchema.shape.show_timeline_control.default(
|
||||
viewerConfigDefault.controls.thumbnails.show_timeline_control,
|
||||
),
|
||||
})
|
||||
.default(viewerConfigDefault.controls.thumbnails),
|
||||
thumbnails: thumbnailsControlSchema.default(
|
||||
viewerConfigDefault.controls.thumbnails,
|
||||
),
|
||||
timeline: miniTimelineConfigSchema.default(
|
||||
viewerConfigDefault.controls.timeline,
|
||||
),
|
||||
@@ -1092,41 +1063,38 @@ export type ViewerConfig = z.infer<typeof viewerConfigSchema>;
|
||||
/**
|
||||
* Event gallery configuration section (clips, snapshots).
|
||||
*/
|
||||
const galleryThumbnailControlsDefaults = {
|
||||
...thumbnailControlsDefaults,
|
||||
show_details: false,
|
||||
};
|
||||
|
||||
const galleryConfigDefault = {
|
||||
controls: {
|
||||
thumbnails: {
|
||||
size: 100,
|
||||
show_details: false,
|
||||
show_favorite_control: true,
|
||||
show_timeline_control: true,
|
||||
thumbnails: galleryThumbnailControlsDefaults,
|
||||
filter: {
|
||||
mode: 'right' as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const gallerythumbnailsControlSchema = thumbnailsControlSchema.extend({
|
||||
show_details: z.boolean().default(galleryThumbnailControlsDefaults.show_details),
|
||||
});
|
||||
|
||||
const galleryConfigSchema = z
|
||||
.object({
|
||||
controls: z
|
||||
.object({
|
||||
thumbnails: thumbnailsControlSchema
|
||||
// Gallery shows thumbnails "centrally" so no need for the mode.
|
||||
.omit({ mode: true })
|
||||
.extend({
|
||||
size: thumbnailsControlSchema.shape.size.default(
|
||||
galleryConfigDefault.controls.thumbnails.size,
|
||||
),
|
||||
show_details: thumbnailsControlSchema.shape.show_details.default(
|
||||
galleryConfigDefault.controls.thumbnails.show_details,
|
||||
),
|
||||
show_favorite_control:
|
||||
thumbnailsControlSchema.shape.show_favorite_control.default(
|
||||
galleryConfigDefault.controls.thumbnails.show_favorite_control,
|
||||
),
|
||||
show_timeline_control:
|
||||
thumbnailsControlSchema.shape.show_timeline_control.default(
|
||||
galleryConfigDefault.controls.thumbnails.show_timeline_control,
|
||||
),
|
||||
thumbnails: gallerythumbnailsControlSchema.default(
|
||||
galleryConfigDefault.controls.thumbnails,
|
||||
),
|
||||
filter: z
|
||||
.object({
|
||||
mode: z
|
||||
.enum(['none', 'left', 'right'])
|
||||
.default(galleryConfigDefault.controls.filter.mode),
|
||||
})
|
||||
.default(galleryConfigDefault.controls.thumbnails),
|
||||
.default(galleryConfigDefault.controls.filter),
|
||||
})
|
||||
.default(galleryConfigDefault.controls),
|
||||
})
|
||||
@@ -1166,13 +1134,7 @@ const dimensionsConfigSchema = z
|
||||
const timelineConfigDefault = {
|
||||
...timelineCoreConfigDefault,
|
||||
controls: {
|
||||
thumbnails: {
|
||||
mode: 'left' as const,
|
||||
size: 100,
|
||||
show_details: true,
|
||||
show_favorite_control: true,
|
||||
show_timeline_control: true,
|
||||
},
|
||||
thumbnails: thumbnailControlsDefaults,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1180,27 +1142,9 @@ const timelineConfigSchema = timelineCoreConfigSchema
|
||||
.extend({
|
||||
controls: z
|
||||
.object({
|
||||
thumbnails: thumbnailsControlSchema
|
||||
.extend({
|
||||
mode: thumbnailsControlSchema.shape.mode.default(
|
||||
timelineConfigDefault.controls.thumbnails.mode,
|
||||
),
|
||||
size: thumbnailsControlSchema.shape.size.default(
|
||||
timelineConfigDefault.controls.thumbnails.size,
|
||||
),
|
||||
show_details: thumbnailsControlSchema.shape.show_details.default(
|
||||
timelineConfigDefault.controls.thumbnails.show_details,
|
||||
),
|
||||
show_favorite_control:
|
||||
thumbnailsControlSchema.shape.show_favorite_control.default(
|
||||
timelineConfigDefault.controls.thumbnails.show_favorite_control,
|
||||
),
|
||||
show_timeline_control:
|
||||
thumbnailsControlSchema.shape.show_timeline_control.default(
|
||||
timelineConfigDefault.controls.thumbnails.show_timeline_control,
|
||||
),
|
||||
})
|
||||
.default(timelineConfigDefault.controls.thumbnails),
|
||||
thumbnails: thumbnailsControlSchema.default(
|
||||
timelineConfigDefault.controls.thumbnails,
|
||||
),
|
||||
})
|
||||
.default(timelineConfigDefault.controls),
|
||||
})
|
||||
@@ -1269,8 +1213,20 @@ const performanceConfigSchema = z
|
||||
.default(performanceConfigDefault);
|
||||
export type PerformanceConfig = z.infer<typeof performanceConfigSchema>;
|
||||
|
||||
const debugConfigDefault = {
|
||||
logging: false,
|
||||
};
|
||||
|
||||
const debugConfigSchema = z
|
||||
.object({
|
||||
logging: z.boolean().default(debugConfigDefault.logging),
|
||||
})
|
||||
.default(debugConfigDefault);
|
||||
type DebugConfig = z.infer<typeof debugConfigSchema>;
|
||||
|
||||
export interface CardWideConfig {
|
||||
performance?: PerformanceConfig;
|
||||
debug?: DebugConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1289,6 +1245,7 @@ export const frigateCardConfigSchema = z.object({
|
||||
dimensions: dimensionsConfigSchema,
|
||||
timeline: timelineConfigSchema,
|
||||
performance: performanceConfigSchema,
|
||||
debug: debugConfigSchema,
|
||||
|
||||
// Configuration overrides.
|
||||
overrides: overridesSchema,
|
||||
@@ -1314,6 +1271,7 @@ export const frigateCardConfigDefaults = {
|
||||
image: imageConfigDefault,
|
||||
timeline: timelineConfigDefault,
|
||||
performance: performanceConfigDefault,
|
||||
debug: debugConfigDefault,
|
||||
};
|
||||
|
||||
const menuButtonSchema = z.discriminatedUnion('type', [
|
||||
@@ -1330,48 +1288,6 @@ export interface ExtendedHomeAssistant extends HomeAssistant {
|
||||
};
|
||||
}
|
||||
|
||||
export interface BrowseMediaQueryParameters {
|
||||
// ========================================
|
||||
// Parameters used to construct media query
|
||||
// ========================================
|
||||
mediaType?: 'clips' | 'snapshots';
|
||||
clientId: string;
|
||||
cameraName: string;
|
||||
label?: string;
|
||||
zone?: string;
|
||||
before?: number;
|
||||
after?: number;
|
||||
unlimited?: boolean;
|
||||
|
||||
// ========================================
|
||||
// Parameters used to differentiate results
|
||||
// ========================================
|
||||
// Optional title to be used for separating results when merging multiple
|
||||
// sets of results. See `mergeFrigateBrowseMediaSources()` .
|
||||
title?: string;
|
||||
|
||||
// Optional camera-id to which this query is associated. May be used to map
|
||||
// results to a particular camera within the card.
|
||||
cameraID?: string;
|
||||
}
|
||||
|
||||
export interface BrowseRecordingQueryParameters {
|
||||
clientId: string;
|
||||
cameraName: string;
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
hour: number;
|
||||
}
|
||||
|
||||
export interface BrowseMediaNeighbors {
|
||||
previous: FrigateBrowseMediaSource | null;
|
||||
previousIndex: number | null;
|
||||
|
||||
next: FrigateBrowseMediaSource | null;
|
||||
nextIndex: number | null;
|
||||
}
|
||||
|
||||
export interface MediaLoadedInfo {
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -1424,83 +1340,6 @@ export interface CardHelpers {
|
||||
* Home Assistant API types.
|
||||
*/
|
||||
|
||||
export const MEDIA_CLASS_PLAYLIST = 'playlist' as const;
|
||||
export const MEDIA_CLASS_VIDEO = 'video' as const;
|
||||
export const MEDIA_TYPE_PLAYLIST = 'playlist' as const;
|
||||
export const MEDIA_TYPE_IMAGE = 'image' as const;
|
||||
export const MEDIA_TYPE_VIDEO = 'video' as const;
|
||||
|
||||
// Recursive type, cannot use type interference:
|
||||
// See: https://github.com/colinhacks/zod#recursive-types
|
||||
//
|
||||
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L46
|
||||
interface BrowseMediaSource {
|
||||
title: string;
|
||||
media_class: string;
|
||||
media_content_type: string;
|
||||
media_content_id: string;
|
||||
can_play: boolean;
|
||||
can_expand: boolean;
|
||||
children_media_class?: string | null;
|
||||
thumbnail: string | null;
|
||||
children?: BrowseMediaSource[] | null;
|
||||
}
|
||||
|
||||
export interface FrigateRecording {
|
||||
// Frigate camera name (may not be unique)
|
||||
camera: string;
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
events: number;
|
||||
}
|
||||
|
||||
export interface FrigateBrowseMediaSource extends BrowseMediaSource {
|
||||
children?: FrigateBrowseMediaSource[] | null;
|
||||
frigate?: {
|
||||
event?: FrigateEvent;
|
||||
recording?: FrigateRecording;
|
||||
cameraID?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const frigateEventSchema = z.object({
|
||||
camera: z.string(),
|
||||
end_time: z.number().nullable(),
|
||||
false_positive: z.boolean().nullable(),
|
||||
has_clip: z.boolean(),
|
||||
has_snapshot: z.boolean(),
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
start_time: z.number(),
|
||||
top_score: z.number(),
|
||||
zones: z.string().array(),
|
||||
retain_indefinitely: z.boolean().optional(),
|
||||
});
|
||||
export type FrigateEvent = z.infer<typeof frigateEventSchema>;
|
||||
|
||||
export const frigateEventsSchema = frigateEventSchema.array();
|
||||
export type FrigateEvents = z.infer<typeof frigateEventsSchema>;
|
||||
|
||||
export const frigateBrowseMediaSourceSchema: z.ZodSchema<BrowseMediaSource> = z.lazy(
|
||||
() =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
media_class: z.string(),
|
||||
media_content_type: z.string(),
|
||||
media_content_id: z.string(),
|
||||
can_play: z.boolean(),
|
||||
can_expand: z.boolean(),
|
||||
children_media_class: z.string().nullable().optional(),
|
||||
thumbnail: z.string().nullable(),
|
||||
children: z.array(frigateBrowseMediaSourceSchema).nullable().optional(),
|
||||
frigate: z
|
||||
.object({
|
||||
event: frigateEventSchema,
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py
|
||||
export const resolvedMediaSchema = z.object({
|
||||
url: z.string(),
|
||||
@@ -1513,7 +1352,7 @@ export const signedPathSchema = z.object({
|
||||
});
|
||||
export type SignedPath = z.infer<typeof signedPathSchema>;
|
||||
|
||||
export const entitySchema = z.object({
|
||||
const entitySchema = z.object({
|
||||
config_entry_id: z.string().nullable(),
|
||||
disabled_by: z.string().nullable(),
|
||||
entity_id: z.string(),
|
||||
|
||||
+91
-11
@@ -1,10 +1,15 @@
|
||||
import differenceInHours from 'date-fns/differenceInHours';
|
||||
import differenceInMinutes from 'date-fns/differenceInMinutes';
|
||||
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
||||
import format from 'date-fns/format';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { FrigateCardError } from '../types';
|
||||
|
||||
export type ModifyInterface<T, R> = Omit<T, keyof R> & R;
|
||||
|
||||
/**
|
||||
* Dispatch a Frigate Card event.
|
||||
* @param element The element to send the event.
|
||||
* @param target The target from which send the event.
|
||||
* @param name The name of the Frigate card event to send.
|
||||
* @param detail An optional detail object to attach.
|
||||
*/
|
||||
@@ -27,6 +32,8 @@ export function dispatchFrigateCardEvent<T>(
|
||||
* @param input The input Frigate (camera/label/zone) name.
|
||||
* @returns A prettified name.
|
||||
*/
|
||||
export function prettifyTitle(input: string): string;
|
||||
export function prettifyTitle(input?: string): string | undefined;
|
||||
export function prettifyTitle(input?: string): string | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
@@ -51,6 +58,24 @@ export function arrayMove(target: unknown[], from: number, to: number): void {
|
||||
target.splice(to, 0, element);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a value to an array if it is not already one.
|
||||
* @param value: A value (which may be an array).
|
||||
* @returns An array.
|
||||
*/
|
||||
export const arrayify = <T>(value: T | T[]): T[] => {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a value to an set if it is not already one.
|
||||
* @param value: A value (which may be a set, an array or a T)
|
||||
* @returns A set of T.
|
||||
*/
|
||||
export const setify = <T>(value: T | T[] | Set<T>): Set<T> => {
|
||||
return value instanceof Set ? value : new Set(arrayify(value));
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if the contents of the n(ew) and o(ld) values have changed. For use
|
||||
* in lit web components that may have a value that changes address but not
|
||||
@@ -68,10 +93,7 @@ export function contentsChanged(n: unknown, o: unknown): boolean {
|
||||
* @param e The Error object.
|
||||
* @param func The Console func to call.
|
||||
*/
|
||||
export function errorToConsole(e: Error, func?: CallableFunction): void {
|
||||
if (!func) {
|
||||
func = console.warn;
|
||||
}
|
||||
export function errorToConsole(e: Error, func: CallableFunction = console.warn): void {
|
||||
if (e instanceof FrigateCardError && e.context) {
|
||||
func(e, e.context);
|
||||
} else {
|
||||
@@ -83,9 +105,8 @@ export function errorToConsole(e: Error, func?: CallableFunction): void {
|
||||
* Determine if the device supports hovering.
|
||||
* @returns `true` if the device supports hovering, `false` otherwise.
|
||||
*/
|
||||
export const isHoverableDevice = (): boolean => window.matchMedia(
|
||||
'(hover: hover) and (pointer: fine)',
|
||||
).matches;
|
||||
export const isHoverableDevice = (): boolean =>
|
||||
window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||
|
||||
/**
|
||||
* Format a date object to RFC3339.
|
||||
@@ -94,7 +115,16 @@ export const isHoverableDevice = (): boolean => window.matchMedia(
|
||||
*/
|
||||
export const formatDateAndTime = (date: Date): string => {
|
||||
return format(date, 'yyyy-MM-dd HH:mm');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a date object to RFC3339.
|
||||
* @param date A Date object.
|
||||
* @returns A date.
|
||||
*/
|
||||
export const formatDate = (date: Date): string => {
|
||||
return format(date, 'yyyy-MM-dd');
|
||||
};
|
||||
|
||||
/**
|
||||
* Run a function in idle periods. If idle callbacks are not supported (e.g.
|
||||
@@ -105,9 +135,59 @@ export const formatDateAndTime = (date: Date): string => {
|
||||
export const runWhenIdleIfSupported = (func: () => void, timeout?: number): void => {
|
||||
if (window.requestIdleCallback) {
|
||||
window.requestIdleCallback(func, {
|
||||
...(timeout && { timeout: timeout})
|
||||
...(timeout && { timeout: timeout }),
|
||||
});
|
||||
} else {
|
||||
func();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience function to return a string representing the difference in hours,
|
||||
* minutes and seconds between two dates. Heavily inspired by, and returning the
|
||||
* same format as, the Frigate UI:
|
||||
* https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97
|
||||
* @param start The start date.
|
||||
* @param end The end date.
|
||||
* @returns A duration string.
|
||||
*/
|
||||
export function getDurationString(start: Date, end: Date): string {
|
||||
const hours = differenceInHours(end, start);
|
||||
const minutes = differenceInMinutes(end, start) - hours * 60;
|
||||
const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60;
|
||||
let duration = '';
|
||||
|
||||
if (hours) {
|
||||
duration += `${hours}h `;
|
||||
}
|
||||
if (minutes) {
|
||||
duration += `${minutes}m `;
|
||||
}
|
||||
duration += `${seconds}s`;
|
||||
return duration;
|
||||
}
|
||||
|
||||
export const allPromises = async <T>(
|
||||
items: T[],
|
||||
func: (arg: T) => void,
|
||||
): Promise<void> => {
|
||||
await Promise.all(Array.from(items).map((item) => func(item)));
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple efficient YYYY-MM-DD -> date converter.
|
||||
*/
|
||||
export const dayToDate = (day: string): Date => {
|
||||
// Must provide the hour:minute:second on parsing or Javascript will assume
|
||||
// *UTC* midnight.
|
||||
return new Date(`${day}T00:00:00`);
|
||||
};
|
||||
|
||||
export const isSuperset = (superset: Set<unknown>, subset: Set<unknown>) => {
|
||||
for (const item of subset) {
|
||||
if (!superset.has(item)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
+6
-87
@@ -1,7 +1,4 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { CameraConfig, RawFrigateCardConfig } from '../types.js';
|
||||
import { prettifyTitle } from './basic.js';
|
||||
import { getEntityIcon, getEntityTitle } from './ha';
|
||||
|
||||
/**
|
||||
* Get a camera id.
|
||||
@@ -26,53 +23,6 @@ export function getCameraID(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a camera text title.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param config The camera config (either parsed or raw).
|
||||
* @returns A title string.
|
||||
*/
|
||||
export function getCameraTitle(
|
||||
hass?: HomeAssistant,
|
||||
config?: CameraConfig | RawFrigateCardConfig | null,
|
||||
): string {
|
||||
// Attempt to render a recognizable name for the camera,
|
||||
// starting with the most likely to be useful and working our
|
||||
// ways towards the least useful. Extra type checking here since this is also
|
||||
// used on raw configuration in the editor.
|
||||
return (
|
||||
(typeof config?.title === 'string' && config.title) ||
|
||||
(typeof config?.camera_entity === 'string'
|
||||
? getEntityTitle(hass, config.camera_entity)
|
||||
: '') ||
|
||||
(typeof config?.webrtc_card === 'object' &&
|
||||
config.webrtc_card &&
|
||||
typeof config.webrtc_card['entity'] === 'string' &&
|
||||
config.webrtc_card['entity']) ||
|
||||
(typeof config?.frigate === 'object' &&
|
||||
config.frigate &&
|
||||
typeof config?.frigate['camera_name'] === 'string' &&
|
||||
config.frigate['camera_name']
|
||||
? prettifyTitle(config.frigate['camera_name'])
|
||||
: '') ||
|
||||
(typeof config?.id === 'string' && config.id) ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a camera icon.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param config The camera config.
|
||||
* @returns An icon string.
|
||||
*/
|
||||
export function getCameraIcon(
|
||||
hass?: HomeAssistant,
|
||||
config?: CameraConfig | null,
|
||||
): string {
|
||||
return config?.icon || getEntityIcon(hass, config?.camera_entity) || 'mdi:video';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cameras that depend on a given camera.
|
||||
* @param cameras Cameras map.
|
||||
@@ -81,13 +31,13 @@ export function getCameraIcon(
|
||||
*/
|
||||
export const getAllDependentCameras = (
|
||||
cameras: Map<string, CameraConfig>,
|
||||
camera?: string,
|
||||
cameraID?: string,
|
||||
): Set<string> => {
|
||||
const cameraIDs: Set<string> = new Set();
|
||||
const getDependentCameras = (camera: string): void => {
|
||||
const cameraConfig = cameras.get(camera);
|
||||
const getDependentCameras = (cameraID: string): void => {
|
||||
const cameraConfig = cameras.get(cameraID);
|
||||
if (cameraConfig) {
|
||||
cameraIDs.add(camera);
|
||||
cameraIDs.add(cameraID);
|
||||
const dependentCameras: Set<string> = new Set();
|
||||
(cameraConfig.dependencies.cameras || []).forEach((item) =>
|
||||
dependentCameras.add(item),
|
||||
@@ -102,39 +52,8 @@ export const getAllDependentCameras = (
|
||||
}
|
||||
}
|
||||
};
|
||||
if (camera) {
|
||||
getDependentCameras(camera);
|
||||
if (cameraID) {
|
||||
getDependentCameras(cameraID);
|
||||
}
|
||||
return cameraIDs;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the cameraIDs of truly unique cameras (some configured cameras may be
|
||||
* the same Frigate came but with different zone/labels).
|
||||
* @param cameras The full set of cameras.
|
||||
* @param cameraIDs The specific IDs to dedup.
|
||||
*/
|
||||
export const getTrueCameras = (
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
): Set<string> => {
|
||||
const getTrueCameraID = (cameraConfig: CameraConfig): string => {
|
||||
return `${cameraConfig.frigate?.client_id ?? ''}/${
|
||||
cameraConfig.frigate.camera_name ?? ''
|
||||
}`;
|
||||
};
|
||||
|
||||
const output = new Set<string>();
|
||||
const visitedTrueCameras = new Set<string>();
|
||||
cameraIDs.forEach((cameraID: string) => {
|
||||
const cameraConfig = cameras.get(cameraID) ?? null;
|
||||
if (cameraConfig && cameraConfig.frigate.camera_name) {
|
||||
const trueCameraID = getTrueCameraID(cameraConfig);
|
||||
if (!visitedTrueCameras.has(trueCameraID)) {
|
||||
output.add(cameraID);
|
||||
visitedTrueCameras.add(trueCameraID);
|
||||
}
|
||||
}
|
||||
});
|
||||
return output;
|
||||
};
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { DataSet, DataView } from 'vis-data/esnext';
|
||||
import type { IdType, TimelineItem } from 'vis-timeline/esnext';
|
||||
import { CAMERA_BIRDSEYE } from '../const.js';
|
||||
import {
|
||||
CameraConfig,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardError,
|
||||
FrigateEvent,
|
||||
FrigateEvents,
|
||||
} from '../types.js';
|
||||
import { errorToConsole, runWhenIdleIfSupported } from './basic.js';
|
||||
import {
|
||||
FrigateGetEventsParameters,
|
||||
getEventsMultiple,
|
||||
getRecordingSegments,
|
||||
getRecordingsSummary,
|
||||
RecordingSegments,
|
||||
RecordingSummary,
|
||||
} from './frigate.js';
|
||||
import { dispatchFrigateCardErrorEvent } from '../components/message.js';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
|
||||
const RECORDING_SEGMENT_TOLERANCE = 60;
|
||||
const DATA_MANAGER_MAX_AGE_SECONDS = 10;
|
||||
const DATA_MANAGER_MAX_FETCH_COUNT = 10000;
|
||||
|
||||
export interface FrigateCardTimelineItem extends TimelineItem {
|
||||
// DataView has issues using datasets with Date objects, so avoid them and use
|
||||
// numbers instead.
|
||||
start: number;
|
||||
end?: number;
|
||||
event?: FrigateEvent;
|
||||
}
|
||||
|
||||
type TimelineMediaType = 'all' | 'clips' | 'snapshots';
|
||||
|
||||
export interface RecordingSegmentsItem {
|
||||
id: string;
|
||||
cameraID: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the timeline items most recent to least recent.
|
||||
* @param a The first item.
|
||||
* @param b The second item.
|
||||
* @returns -1, 0, 1 (standard array sort function configuration).
|
||||
*/
|
||||
export const sortYoungestToOldest = (
|
||||
a: RecordingSegmentsItem | FrigateCardTimelineItem,
|
||||
b: RecordingSegmentsItem | FrigateCardTimelineItem,
|
||||
): number => {
|
||||
if (a.start < b.start) {
|
||||
return 1;
|
||||
}
|
||||
if (a.start > b.start) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort the segments least recent to most recent.
|
||||
* @param a The first item.
|
||||
* @param b The second item.
|
||||
* @returns -1, 0, 1 (standard array sort function configuration).
|
||||
*/
|
||||
export const sortOldestToYoungest = (
|
||||
a: RecordingSegmentsItem | FrigateCardTimelineItem,
|
||||
b: RecordingSegmentsItem | FrigateCardTimelineItem,
|
||||
): number => {
|
||||
if (a.start < b.start) {
|
||||
return -1;
|
||||
}
|
||||
if (a.start > b.start) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* A manager to maintain/fetch timeline events.
|
||||
*/
|
||||
export class DataManager {
|
||||
protected _recordingSummary: Map<string, RecordingSummary | null> = new Map();
|
||||
protected _recordingSegments = new DataSet<RecordingSegmentsItem>();
|
||||
|
||||
protected _dataset = new DataSet<FrigateCardTimelineItem>();
|
||||
|
||||
// The earliest date managed.
|
||||
protected _dateStart: Date | null = null;
|
||||
|
||||
// The latest date managed.
|
||||
protected _dateEnd: Date | null = null;
|
||||
|
||||
// The last fetch date.
|
||||
protected _dateFetch: Date | null = null;
|
||||
|
||||
// The maximum allowable age of fetch data (will not fetch more frequently
|
||||
// than this).
|
||||
protected _maxAgeSeconds: number = DATA_MANAGER_MAX_AGE_SECONDS;
|
||||
|
||||
protected _cameras: Map<string, CameraConfig>;
|
||||
|
||||
// Garbage collect segments at most once an hour.
|
||||
protected _throttledSegmentGarbageCollector = throttle(
|
||||
() => {
|
||||
runWhenIdleIfSupported(this._garbageCollectSegments.bind(this));
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
{ trailing: true },
|
||||
);
|
||||
|
||||
constructor(cameras: Map<string, CameraConfig>) {
|
||||
this._cameras = cameras;
|
||||
}
|
||||
|
||||
// Get the last event fetch date.
|
||||
get lastFetchDate(): Date | null {
|
||||
return this._dateFetch ?? null;
|
||||
}
|
||||
|
||||
public getRecordingSummaryForCamera(cameraID: string): RecordingSummary | null {
|
||||
return this._recordingSummary.get(cameraID) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dataview for a given set of camera.
|
||||
* @param cameraIDs The cameraIDs to include.
|
||||
* @param showRecordings Whether or not to show recordings.
|
||||
* @returns A dataview.
|
||||
*/
|
||||
public createDataView(
|
||||
cameraIDs: Set<string>,
|
||||
showRecordings: boolean,
|
||||
mediaType: TimelineMediaType,
|
||||
): DataView<FrigateCardTimelineItem> {
|
||||
return new DataView(this._dataset, {
|
||||
filter: (item: FrigateCardTimelineItem) =>
|
||||
// Only return items for the given cameras.
|
||||
!!item.group &&
|
||||
cameraIDs.has(String(item.group)) &&
|
||||
// Don't return recordings if the user does not want them.
|
||||
(showRecordings || item.type !== 'background') &&
|
||||
// Don't return events that are the wrong media type.
|
||||
(item.type === 'background' ||
|
||||
mediaType === 'all' ||
|
||||
(mediaType === 'clips' && !!item.event?.has_clip) ||
|
||||
(mediaType === 'snapshots' && !!item.event?.has_snapshot)),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dataview for segments.
|
||||
* @returns A dataview.
|
||||
*/
|
||||
public createSegmentDataView(): DataView<RecordingSegmentsItem> {
|
||||
return new DataView(this._recordingSegments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying recording segments dataset.
|
||||
*/
|
||||
get recordingSegments(): DataSet<RecordingSegmentsItem> {
|
||||
return this._recordingSegments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite an item as-is. May be useful in cases where clustering may need to
|
||||
* be recalculated.
|
||||
* @param id The id to rewrite.
|
||||
*/
|
||||
public rewriteItem(id: IdType): void {
|
||||
// Hack: Clustering may not update unless the dataset changes, artifically
|
||||
// update the dataset to ensure the newly selected item cannot be included
|
||||
// in a cluster.
|
||||
const item = this._dataset.get(id);
|
||||
if (item) {
|
||||
this._dataset.updateOnly(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add events for the given camera.
|
||||
* @param cameraID The camera ID.
|
||||
* @param events The array of events.
|
||||
*/
|
||||
protected _addEvents(cameraID: string, events: FrigateEvents): void {
|
||||
this._dataset.update(
|
||||
events.map((event) => ({
|
||||
id: event.id,
|
||||
group: cameraID,
|
||||
content: '',
|
||||
event: event,
|
||||
start: event.start_time * 1000,
|
||||
type: event.end_time ? 'range' : 'point',
|
||||
...(event.end_time && { end: event.end_time * 1000 }),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the timeline has coverage for a given range of dates.
|
||||
* @param start The start of the date range.
|
||||
* @param end An optional end of the date range.
|
||||
* @returns
|
||||
*/
|
||||
public hasCoverage(now: Date, start: Date, end?: Date): boolean {
|
||||
// Never fetched: no coverage.
|
||||
if (!this._dateFetch || !this._dateStart || !this._dateEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the most recent fetch is older than maxAgeSeconds: no coverage.
|
||||
if (
|
||||
this._maxAgeSeconds &&
|
||||
now.getTime() - this._dateFetch.getTime() > this._maxAgeSeconds * 1000
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the most requested data is earlier than the earliest stored: no
|
||||
// coverage.
|
||||
if (start < this._dateStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If there's no end time specified: there IS coverage.
|
||||
if (!end) {
|
||||
return true;
|
||||
}
|
||||
// If the requested end time is older than the oldest requested: there IS
|
||||
// coverage.
|
||||
if (end.getTime() < this._dateEnd.getTime()) {
|
||||
return true;
|
||||
}
|
||||
// If there's no maxAgeSeconds specified: no coverage.
|
||||
if (!this._maxAgeSeconds) {
|
||||
return false;
|
||||
}
|
||||
// If the requested end time is beyond `_maxAgeSeconds` of now: no coverage.
|
||||
if (now.getTime() - end.getTime() > this._maxAgeSeconds * 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// End time is within `_maxAgeSeconds` of the latest data: there IS
|
||||
// coverage.
|
||||
return end.getTime() - this._maxAgeSeconds * 1000 <= this._dateEnd.getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events if no coverage in given range.
|
||||
* @param element The element to send error events from.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param start Fetch events that start later than this date.
|
||||
* @param end Fetch events that start earlier than this date.
|
||||
* @returns `true` if events were fetched, `false` otherwise.
|
||||
*/
|
||||
public async fetchIfNecessary(
|
||||
element: HTMLElement,
|
||||
hass: ExtendedHomeAssistant,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<boolean> {
|
||||
// Cannot fetch the future, always clip the end date to now so as to avoid
|
||||
// checking for coverage that could not possibly exist yet.
|
||||
const now = new Date();
|
||||
end = end > now ? now : end;
|
||||
|
||||
if (this.hasCoverage(now, start, end)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const oldStart = this._dateStart;
|
||||
const oldEnd = this._dateEnd;
|
||||
let segmentStart: Date | null = null;
|
||||
let segmentEnd: Date | null = null;
|
||||
if (!this._dateStart || start < this._dateStart) {
|
||||
this._dateStart = start;
|
||||
segmentStart = start;
|
||||
} else {
|
||||
segmentStart = oldEnd ?? end;
|
||||
}
|
||||
if (!this._dateEnd || end > this._dateEnd) {
|
||||
this._dateEnd = end;
|
||||
segmentEnd = end;
|
||||
} else {
|
||||
segmentEnd = oldStart ?? start;
|
||||
}
|
||||
|
||||
this._dateFetch = new Date();
|
||||
|
||||
await Promise.all([
|
||||
// Events are always fetched for the maximum extent of the managed
|
||||
// range. This is because events may change at any point in time
|
||||
// (e.g. a long-running event that ends).
|
||||
this._fetchEvents(element, hass, this._dateStart, this._dateEnd),
|
||||
this._fetchRecordingSummary(hass),
|
||||
...(segmentEnd > segmentStart
|
||||
? [this._fetchRecordingSegments(hass, segmentStart, segmentEnd)]
|
||||
: []),
|
||||
]);
|
||||
|
||||
this._throttledSegmentGarbageCollector();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage collect recording segments that no longer feature in the summary.
|
||||
*/
|
||||
protected _garbageCollectSegments(): void {
|
||||
if (!this._recordingSegments || !this._recordingSummary) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Performance: _recordingSegments is potentially very large (e.g. 10K - 1M
|
||||
// items) and each item must be examined, so care required here to stick to
|
||||
// nothing worse than O(n) performance.
|
||||
const getHourID = (cameraID: string, day: number, hour: number): string => {
|
||||
return `${cameraID}/${day}/${hour}`;
|
||||
};
|
||||
|
||||
const goodHours: Set<string> = new Set();
|
||||
for (const cameraID of this._recordingSummary.keys()) {
|
||||
for (const summaryDay of this._recordingSummary?.get(cameraID) ?? []) {
|
||||
for (const summaryHour of summaryDay.hours) {
|
||||
goodHours.add(getHourID(cameraID, summaryDay.day.getDate(), summaryHour.hour));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deleteIDs: string[] = [];
|
||||
this._recordingSegments.forEach((item, id) => {
|
||||
const startDate = fromUnixTime(item.start / 1000);
|
||||
const hourID = getHourID(item.cameraID, startDate.getDate(), startDate.getHours());
|
||||
|
||||
// ~O(1) lookup time for a JS set.
|
||||
if (!goodHours.has(hourID)) {
|
||||
deleteIDs.push(String(id));
|
||||
}
|
||||
});
|
||||
|
||||
this._recordingSegments.remove(deleteIDs);
|
||||
this._compressRecordingSegmentsOntoTimeline();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recording segments for cameras.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param start Fetch segments that start later than this date.
|
||||
* @param end Fetch segments that start earlier than this date.
|
||||
*/
|
||||
protected async _fetchRecordingSegments(
|
||||
hass: ExtendedHomeAssistant,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<void> {
|
||||
const results: Map<string, RecordingSegments> = new Map();
|
||||
const fetch = async (camera: string, config?: CameraConfig): Promise<void> => {
|
||||
if (!config || !config.frigate.camera_name || !hass) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cameraResults = await getRecordingSegments(
|
||||
hass,
|
||||
config.frigate.client_id,
|
||||
config.frigate.camera_name,
|
||||
end,
|
||||
start,
|
||||
);
|
||||
results.set(camera, cameraResults);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from(this._cameras.keys()).map((camera) =>
|
||||
fetch(camera, this._cameras.get(camera)),
|
||||
),
|
||||
);
|
||||
|
||||
const items: RecordingSegmentsItem[] = [];
|
||||
results.forEach((segments, cameraID) => {
|
||||
segments.forEach((segment) => {
|
||||
items.push({
|
||||
id: `${cameraID}/${segment.id}`,
|
||||
cameraID: cameraID,
|
||||
start: segment.start_time * 1000,
|
||||
end: segment.end_time * 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
this._recordingSegments.update(items);
|
||||
this._compressRecordingSegmentsOntoTimeline();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress recording segments into recordings shown on the timeline
|
||||
* background.
|
||||
*/
|
||||
protected _compressRecordingSegmentsOntoTimeline(): void {
|
||||
if (!this._recordingSegments.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete all the existing background.
|
||||
this._dataset.remove(
|
||||
this._dataset.get({
|
||||
filter: (item) => item.type === 'background',
|
||||
}),
|
||||
);
|
||||
|
||||
const convertToRecording = (
|
||||
segment: RecordingSegmentsItem,
|
||||
): FrigateCardTimelineItem => {
|
||||
return {
|
||||
id: `recording-${segment.cameraID}-${segment.id}`,
|
||||
group: segment.cameraID,
|
||||
start: segment.start,
|
||||
end: segment.end,
|
||||
content: ' ',
|
||||
type: 'background',
|
||||
};
|
||||
};
|
||||
|
||||
// Iterate through the segments least to most recent, effectively joining
|
||||
// segments together that are within a certain tolerance to create large
|
||||
// blocks that are visualized on the timeline as recordings.
|
||||
const recordings: FrigateCardTimelineItem[] = [];
|
||||
|
||||
this._cameras.forEach((_, cameraID) => {
|
||||
const segments = this._recordingSegments.get({
|
||||
filter: (item) => item.cameraID === cameraID,
|
||||
order: sortOldestToYoungest,
|
||||
});
|
||||
let current: RecordingSegmentsItem | null = null;
|
||||
for (let i = 0; i < segments.length; ++i) {
|
||||
const item = segments[i];
|
||||
|
||||
if (!current) {
|
||||
current = { ...item };
|
||||
} else if (current.end + RECORDING_SEGMENT_TOLERANCE * 1000 >= item.start) {
|
||||
current.end = item.end;
|
||||
} else {
|
||||
recordings.push(convertToRecording(current));
|
||||
current = null;
|
||||
}
|
||||
if (i === segments.length - 1 && current) {
|
||||
recordings.push(convertToRecording(current));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._dataset.update(recordings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recording summary.
|
||||
* @param hass The HomeAssistant object.
|
||||
*/
|
||||
protected async _fetchRecordingSummary(hass: ExtendedHomeAssistant): Promise<void> {
|
||||
const storeRecordingSummary = async (
|
||||
cameraID: string,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<void> => {
|
||||
if (!cameraConfig.frigate.camera_name) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._recordingSummary.set(
|
||||
cameraID,
|
||||
await getRecordingsSummary(
|
||||
hass,
|
||||
cameraConfig.frigate.client_id,
|
||||
cameraConfig.frigate.camera_name,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// Recording failure should not disrupt the rest of the timeline
|
||||
// experience.
|
||||
errorToConsole(e as Error);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
Array.from(this._cameras.keys()).map(async (cameraID) => {
|
||||
const cameraConfig = this._cameras.get(cameraID);
|
||||
if (cameraConfig) {
|
||||
await storeRecordingSummary(cameraID, cameraConfig);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch events for the timeline.
|
||||
* @param element The element to send error events from.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param start Fetch events that start later than this date.
|
||||
* @param end Fetch events that start earlier than this date.
|
||||
*/
|
||||
protected async _fetchEvents(
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<void> {
|
||||
const params: Map<string, FrigateGetEventsParameters> = new Map();
|
||||
|
||||
this._cameras.forEach((cameraConfig, cameraID) => {
|
||||
if (
|
||||
cameraConfig.frigate.camera_name &&
|
||||
cameraConfig.frigate.camera_name !== CAMERA_BIRDSEYE
|
||||
) {
|
||||
params.set(cameraID, {
|
||||
instance_id: cameraConfig.frigate.client_id,
|
||||
camera: cameraConfig.frigate.camera_name,
|
||||
...(cameraConfig.frigate.label && { label: cameraConfig.frigate.label }),
|
||||
...(cameraConfig.frigate.zone && { label: cameraConfig.frigate.zone }),
|
||||
before: Math.floor(end.getTime() / 1000),
|
||||
after: Math.floor(start.getTime() / 1000),
|
||||
limit: DATA_MANAGER_MAX_FETCH_COUNT,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let results: Map<string, FrigateEvents>;
|
||||
try {
|
||||
results = await getEventsMultiple(hass, params);
|
||||
} catch (e) {
|
||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||
}
|
||||
results.forEach((params, cameraID) => this._addEvents(cameraID, params));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { CardWideConfig } from '../types';
|
||||
|
||||
export const log = (cardWideConfig?: CardWideConfig, ...args: unknown[]) => {
|
||||
if (cardWideConfig?.debug?.logging) {
|
||||
console.debug(...args);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* For debug purposes only.
|
||||
* @param seconds
|
||||
*/
|
||||
// ts-prune-ignore-next
|
||||
export const sleep = async (seconds: number) => {
|
||||
await new Promise((r) => setTimeout(r, seconds * 1000));
|
||||
};
|
||||
@@ -1,309 +0,0 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import utcToZonedTime from 'date-fns-tz/utcToZonedTime';
|
||||
import differenceInHours from 'date-fns/differenceInHours';
|
||||
import differenceInMinutes from 'date-fns/differenceInMinutes';
|
||||
import differenceInSeconds from 'date-fns/differenceInSeconds';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import { z } from 'zod';
|
||||
import { localize } from '../localize/localize';
|
||||
import {
|
||||
BrowseRecordingQueryParameters,
|
||||
ClipsOrSnapshots,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardError,
|
||||
FrigateEvent,
|
||||
FrigateEvents,
|
||||
frigateEventsSchema,
|
||||
} from '../types';
|
||||
import { formatDateAndTime, prettifyTitle } from './basic';
|
||||
import { homeAssistantWSRequest } from './ha';
|
||||
|
||||
export const FRIGATE_ICON_SVG_PATH =
|
||||
'm 4.8759466,22.743573 c 0.0866,0.69274 0.811811,1.16359 0.37885,1.27183 ' +
|
||||
'-0.43297,0.10824 -2.32718,-3.43665 -2.7601492,-4.95202 -0.4329602,-1.51538 ' +
|
||||
'-0.6764993,-3.22017 -0.5682593,-4.19434 0.1082301,-0.97417 5.7097085,-2.48955 ' +
|
||||
'5.7097085,-2.89545 0,-0.4059 -1.81304,-0.0271 -1.89422,-0.35178 -0.0812,-0.32472 ' +
|
||||
'1.36925,-0.12989 1.75892,-0.64945 0.60885,-0.81181 1.3800713,-0.6765 1.8671505,' +
|
||||
'-1.1094696 0.4870902,-0.4329599 1.0824089,-2.0836399 1.1906589,-2.7871996 0.108241,' +
|
||||
'-0.70357 -1.0824084,-1.51538 -1.4071389,-2.05658 -0.3247195,-0.54121 0.7035702,' +
|
||||
'-0.92005 3.1931099,-1.94834 2.48954,-1.02829 10.39114,-3.30134994 10.49938,' +
|
||||
'-3.03074994 0.10824,0.27061 -2.59779,1.40713994 -4.492,2.11069994 -1.89422,0.70357 ' +
|
||||
'-4.97909,2.05658 -4.97909,2.43542 0,0.37885 0.16236,0.67651 0.0541,1.54244 -0.10824,' +
|
||||
'0.86593 -0.12123,1.2702597 -0.32472,1.8400997 -0.1353,0.37884 -0.2706,1.27183 ' +
|
||||
'0,2.0836295 0.21648,0.64945 0.92005,1.13653 1.24477,1.24478 0.2706,0.018 1.01746,' +
|
||||
'0.0433 1.8401,0 1.02829,-0.0541 2.48954,0.0541 2.48954,0.32472 0,0.2706 -2.21894,' +
|
||||
'0.10824 -2.21894,0.48708 0,0.37885 2.27306,-0.0541 2.21894,0.32473 -0.0541,0.37884 ' +
|
||||
'-1.89422,0.21648 -2.86839,0.21648 -0.77933,0 -1.93031,-0.0361 -2.43542,-0.21648 ' +
|
||||
'l -0.10824,0.37884 c -0.18038,0 -0.55744,0.10824 -0.94711,0.10824 -0.48708,0 ' +
|
||||
'-0.51414,0.16236 -1.40713,0.16236 -0.892989,0 -0.622391,-0.0541 -1.4341894,-0.10824 ' +
|
||||
'-0.81181,-0.0541 -3.842561,2.27306 -4.383761,3.03075 -0.54121,0.75768 ' +
|
||||
'-0.21649,2.59778 -0.21649,3.43665 0,0.75379 -0.10824,2.43542 0,3.30135 z';
|
||||
|
||||
const recordingSummaryHourSchema = z.object({
|
||||
hour: z.preprocess((arg) => Number(arg), z.number().min(0).max(23)),
|
||||
duration: z.number().min(0),
|
||||
events: z.number().min(0),
|
||||
});
|
||||
|
||||
const recordingSummarySchema = z
|
||||
.object({
|
||||
day: z.preprocess((arg) => {
|
||||
// Must provide the hour:minute:second on parsing or Javascript will
|
||||
// assume *UTC* midnight.
|
||||
return typeof arg === 'string' ? new Date(`${arg}T00:00:00`) : arg;
|
||||
}, z.date()),
|
||||
events: z.number(),
|
||||
hours: recordingSummaryHourSchema.array(),
|
||||
})
|
||||
.array();
|
||||
export type RecordingSummary = z.infer<typeof recordingSummarySchema>;
|
||||
|
||||
const recordingSegmentSchema = z.object({
|
||||
start_time: z.number(),
|
||||
end_time: z.number(),
|
||||
id: z.string(),
|
||||
});
|
||||
const recordingSegmentsSchema = recordingSegmentSchema.array();
|
||||
export type RecordingSegments = z.infer<typeof recordingSegmentsSchema>;
|
||||
|
||||
const retainResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
});
|
||||
export type RetainResult = z.infer<typeof retainResultSchema>;
|
||||
|
||||
/**
|
||||
* Get the recordings summary. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param client_id The Frigate client_id.
|
||||
* @param camera_name The Frigate camera name.
|
||||
* @returns A RecordingSummary object.
|
||||
*/
|
||||
export const getRecordingsSummary = async (
|
||||
hass: ExtendedHomeAssistant,
|
||||
client_id: string,
|
||||
camera_name: string,
|
||||
): Promise<RecordingSummary> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
recordingSummarySchema,
|
||||
{
|
||||
type: 'frigate/recordings/summary',
|
||||
instance_id: client_id,
|
||||
camera: camera_name,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the recording segments. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param client_id The Frigate client_id.
|
||||
* @param camera_name The Frigate camera name.
|
||||
* @param before The segment low watermark.
|
||||
* @param after The segment high watermark.
|
||||
* @returns A RecordingSegments object.
|
||||
*/
|
||||
export const getRecordingSegments = async (
|
||||
hass: ExtendedHomeAssistant,
|
||||
client_id: string,
|
||||
camera_name: string,
|
||||
before: Date,
|
||||
after: Date,
|
||||
): Promise<RecordingSegments> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
recordingSegmentsSchema,
|
||||
{
|
||||
type: 'frigate/recordings/get',
|
||||
instance_id: client_id,
|
||||
camera: camera_name,
|
||||
before: Math.floor(before.getTime() / 1000),
|
||||
after: Math.ceil(after.getTime() / 1000),
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Request that Frigate retain an event. May throw.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param client_id The Frigate client_id.
|
||||
* @param eventID The event ID to retain.
|
||||
* @param retain `true` to retain or `false` to unretain.
|
||||
*/
|
||||
export async function retainEvent(
|
||||
hass: HomeAssistant,
|
||||
client_id: string,
|
||||
eventID: string,
|
||||
retain: boolean,
|
||||
): Promise<void> {
|
||||
const retainRequest = {
|
||||
type: 'frigate/event/retain',
|
||||
instance_id: client_id,
|
||||
event_id: eventID,
|
||||
retain: retain,
|
||||
};
|
||||
const response = await homeAssistantWSRequest<RetainResult>(
|
||||
hass,
|
||||
retainResultSchema,
|
||||
retainRequest,
|
||||
true,
|
||||
);
|
||||
if (!response.success) {
|
||||
throw new FrigateCardError(localize('error.failed_retain'), {
|
||||
request: retainRequest,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface FrigateGetEventsParameters {
|
||||
instance_id?: string;
|
||||
camera?: string;
|
||||
label?: string;
|
||||
zone?: string;
|
||||
after?: number;
|
||||
before?: number;
|
||||
limit?: number;
|
||||
has_clip?: boolean;
|
||||
has_snapshot?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events over websocket. May throw.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param params The events search parameters.
|
||||
* @returns An array of 'FrigateEvent's.
|
||||
*/
|
||||
export const getEvents = async (
|
||||
hass: HomeAssistant,
|
||||
params?: FrigateGetEventsParameters,
|
||||
): Promise<FrigateEvents> => {
|
||||
return await homeAssistantWSRequest(
|
||||
hass,
|
||||
frigateEventsSchema,
|
||||
{
|
||||
type: 'frigate/events/get',
|
||||
...params,
|
||||
},
|
||||
true,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get multiple sets of events.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param params A Map of parameters keyed on any key.
|
||||
* @returns A Map of key -> events.
|
||||
*/
|
||||
export const getEventsMultiple = async <T>(
|
||||
hass: HomeAssistant,
|
||||
params: Map<T, FrigateGetEventsParameters>,
|
||||
): Promise<Map<T, FrigateEvents>> => {
|
||||
const output: Map<T, FrigateEvents> = new Map();
|
||||
const getEventsAndStore = async (
|
||||
key: T,
|
||||
param: FrigateGetEventsParameters,
|
||||
): Promise<void> => {
|
||||
output.set(key, await getEvents(hass, param));
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from(params).map(([key, param]) => getEventsAndStore(key, param)),
|
||||
);
|
||||
return output;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an event generate a title.
|
||||
* @param event
|
||||
*/
|
||||
export const getEventTitle = (event: FrigateEvent): string => {
|
||||
const localTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const durationSeconds = Math.round(
|
||||
event.end_time
|
||||
? event.end_time - event.start_time
|
||||
: Date.now() / 1000 - event.start_time,
|
||||
);
|
||||
return `${formatDateAndTime(
|
||||
utcToZonedTime(event.start_time * 1000, localTimezone),
|
||||
)} [${durationSeconds}s, ${prettifyTitle(event.label)} ${Math.round(
|
||||
event.top_score * 100,
|
||||
)}%]`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a thumbnail URL for an event.
|
||||
* @param clientId The Frigate client id.
|
||||
* @param event The event.
|
||||
* @returns A string URL.
|
||||
*/
|
||||
export const getEventThumbnailURL = (clientId: string, event: FrigateEvent): string => {
|
||||
return `/api/frigate/${clientId}/thumbnail/${event.id}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a media content ID for an event.
|
||||
* @param clientId The Frigate client id.
|
||||
* @param cameraName The Frigate camera name.
|
||||
* @param id The event id.
|
||||
* @param mediaType The media type required.
|
||||
* @returns A string media content id.
|
||||
*/
|
||||
export const getEventMediaContentID = (
|
||||
clientId: string,
|
||||
cameraName: string,
|
||||
id: string,
|
||||
mediaType: ClipsOrSnapshots,
|
||||
): string => {
|
||||
return `media-source://frigate/${clientId}/event/${mediaType}/${cameraName}/${id}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a recording identifier.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param params The recording parameters to use in the identifer.
|
||||
* @returns A recording identifier.
|
||||
*/
|
||||
export const getRecordingMediaContentID = (
|
||||
params: BrowseRecordingQueryParameters,
|
||||
): string => {
|
||||
return [
|
||||
'media-source://frigate',
|
||||
params.clientId,
|
||||
'recordings',
|
||||
`${params.year}-${String(params.month).padStart(2, '0')}`,
|
||||
String(params.day).padStart(2, '0'),
|
||||
String(params.hour).padStart(2, '0'),
|
||||
params.cameraName,
|
||||
].join('/');
|
||||
};
|
||||
|
||||
/**
|
||||
* Convenience function to convert a timestamp to hours, minutes and seconds
|
||||
* string. Heavily inspired by, and returning the same format as, the Frigate
|
||||
* UI: https://github.com/blakeblackshear/frigate/blob/master/web/src/components/RecordingPlaylist.jsx#L97
|
||||
* @param event The Frigate event.
|
||||
* @returns A duration string.
|
||||
*/
|
||||
export function getEventDurationString(event: FrigateEvent): string {
|
||||
if (!event.end_time) {
|
||||
return localize('event.in_progress');
|
||||
}
|
||||
const start = fromUnixTime(event.start_time);
|
||||
const end = fromUnixTime(event.end_time);
|
||||
const hours = differenceInHours(end, start);
|
||||
const minutes = differenceInMinutes(end, start) - hours * 60;
|
||||
const seconds = differenceInSeconds(end, start) - hours * 60 * 60 - minutes * 60;
|
||||
let duration = '';
|
||||
|
||||
if (hours) {
|
||||
duration += `${hours}h `;
|
||||
}
|
||||
if (minutes) {
|
||||
duration += `${minutes}m `;
|
||||
}
|
||||
duration += `${seconds}s`;
|
||||
return duration;
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { ViewContext } from 'view';
|
||||
import { homeAssistantWSRequest } from '.';
|
||||
import {
|
||||
dispatchErrorMessageEvent,
|
||||
dispatchFrigateCardErrorEvent,
|
||||
dispatchMessageEvent,
|
||||
} from '../../components/message.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import {
|
||||
BrowseMediaQueryParameters,
|
||||
BrowseRecordingQueryParameters,
|
||||
CameraConfig,
|
||||
ClipsOrSnapshots,
|
||||
FrigateBrowseMediaSource,
|
||||
frigateBrowseMediaSourceSchema,
|
||||
FrigateCardError,
|
||||
FrigateEvent,
|
||||
FrigateRecording,
|
||||
MEDIA_CLASS_PLAYLIST,
|
||||
MEDIA_CLASS_VIDEO,
|
||||
MEDIA_TYPE_PLAYLIST,
|
||||
MEDIA_TYPE_VIDEO,
|
||||
} from '../../types.js';
|
||||
import { View } from '../../view.js';
|
||||
import { getAllDependentCameras, getCameraTitle } from '../camera.js';
|
||||
|
||||
/**
|
||||
* Return the Frigate event_id given a FrigateBrowseMediaSource object.
|
||||
* @param media The event to get the id from.
|
||||
* @returns The `event_id` or `null` if not successfully parsed.
|
||||
*/
|
||||
export const getEventID = (media: FrigateBrowseMediaSource): string | null => {
|
||||
return media.frigate?.event?.id ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the event start time given a FrigateBrowseMediaSource object.
|
||||
* @param browseMedia The media object to get the start time from.
|
||||
* @returns The start time in unix/epoch time, or null if it cannot be determined.
|
||||
*/
|
||||
export const getEventStartTime = (media: FrigateBrowseMediaSource): number | null => {
|
||||
return media.frigate?.event?.start_time ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if a FrigateBrowseMediaSource object is truly a media item (vs a folder).
|
||||
* @param media The media object.
|
||||
* @returns `true` if it's truly a media item, `false` otherwise.
|
||||
*/
|
||||
export const isTrueMedia = (media?: FrigateBrowseMediaSource): boolean => {
|
||||
return !!media && !media.can_expand;
|
||||
};
|
||||
|
||||
/**
|
||||
* From a FrigateBrowseMediaSource item extract the first true media item from the
|
||||
* children (i.e. a clip/snapshot, not a folder).
|
||||
* @param media The media object with children.
|
||||
* @returns The first true media item found.
|
||||
*/
|
||||
export const getFirstTrueMediaChildIndex = (
|
||||
media: FrigateBrowseMediaSource | null,
|
||||
): number | null => {
|
||||
if (!media || !media.children) {
|
||||
return null;
|
||||
}
|
||||
const index = media.children.findIndex((child) => isTrueMedia(child));
|
||||
return index >= 0 ? index : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Browse Frigate media with a media content id. May throw.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param media_content_id The media content id to browse.
|
||||
* @returns A FrigateBrowseMediaSource object or null on malformed.
|
||||
*/
|
||||
const browseMedia = async (
|
||||
hass: HomeAssistant,
|
||||
media_content_id: string,
|
||||
): Promise<FrigateBrowseMediaSource> => {
|
||||
const request = {
|
||||
type: 'media_source/browse_media',
|
||||
media_content_id: media_content_id,
|
||||
};
|
||||
return await homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request);
|
||||
};
|
||||
|
||||
/**
|
||||
* Browse Frigate media with a media query. May throw.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param params The search parameters to use to search for media.
|
||||
* @returns A FrigateBrowseMediaSource object or null on malformed.
|
||||
*/
|
||||
const browseMediaQuery = async (
|
||||
hass: HomeAssistant,
|
||||
params: BrowseMediaQueryParameters,
|
||||
): Promise<FrigateBrowseMediaSource> => {
|
||||
const result = await browseMedia(
|
||||
hass,
|
||||
// Defined in:
|
||||
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
|
||||
[
|
||||
'media-source://frigate',
|
||||
params.clientId,
|
||||
'event-search',
|
||||
params.mediaType,
|
||||
|
||||
// If the name field ends in '.all' the integration will return up to 10K events.
|
||||
params.unlimited ? '.all' : '',
|
||||
params.after ? String(Math.floor(params.after)) : '',
|
||||
params.before ? String(Math.ceil(params.before)) : '',
|
||||
params.cameraName,
|
||||
params.label,
|
||||
params.zone,
|
||||
].join('/'),
|
||||
);
|
||||
// If a cameraID was specified, imprint each child with that id for
|
||||
// traceability.
|
||||
if (params.cameraID) {
|
||||
result.children?.forEach((child: FrigateBrowseMediaSource) => {
|
||||
(child.frigate ??= {}).cameraID = params.cameraID;
|
||||
})
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Browse multiple Frigate media queries. May throw.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param params An array of search parameters to use to search for media.
|
||||
* @returns A map of FrigateBrowseMediaSource object or null on malformed.
|
||||
*/
|
||||
export const multipleBrowseMediaQuery = async (
|
||||
hass: HomeAssistant,
|
||||
params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
|
||||
): Promise<Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>> => {
|
||||
params = Array.isArray(params) ? params : [params];
|
||||
const output: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource> = new Map();
|
||||
await Promise.all(
|
||||
params.map(async (param: BrowseMediaQueryParameters): Promise<void> => {
|
||||
output.set(param, await browseMediaQuery(hass, param));
|
||||
}),
|
||||
);
|
||||
return output;
|
||||
};
|
||||
|
||||
/**
|
||||
* Browse multiple Frigate media queries, then merged them. May throw.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param params An array of search parameters to use to search for media.
|
||||
* @returns A single FrigateBrowseMediaSource object or null on malformed.
|
||||
*/
|
||||
export const multipleBrowseMediaQueryMerged = async (
|
||||
hass: HomeAssistant,
|
||||
params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
|
||||
): Promise<FrigateBrowseMediaSource> => {
|
||||
return mergeFrigateBrowseMediaSources(await multipleBrowseMediaQuery(hass, params));
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge multiple FrigateBrowseMediaSource into a single. Note that this may
|
||||
* use information from the query to differentiate results that may otherwise
|
||||
* be identical.
|
||||
* @param input A map of query -> result.
|
||||
* @returns A single FrigateBrowseMediaSource object.
|
||||
*/
|
||||
export const mergeFrigateBrowseMediaSources = async (
|
||||
input: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>,
|
||||
): Promise<FrigateBrowseMediaSource> => {
|
||||
const children: FrigateBrowseMediaSource[] = [];
|
||||
|
||||
for (const [query, result] of input.entries()) {
|
||||
for (const child of result.children || []) {
|
||||
if (isTrueMedia(child)) {
|
||||
children.push(child);
|
||||
} else {
|
||||
// If there are multiple inputs, separate the folder names with the
|
||||
// query title (if available).
|
||||
if (query.title && input.size > 1) {
|
||||
children.push({ ...child, title: `[${query.title}] ${child.title}` });
|
||||
} else {
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return createEventParentForChildren('Merged events', children.sort(sortYoungestToOldest));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the parameters to search for media.
|
||||
* @returns A BrowseMediaQueryParameters object.
|
||||
*/
|
||||
export const getBrowseMediaQueryParameters = (
|
||||
hass: HomeAssistant,
|
||||
cameraID: string,
|
||||
cameraConfig?: CameraConfig,
|
||||
overrides?: Partial<BrowseMediaQueryParameters>,
|
||||
): BrowseMediaQueryParameters | null => {
|
||||
if (!cameraConfig || !cameraConfig.frigate.camera_name) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
clientId: cameraConfig.frigate.client_id,
|
||||
cameraName: cameraConfig.frigate.camera_name,
|
||||
label: cameraConfig.frigate.label,
|
||||
zone: cameraConfig.frigate.zone,
|
||||
title: getCameraTitle(hass, cameraConfig),
|
||||
cameraID: cameraID,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply overrides to multiple query parameters.
|
||||
* @param parameters An array of query parameters.
|
||||
* @param overrides The overrides to apply.
|
||||
* @returns The override query parameters.
|
||||
*/
|
||||
export const overrideMultiBrowseMediaQueryParameters = (
|
||||
parameters: BrowseMediaQueryParameters[],
|
||||
overrides: Partial<BrowseMediaQueryParameters>,
|
||||
): BrowseMediaQueryParameters[] => {
|
||||
const output: BrowseMediaQueryParameters[] = [];
|
||||
parameters.forEach((param) => {
|
||||
output.push({ ...param, ...overrides });
|
||||
});
|
||||
return output;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get BrowseMediaQueryParameters for a camera (including its dependencies).
|
||||
* @param hass Home Assistant object.
|
||||
* @param cameras Cameras map.
|
||||
* @param camera Name of the current camera.
|
||||
* @param mediaType Optional media type to include in the parameters.
|
||||
* @returns An array of query parameters.
|
||||
*/
|
||||
export const getFullDependentBrowseMediaQueryParameters = (
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
camera: string,
|
||||
mediaType?: ClipsOrSnapshots,
|
||||
): BrowseMediaQueryParameters[] | null => {
|
||||
const cameraIDs = getAllDependentCameras(cameras, camera);
|
||||
const params: BrowseMediaQueryParameters[] = [];
|
||||
for (const cameraID of cameraIDs) {
|
||||
const param = getBrowseMediaQueryParameters(
|
||||
hass,
|
||||
cameraID,
|
||||
cameras.get(cameraID),
|
||||
mediaType ? { mediaType: mediaType } : {},
|
||||
);
|
||||
if (param) {
|
||||
params.push(param);
|
||||
}
|
||||
}
|
||||
return params.length ? params : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get BrowseMediaQueryParameters for a camera (including its dependencies) or dispatch an error.
|
||||
* @param element The element from which to dispatch the error.
|
||||
* @param hass Home Assistant object.
|
||||
* @param cameras Cameras map.
|
||||
* @param camera Name of the current camera.
|
||||
* @param mediaType Optional media type to include in the parameters.
|
||||
* @returns An array of query parameters.
|
||||
*/
|
||||
export const getFullDependentBrowseMediaQueryParametersOrDispatchError = (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
camera: string,
|
||||
mediaType?: ClipsOrSnapshots,
|
||||
): BrowseMediaQueryParameters[] | null => {
|
||||
const params = getFullDependentBrowseMediaQueryParameters(
|
||||
hass,
|
||||
cameras,
|
||||
camera,
|
||||
mediaType,
|
||||
);
|
||||
if (!params) {
|
||||
dispatchErrorMessageEvent(element, localize('error.no_camera_name'), {
|
||||
context: cameras.get(camera),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the latest media and dispatch a change view event to reflect the
|
||||
* results. If no media is found a suitable message event will be triggered
|
||||
* instead.
|
||||
* @param element The HTMLElement to dispatch events from.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param view The current view to evolve.
|
||||
* @param browseMediaQueryParameters The media parameters to query with.
|
||||
* @returns
|
||||
*/
|
||||
export const fetchLatestMediaAndDispatchViewChange = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
view: Readonly<View>,
|
||||
browseMediaQueryParameters: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
|
||||
): Promise<void> => {
|
||||
let parent: FrigateBrowseMediaSource | null;
|
||||
try {
|
||||
parent = await multipleBrowseMediaQueryMerged(hass, browseMediaQueryParameters);
|
||||
} catch (e) {
|
||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||
}
|
||||
const childIndex = getFirstTrueMediaChildIndex(parent);
|
||||
if (!parent || !parent.children || childIndex == null) {
|
||||
return dispatchMessageEvent(
|
||||
element,
|
||||
view.isClipRelatedView()
|
||||
? localize('common.no_clip')
|
||||
: localize('common.no_snapshot'),
|
||||
'info',
|
||||
{
|
||||
icon: view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
view
|
||||
.evolve({
|
||||
target: parent,
|
||||
childIndex: childIndex,
|
||||
})
|
||||
.dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the media of a child FrigateBrowseMediaSource object and dispatch a change
|
||||
* view event to reflect the results.
|
||||
* @param node The HTMLElement to dispatch events from.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param view The current view to evolve.
|
||||
* @param child The FrigateBrowseMediaSource child to query for.
|
||||
* @returns
|
||||
*/
|
||||
export const fetchChildMediaAndDispatchViewChange = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
view: Readonly<View>,
|
||||
child: Readonly<FrigateBrowseMediaSource>,
|
||||
context?: ViewContext,
|
||||
): Promise<void> => {
|
||||
let parent: FrigateBrowseMediaSource;
|
||||
try {
|
||||
parent = await browseMedia(hass, child.media_content_id);
|
||||
} catch (e) {
|
||||
return dispatchFrigateCardErrorEvent(element, e as FrigateCardError);
|
||||
}
|
||||
|
||||
view
|
||||
.evolve({
|
||||
target: parent,
|
||||
})
|
||||
.mergeInContext(context)
|
||||
.dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an array of media children, create a parent for them.
|
||||
* @param title The title to use for the parent.
|
||||
* @param children The children media items.
|
||||
* @returns A single parent containing the children.
|
||||
*/
|
||||
export const createEventParentForChildren = (
|
||||
title: string,
|
||||
children: FrigateBrowseMediaSource[],
|
||||
): FrigateBrowseMediaSource => {
|
||||
return {
|
||||
title: title,
|
||||
media_class: MEDIA_CLASS_PLAYLIST,
|
||||
media_content_type: MEDIA_TYPE_PLAYLIST,
|
||||
media_content_id: '',
|
||||
can_play: false,
|
||||
can_expand: true,
|
||||
children_media_class: MEDIA_CLASS_PLAYLIST,
|
||||
thumbnail: null,
|
||||
children: children,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a media video child with a given media_content_id.
|
||||
* @param title The title to use for the child.
|
||||
* @param mediaContentID The media content id to use for the child.
|
||||
* @param children The children media items.
|
||||
* @returns A single parent containing the children.
|
||||
*/
|
||||
export const createChild = (
|
||||
title: string,
|
||||
mediaContentID: string,
|
||||
options?: {
|
||||
thumbnail?: string;
|
||||
recording?: FrigateRecording;
|
||||
event?: FrigateEvent;
|
||||
cameraID?: string,
|
||||
},
|
||||
): FrigateBrowseMediaSource => {
|
||||
const result: FrigateBrowseMediaSource = {
|
||||
title: title,
|
||||
media_class: MEDIA_CLASS_VIDEO,
|
||||
media_content_type: MEDIA_TYPE_VIDEO,
|
||||
media_content_id: mediaContentID,
|
||||
can_play: true,
|
||||
can_expand: false,
|
||||
thumbnail: options?.thumbnail ?? null,
|
||||
children: null
|
||||
}
|
||||
if (options?.recording || options?.cameraID || options?.event) {
|
||||
result.frigate = {}
|
||||
if (options?.event) {
|
||||
result.frigate.event = options.event;
|
||||
}
|
||||
if (options?.recording) {
|
||||
result.frigate.recording = options.recording;
|
||||
}
|
||||
if (options?.cameraID) {
|
||||
result.frigate.cameraID = options.cameraID;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort the timeline items most recent to least recent.
|
||||
* @param a The first item.
|
||||
* @param b The second item.
|
||||
* @returns -1, 0, 1 (standard array sort function configuration).
|
||||
*/
|
||||
export const sortYoungestToOldest = (
|
||||
a: FrigateBrowseMediaSource,
|
||||
b: FrigateBrowseMediaSource,
|
||||
): number => {
|
||||
const a_source = a.frigate?.event ?? a.frigate?.recording;
|
||||
const b_source = b.frigate?.event ?? b.frigate?.recording;
|
||||
|
||||
if (
|
||||
!a_source ||
|
||||
(b_source && b_source.start_time > a_source.start_time)
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (
|
||||
!b_source ||
|
||||
(a_source && b_source.start_time < a_source.start_time)
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
/**
|
||||
* Generate a recording identifier.
|
||||
* @param hass The HomeAssistant object.
|
||||
* @param params The recording parameters to use in the identifer.
|
||||
* @returns A recording identifier.
|
||||
*/
|
||||
export const generateRecordingIdentifier = (
|
||||
params: BrowseRecordingQueryParameters,
|
||||
): string => {
|
||||
return [
|
||||
'media-source://frigate',
|
||||
params.clientId,
|
||||
'recordings',
|
||||
params.cameraName,
|
||||
`${params.year}-${String(params.month).padStart(2, '0')}-${String(
|
||||
params.day,
|
||||
).padStart(2, '0')}`,
|
||||
String(params.hour).padStart(2, '0'),
|
||||
].join('/');
|
||||
};
|
||||
@@ -7,7 +7,7 @@ const deviceSchema = z.object({
|
||||
config_entries: z.string().array(),
|
||||
manufacturer: z.string().nullable(),
|
||||
})
|
||||
export const deviceListSchema = deviceSchema.array();
|
||||
const deviceListSchema = deviceSchema.array();
|
||||
export type DeviceList = z.infer<typeof deviceListSchema>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -205,7 +205,7 @@ function computeStyle(state: HassEntity): StyleInfo {
|
||||
* @param stateObj The HassEntity object from `hass.states`.
|
||||
* @returns A string state, e.g. 'on'.
|
||||
*/
|
||||
export const computeActiveState = (stateObj: HassEntity): string => {
|
||||
const computeActiveState = (stateObj: HassEntity): string => {
|
||||
const domain = stateObj.entity_id.split('.')[0];
|
||||
let state = stateObj.state;
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import QuickLRU from 'quick-lru';
|
||||
import { homeAssistantWSRequest } from '.';
|
||||
import {
|
||||
FrigateBrowseMediaSource,
|
||||
ResolvedMedia,
|
||||
resolvedMediaSchema,
|
||||
} from '../../types.js';
|
||||
import { ResolvedMedia, resolvedMediaSchema } from '../../types.js';
|
||||
import { errorToConsole } from '../basic';
|
||||
|
||||
// It's important the cache size be at least as large as the largest likely
|
||||
@@ -53,25 +49,22 @@ export class ResolvedMediaCache {
|
||||
/**
|
||||
* Resolve a given media source item.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param mediaSource The media source object.
|
||||
* @param mediaContentID The media content ID.
|
||||
* @param cache An optional ResolvedMediaCache object.
|
||||
* @returns The resolved media or `null`.
|
||||
*/
|
||||
export const resolveMedia = async (
|
||||
hass: HomeAssistant,
|
||||
mediaSource?: FrigateBrowseMediaSource,
|
||||
mediaContentID: string,
|
||||
cache?: ResolvedMediaCache,
|
||||
): Promise<ResolvedMedia | null> => {
|
||||
if (!mediaSource) {
|
||||
return null;
|
||||
}
|
||||
const cachedValue = cache ? cache.get(mediaSource.media_content_id) : undefined;
|
||||
const cachedValue = cache ? cache.get(mediaContentID) : undefined;
|
||||
if (cachedValue) {
|
||||
return cachedValue;
|
||||
}
|
||||
const request = {
|
||||
type: 'media_source/resolve_media',
|
||||
media_content_id: mediaSource.media_content_id,
|
||||
media_content_id: mediaContentID,
|
||||
};
|
||||
let resolvedMedia: ResolvedMedia | null = null;
|
||||
try {
|
||||
@@ -80,7 +73,7 @@ export const resolveMedia = async (
|
||||
errorToConsole(e as Error);
|
||||
}
|
||||
if (cache && resolvedMedia) {
|
||||
cache.set(mediaSource.media_content_id, resolvedMedia);
|
||||
cache.set(mediaContentID, resolvedMedia);
|
||||
}
|
||||
return resolvedMedia;
|
||||
};
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
HassEntityBase
|
||||
} from 'home-assistant-js-websocket';
|
||||
|
||||
export const UPDATE_SUPPORT_INSTALL = 1;
|
||||
export const UPDATE_SUPPORT_SPECIFIC_VERSION = 2;
|
||||
export const UPDATE_SUPPORT_PROGRESS = 4;
|
||||
export const UPDATE_SUPPORT_BACKUP = 8;
|
||||
export const UPDATE_SUPPORT_RELEASE_NOTES = 16;
|
||||
const UPDATE_SUPPORT_PROGRESS = 4;
|
||||
|
||||
interface UpdateEntityAttributes extends HassEntityAttributeBase {
|
||||
auto_update: boolean | null;
|
||||
@@ -28,7 +24,7 @@ export interface UpdateEntity extends HassEntityBase {
|
||||
export const supportsFeature = (stateObj: HassEntity, feature: number): boolean =>
|
||||
((stateObj.attributes.supported_features ?? 0) & feature) !== 0;
|
||||
|
||||
export const updateUsesProgress = (entity: UpdateEntity): boolean =>
|
||||
const updateUsesProgress = (entity: UpdateEntity): boolean =>
|
||||
supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) &&
|
||||
typeof entity.attributes.in_progress === 'number';
|
||||
|
||||
|
||||
@@ -21,22 +21,3 @@ export const alarmPanelIcon = (state?: string) => {
|
||||
return 'mdi:shield';
|
||||
}
|
||||
};
|
||||
|
||||
export const alarmPanelIconAction = (state?: string) => {
|
||||
switch (state) {
|
||||
case 'armed_away':
|
||||
return 'mdi:shield-lock-outline';
|
||||
case 'armed_vacation':
|
||||
return 'mdi:shield-airplane-outline';
|
||||
case 'armed_home':
|
||||
return 'mdi:shield-home-outline';
|
||||
case 'armed_night':
|
||||
return 'mdi:shield-moon-outline';
|
||||
case 'armed_custom_bypass':
|
||||
return 'mdi:shield-half-full';
|
||||
case 'disarmed':
|
||||
return 'mdi:shield-off-outline';
|
||||
default:
|
||||
return 'mdi:shield-outline';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,27 +87,3 @@ export const coverIcon = (state?: string, entity?: HassEntity): string => {
|
||||
return 'mdi:window-open';
|
||||
}
|
||||
};
|
||||
|
||||
export const computeOpenIcon = (stateObj: HassEntity): string => {
|
||||
switch (stateObj.attributes.device_class) {
|
||||
case 'awning':
|
||||
case 'curtain':
|
||||
case 'door':
|
||||
case 'gate':
|
||||
return 'mdi:arrow-expand-horizontal';
|
||||
default:
|
||||
return 'mdi:arrow-up';
|
||||
}
|
||||
};
|
||||
|
||||
export const computeCloseIcon = (stateObj: HassEntity): string => {
|
||||
switch (stateObj.attributes.device_class) {
|
||||
case 'awning':
|
||||
case 'curtain':
|
||||
case 'door':
|
||||
case 'gate':
|
||||
return 'mdi:arrow-collapse-horizontal';
|
||||
default:
|
||||
return 'mdi:arrow-down';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ const batteryStateIcon = (
|
||||
return batteryIcon(battery, batteryCharging);
|
||||
};
|
||||
|
||||
export const batteryIcon = (
|
||||
const batteryIcon = (
|
||||
batteryState: number | string,
|
||||
batteryCharging?: boolean,
|
||||
) => {
|
||||
|
||||
+200
-240
@@ -1,41 +1,99 @@
|
||||
import add from 'date-fns/add';
|
||||
import endOfHour from 'date-fns/endOfHour';
|
||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||
import getUnixTime from 'date-fns/getUnixTime';
|
||||
import startOfHour from 'date-fns/startOfHour';
|
||||
import sub from 'date-fns/sub';
|
||||
import { ViewContext } from 'view';
|
||||
import { dispatchMessageEvent } from '../components/message';
|
||||
import { localize } from '../localize/localize';
|
||||
import { CameraConfig, ExtendedHomeAssistant, FrigateBrowseMediaSource } from '../types';
|
||||
import { View } from '../view';
|
||||
import { formatDateAndTime, prettifyTitle } from './basic';
|
||||
import { getRecordingMediaContentID } from './frigate';
|
||||
import { CameraConfig, ClipsOrSnapshotsOrAll, FrigateCardView } from '../types';
|
||||
import { View } from '../view/view';
|
||||
import {
|
||||
createChild,
|
||||
createEventParentForChildren,
|
||||
sortYoungestToOldest,
|
||||
} from './ha/browse-media';
|
||||
import {
|
||||
RecordingSegmentsItem,
|
||||
sortOldestToYoungest,
|
||||
DataManager,
|
||||
} from './data-manager';
|
||||
import { getAllDependentCameras, getTrueCameras } from './camera.js';
|
||||
EventMediaQueries,
|
||||
MediaQueries,
|
||||
RecordingMediaQueries,
|
||||
} from '../view/media-queries';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { getAllDependentCameras } from './camera.js';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import { dispatchFrigateCardErrorEvent } from '../components/message';
|
||||
import { MediaQueriesResults } from '../view/media-queries-results';
|
||||
import { errorToConsole } from './basic';
|
||||
import { MediaQuery } from '../camera-manager/types';
|
||||
|
||||
export const changeViewToRecentEventsForCameraAndDependents = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
mediaType?: ClipsOrSnapshotsOrAll;
|
||||
targetView?: FrigateCardView;
|
||||
},
|
||||
): Promise<void> => {
|
||||
(
|
||||
await createViewForEvents(element, hass, cameraManager, cameras, view, {
|
||||
...options,
|
||||
limit: 50, // Capture the 50 most recent events.
|
||||
})
|
||||
)?.dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
export const createViewForEvents = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
query?: EventMediaQueries;
|
||||
cameraIDs?: Set<string>;
|
||||
mediaType?: ClipsOrSnapshotsOrAll;
|
||||
targetCameraID?: string;
|
||||
targetView?: FrigateCardView;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<View | null> => {
|
||||
let query: EventMediaQueries;
|
||||
const cameraIDs: Set<string> = options?.cameraIDs
|
||||
? options.cameraIDs
|
||||
: new Set(getAllDependentCameras(cameras, view.camera));
|
||||
|
||||
if (options?.query) {
|
||||
query = options.query;
|
||||
} else {
|
||||
const eventQueries = cameraManager.generateDefaultEventQueries(cameraIDs, {
|
||||
...(options?.limit && { limit: options.limit }),
|
||||
...(options?.mediaType === 'clips' && { hasClip: true }),
|
||||
...(options?.mediaType === 'snapshots' && { hasSnapshot: true }),
|
||||
});
|
||||
if (!eventQueries) {
|
||||
return null;
|
||||
}
|
||||
query = new EventMediaQueries(eventQueries);
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return executeMediaQueryForView(element, hass, cameraManager, view, query, {
|
||||
cameraIDs: cameraIDs,
|
||||
targetView: options?.targetView,
|
||||
targetCameraID: options?.targetCameraID,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the view to a recent recording.
|
||||
* @param element The element to dispatch the view change from.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param dataManager The datamanager to use for data access.
|
||||
* @param cameraManager The datamanager to use for data access.
|
||||
* @param cameras The camera configurations.
|
||||
* @param view The current view.
|
||||
* @param options A set of cameraIDs to fetch recordings for, and a targetView to dispatch to.
|
||||
*/
|
||||
export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
element: HTMLElement,
|
||||
hass: ExtendedHomeAssistant,
|
||||
dataManager: DataManager,
|
||||
hass: HomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
@@ -43,200 +101,133 @@ export const changeViewToRecentRecordingForCameraAndDependents = async (
|
||||
},
|
||||
): Promise<void> => {
|
||||
const now = new Date();
|
||||
|
||||
await changeViewToRecording(element, hass, dataManager, cameras, view, {
|
||||
...options,
|
||||
|
||||
// Fetch 1 days worth of recordings (including recordings that are for the current hour).
|
||||
cameraIDs: getAllDependentCameras(cameras, view.camera),
|
||||
start: sub(now, { days: 1 }),
|
||||
end: add(now, { hours: 1 }),
|
||||
});
|
||||
(
|
||||
await createViewForRecordings(element, hass, cameraManager, cameras, view, {
|
||||
...options,
|
||||
// Fetch 7 days worth of recordings (including recordings that are for the
|
||||
// current hour).
|
||||
start: sub(now, { days: 7 }),
|
||||
end: add(now, { hours: 1 }),
|
||||
})
|
||||
)?.dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Change the view to a recording.
|
||||
* Create a view for recordings.
|
||||
* @param element The element to dispatch the view change from.
|
||||
* @param hass The Home Assistant object.
|
||||
* @param dataManager The datamanager to use for data access.
|
||||
* @param cameraManager The datamanager to use for data access.
|
||||
* @param cameras The camera configurations.
|
||||
* @param view The current view.
|
||||
* @param options A specific window (start and end) to fetch recordings for, a
|
||||
* targetTime to seek to, a targetView to dispatch to and a set of cameraIDs to
|
||||
* restrict to.
|
||||
*/
|
||||
export const changeViewToRecording = async (
|
||||
export const createViewForRecordings = async (
|
||||
element: HTMLElement,
|
||||
hass: ExtendedHomeAssistant,
|
||||
dataManager: DataManager,
|
||||
hass: HomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
view: View,
|
||||
options?: {
|
||||
query?: RecordingMediaQueries;
|
||||
cameraIDs?: Set<string>;
|
||||
targetCameraID?: string;
|
||||
targetView?: 'recording' | 'recordings';
|
||||
targetTime?: Date;
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
},
|
||||
): Promise<void> => {
|
||||
if (options && options.start && options.end) {
|
||||
await dataManager.fetchIfNecessary(element, hass, options.start, options.end);
|
||||
}
|
||||
|
||||
): Promise<View | null> => {
|
||||
const cameraIDs: Set<string> = options?.cameraIDs
|
||||
? options.cameraIDs
|
||||
: new Set([view.camera]);
|
||||
const children = createRecordingChildren(dataManager, cameras, cameraIDs, {
|
||||
...(options?.start && options?.end && { start: options.start, end: options.end }),
|
||||
});
|
||||
: new Set(getAllDependentCameras(cameras, view.camera));
|
||||
|
||||
if (!children.length) {
|
||||
return dispatchMessageEvent(element, localize('common.no_recording'), 'info', {
|
||||
icon: 'mdi:album',
|
||||
let query: RecordingMediaQueries;
|
||||
if (options?.query) {
|
||||
query = options.query;
|
||||
} else {
|
||||
const recordingQueries = cameraManager.generateDefaultRecordingQueries(cameraIDs, {
|
||||
...(options?.start && { start: options.start }),
|
||||
...(options?.end && { end: options.end }),
|
||||
});
|
||||
|
||||
if (!recordingQueries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
query = new RecordingMediaQueries(recordingQueries);
|
||||
}
|
||||
|
||||
const viewerContext = options?.targetTime
|
||||
? generateMediaViewerContextForChildren(dataManager, children, options.targetTime)
|
||||
: {};
|
||||
const childIndex = options?.targetTime
|
||||
? findChildIndex(children, options.targetTime, cameraIDs)
|
||||
: null;
|
||||
const child = childIndex !== null ? children[childIndex] ?? null : null;
|
||||
|
||||
view
|
||||
?.evolve({
|
||||
view: options?.targetView ? options.targetView : 'recording',
|
||||
target: createEventParentForChildren(localize('common.recordings'), children),
|
||||
childIndex: childIndex ?? 0,
|
||||
...(child?.frigate?.cameraID && { camera: child.frigate?.cameraID }),
|
||||
})
|
||||
.mergeInContext(viewerContext)
|
||||
.dispatchChangeEvent(element);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create recording objects.
|
||||
* @param dataManager The datamanager to use for data access.
|
||||
* @param cameras The camera configurations.
|
||||
* @param cameraIDs The camera IDs to include recordings for.
|
||||
* @param options A specific window (start and end) to allow recordings for.
|
||||
* @returns
|
||||
*/
|
||||
const createRecordingChildren = (
|
||||
dataManager: DataManager,
|
||||
cameras: Map<string, CameraConfig>,
|
||||
cameraIDs: Set<string>,
|
||||
options?: {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
},
|
||||
): FrigateBrowseMediaSource[] => {
|
||||
const children: FrigateBrowseMediaSource[] = [];
|
||||
|
||||
for (const cameraID of getTrueCameras(cameras, cameraIDs)) {
|
||||
const config = cameras.get(cameraID) ?? null;
|
||||
const recordingSummary = dataManager.getRecordingSummaryForCamera(cameraID);
|
||||
if (!config?.frigate.camera_name || !recordingSummary) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dayData of recordingSummary) {
|
||||
for (const hourData of dayData.hours) {
|
||||
const hour = add(dayData.day, { hours: hourData.hour });
|
||||
const startHour = startOfHour(hour);
|
||||
const endHour = endOfHour(hour);
|
||||
|
||||
if (
|
||||
(!options?.start || startHour >= options.start) &&
|
||||
(!options?.end || endHour <= options.end)
|
||||
) {
|
||||
children.push(
|
||||
createChild(
|
||||
`${prettifyTitle(config.frigate.camera_name)} ${formatDateAndTime(hour)}`,
|
||||
getRecordingMediaContentID({
|
||||
clientId: config.frigate.client_id,
|
||||
year: dayData.day.getFullYear(),
|
||||
month: dayData.day.getMonth() + 1,
|
||||
day: dayData.day.getDate(),
|
||||
hour: hourData.hour,
|
||||
cameraName: config.frigate.camera_name,
|
||||
}),
|
||||
{
|
||||
recording: {
|
||||
camera: config.frigate.camera_name,
|
||||
start_time: getUnixTime(startHour),
|
||||
end_time: getUnixTime(endHour),
|
||||
events: hourData.events,
|
||||
},
|
||||
cameraID: cameraID,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sort the events by time (to align recordings for different cameras at the
|
||||
// same time).
|
||||
return children.sort(sortYoungestToOldest);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate the media view context for a set of media children (used to set
|
||||
* seek times into each media item).
|
||||
* @param dataManager The datamanager to use for data access.
|
||||
* @param children The media children.
|
||||
* @param targetTime The target time.
|
||||
* @returns The ViewContext.
|
||||
*/
|
||||
export const generateMediaViewerContextForChildren = (
|
||||
dataManager: DataManager,
|
||||
children: FrigateBrowseMediaSource[],
|
||||
targetTime: Date,
|
||||
): ViewContext => {
|
||||
const seek = new Map();
|
||||
const segmentsDataset = dataManager.recordingSegments;
|
||||
const hourStart = startOfHour(targetTime);
|
||||
|
||||
children.forEach((child, index) => {
|
||||
const source = child.frigate?.recording ?? child.frigate?.event;
|
||||
if (source && source.end_time && child.frigate?.cameraID) {
|
||||
const start = source.start_time * 1000;
|
||||
const end = source.end_time * 1000;
|
||||
let seekSeconds: number | null = null;
|
||||
|
||||
if (targetTime.getTime() >= start && targetTime.getTime() <= end) {
|
||||
const segments = segmentsDataset.get({
|
||||
filter: (segment) =>
|
||||
segment.cameraID === child.frigate?.cameraID &&
|
||||
segment.start >= start &&
|
||||
segment.end <= end,
|
||||
order: sortOldestToYoungest,
|
||||
});
|
||||
seekSeconds = getSeekTimeInSegments(
|
||||
// Recordings start from the top of the hour.
|
||||
child.frigate.recording ? hourStart : fromUnixTime(source.start_time),
|
||||
targetTime,
|
||||
segments,
|
||||
);
|
||||
}
|
||||
|
||||
if (seekSeconds !== null) {
|
||||
seek.set(index, {
|
||||
seekSeconds: seekSeconds,
|
||||
seekTime: targetTime.getTime() / 1000,
|
||||
});
|
||||
}
|
||||
}
|
||||
return executeMediaQueryForView(element, hass, cameraManager, view, query, {
|
||||
cameraIDs: cameraIDs,
|
||||
targetView: options?.targetView,
|
||||
targetCameraID: options?.targetCameraID,
|
||||
targetTime: options?.targetTime,
|
||||
});
|
||||
return seek.size > 0 ? { mediaViewer: { seek: seek } } : {};
|
||||
};
|
||||
|
||||
const executeMediaQueryForView = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
cameraManager: CameraManager,
|
||||
view: View,
|
||||
query: MediaQueries,
|
||||
options?: {
|
||||
cameraIDs?: Set<string>;
|
||||
targetCameraID?: string;
|
||||
targetView?: FrigateCardView;
|
||||
targetTime?: Date;
|
||||
},
|
||||
): Promise<View | null> => {
|
||||
let mediaArray: ViewMedia[] | null;
|
||||
|
||||
const queries = query.getQueries();
|
||||
if (!queries) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
mediaArray = await cameraManager.executeMediaQueries<MediaQuery>(hass, queries);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
dispatchFrigateCardErrorEvent(element, e as Error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!mediaArray) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryResults = new MediaQueriesResults(mediaArray);
|
||||
let viewerContext: ViewContext | undefined = {};
|
||||
|
||||
if (options?.targetTime && options.cameraIDs) {
|
||||
queryResults.selectBestResult((media) =>
|
||||
findClosestMediaIndex(media, options.targetTime as Date, options.cameraIDs),
|
||||
);
|
||||
viewerContext = {
|
||||
mediaViewer: {
|
||||
seek: options.targetTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
view
|
||||
?.evolve({
|
||||
query: query,
|
||||
queryResults: queryResults,
|
||||
view: options?.targetView,
|
||||
camera: options?.targetCameraID,
|
||||
})
|
||||
.mergeInContext(viewerContext) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the relevant recording child given a date target.
|
||||
* @param children The FrigateBrowseMediaSource[] children. Must be sorted
|
||||
* most recent first.
|
||||
* Find the closest matching media object.
|
||||
* @param mediaArray The media. Must be sorted most recent first.
|
||||
* @param targetTime The target time used to find the relevant child.
|
||||
* @param cameraIDs The camera IDs to search for.
|
||||
* @param refPoint Whether to find based on the start or end of the
|
||||
@@ -244,10 +235,10 @@ export const generateMediaViewerContextForChildren = (
|
||||
* the best match.
|
||||
* @returns The childindex or null if no matching child is found.
|
||||
*/
|
||||
export const findChildIndex = (
|
||||
children: FrigateBrowseMediaSource[],
|
||||
export const findClosestMediaIndex = (
|
||||
mediaArray: ViewMedia[],
|
||||
targetTime: Date,
|
||||
cameraIDs: Set<string>,
|
||||
cameraIDs?: Set<string>,
|
||||
refPoint?: 'start' | 'end',
|
||||
): number | null => {
|
||||
let bestMatch:
|
||||
@@ -257,60 +248,29 @@ export const findChildIndex = (
|
||||
}
|
||||
| undefined;
|
||||
|
||||
for (let i = 0; i < children.length; ++i) {
|
||||
const child = children[i];
|
||||
if (child.frigate?.cameraID && cameraIDs.has(child.frigate.cameraID)) {
|
||||
const source = child.frigate.event ?? child.frigate.recording;
|
||||
if (!source?.start_time || !source?.end_time) {
|
||||
continue;
|
||||
}
|
||||
const startTime = fromUnixTime(source.start_time);
|
||||
const endTime = fromUnixTime(source.end_time);
|
||||
if (!cameraIDs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (startTime <= targetTime && endTime >= targetTime) {
|
||||
if (!refPoint) {
|
||||
return i;
|
||||
}
|
||||
const delta =
|
||||
refPoint === 'end'
|
||||
? endTime.getTime() - targetTime.getTime()
|
||||
: targetTime.getTime() - startTime.getTime();
|
||||
if (!bestMatch || delta < bestMatch.delta) {
|
||||
bestMatch = { index: i, delta: delta };
|
||||
}
|
||||
for (const [i, media] of mediaArray.entries()) {
|
||||
if (!cameraIDs.has(media.getCameraID())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (media.includesTime(targetTime)) {
|
||||
const start = media.getStartTime();
|
||||
const end = media.getEndTime();
|
||||
if (!refPoint || !start || !end) {
|
||||
return i;
|
||||
}
|
||||
const delta =
|
||||
refPoint === 'end'
|
||||
? end.getTime() - targetTime.getTime()
|
||||
: targetTime.getTime() - start.getTime();
|
||||
if (!bestMatch || delta < bestMatch.delta) {
|
||||
bestMatch = { index: i, delta: delta };
|
||||
}
|
||||
}
|
||||
}
|
||||
return bestMatch ? bestMatch.index : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the number of seconds to seek into a video stream consisting of the
|
||||
* provided segments to reach the target time provided.
|
||||
* @param startTime The earliest allowable time to seek from.
|
||||
* @param targetTime Target time.
|
||||
* @param segments An array of segments dataset items. Must be sorted from oldest to youngest.
|
||||
* @returns
|
||||
*/
|
||||
const getSeekTimeInSegments = (
|
||||
startTime: Date,
|
||||
targetTime: Date,
|
||||
segments: RecordingSegmentsItem[],
|
||||
): number | null => {
|
||||
if (!segments.length) {
|
||||
return null;
|
||||
}
|
||||
let seekMilliseconds = 0;
|
||||
|
||||
// Inspired by: https://github.com/blakeblackshear/frigate/blob/release-0.11.0/web/src/routes/Recording.jsx#L27
|
||||
for (const segment of segments) {
|
||||
if (segment.start > targetTime.getTime()) {
|
||||
break;
|
||||
}
|
||||
const start =
|
||||
segment.start < startTime.getTime() ? startTime.getTime() : segment.start;
|
||||
const end = segment.end > targetTime.getTime() ? targetTime.getTime() : segment.end;
|
||||
seekMilliseconds += end - start;
|
||||
}
|
||||
return seekMilliseconds / 1000;
|
||||
};
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
// to give a cleaner UI appearance, see:
|
||||
// https://github.com/dermotduffy/frigate-hass-card/issues/856
|
||||
export const MEDIA_LOAD_CONTROLS_HIDE_SECONDS = 2;
|
||||
export const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1;
|
||||
const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1;
|
||||
|
||||
/**
|
||||
* Temporarily hide media controls.
|
||||
|
||||
@@ -8,7 +8,7 @@ import { HomeAssistant } from 'custom-card-helpers';
|
||||
* @param thumbnailURL The thumbnail URL.
|
||||
* @returns A base64 encoded data URL for the thumbnail.
|
||||
*/
|
||||
export const fetchThumbnail = async (
|
||||
const fetchThumbnail = async (
|
||||
hass: HomeAssistant,
|
||||
thumbnailURL: string,
|
||||
): Promise<string | null> => {
|
||||
@@ -42,7 +42,7 @@ export const fetchThumbnail = async (
|
||||
});
|
||||
};
|
||||
|
||||
type FetchThumbnailTaskArgs = [boolean, string | undefined];
|
||||
export type FetchThumbnailTaskArgs = [boolean, string | undefined];
|
||||
|
||||
/**
|
||||
* Create a Lit task to fetch a thumbnail.
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { HomeAssistant } from 'custom-card-helpers';
|
||||
import add from 'date-fns/add';
|
||||
import sub from 'date-fns/sub';
|
||||
import { DataSet } from 'vis-data';
|
||||
import { IdType, TimelineItem, TimelineWindow } from 'vis-timeline/esnext';
|
||||
import { ClipsOrSnapshotsOrAll } from '../types';
|
||||
import { CameraManager } from '../camera-manager/manager';
|
||||
import { EventQuery, RecordingSegment } from '../camera-manager/types';
|
||||
import { capEndDate, convertRangeToCacheFriendlyTimes } from '../camera-manager/util';
|
||||
import { ViewMedia } from '../view/media';
|
||||
import {
|
||||
compressRanges,
|
||||
ExpiringMemoryRangeSet,
|
||||
MemoryRangeSet,
|
||||
} from '../camera-manager/range';
|
||||
import { errorToConsole, ModifyInterface } from './basic.js';
|
||||
|
||||
// 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).
|
||||
const TIMELINE_FRESHNESS_TOLERANCE_SECONDS = 30;
|
||||
|
||||
// Number of seconds gap allowable in order to consider two recording segments
|
||||
// to be consecutive. Some low performance cameras have trouble and without a
|
||||
// generous allowance here the timeline may be littered with individual segments
|
||||
// instead of clean recording blocks.
|
||||
const TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS = 60;
|
||||
|
||||
export interface FrigateCardTimelineItem extends TimelineItem {
|
||||
// Use numbers to avoid significant volumes of Date object construction (for
|
||||
// high-quantity recording segments).
|
||||
start: number;
|
||||
end?: number;
|
||||
media?: ViewMedia;
|
||||
}
|
||||
|
||||
export class TimelineDataSource {
|
||||
protected _cameraManager: CameraManager;
|
||||
protected _dataset: DataSet<FrigateCardTimelineItem> = new DataSet();
|
||||
|
||||
// The ranges in which recordings have been calculated and added for.
|
||||
// Calculating recordings is a very expensive process since it is based on
|
||||
// segments (not just the fetch is expensive, but the JS to dedup and turn the
|
||||
// high-N segments into a smaller number of consecutive recording blocks).
|
||||
protected _recordingRanges = new MemoryRangeSet();
|
||||
|
||||
// Cache event ranges since re-adding the same events is a timeline
|
||||
// performance killer (even if the request results are cached).
|
||||
protected _eventRanges = new ExpiringMemoryRangeSet();
|
||||
|
||||
protected _cameraIDs: Set<string>;
|
||||
protected _mediaType: ClipsOrSnapshotsOrAll;
|
||||
|
||||
constructor(
|
||||
cameraManager: CameraManager,
|
||||
cameraIDs: Set<string>,
|
||||
media: ClipsOrSnapshotsOrAll,
|
||||
) {
|
||||
this._cameraManager = cameraManager;
|
||||
this._cameraIDs = cameraIDs;
|
||||
this._mediaType = media;
|
||||
}
|
||||
|
||||
get dataset(): DataSet<FrigateCardTimelineItem> {
|
||||
return this._dataset;
|
||||
}
|
||||
|
||||
public rewriteEvent(id: IdType): void {
|
||||
// Hack: For timeline uses of the event dataset clustering may not update
|
||||
// unless the dataset changes, artifically update the dataset to ensure the
|
||||
// newly selected item cannot be included in a cluster.
|
||||
|
||||
// Hack2: Cannot use `updateOnly` here, as vis-data loses the object
|
||||
// prototype, see: https://github.com/visjs/vis-data/issues/997 . Instead,
|
||||
// remove then add.
|
||||
const item = this._dataset.get(id);
|
||||
if (item) {
|
||||
this._dataset.remove(id);
|
||||
this._dataset.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
public async refresh(hass: HomeAssistant, window: TimelineWindow): Promise<void> {
|
||||
try {
|
||||
await Promise.all([
|
||||
this._refreshEvents(hass, window),
|
||||
this._refreshRecordings(hass, 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 getCacheFriendlyEventWindow(window: TimelineWindow): TimelineWindow {
|
||||
return convertRangeToCacheFriendlyTimes(window, {
|
||||
endCap: true,
|
||||
});
|
||||
}
|
||||
|
||||
public getTimelineEventQueries(window: TimelineWindow): EventQuery[] | null {
|
||||
return this._cameraManager.generateDefaultEventQueries(this._cameraIDs, {
|
||||
start: window.start,
|
||||
end: window.end,
|
||||
...(this._mediaType === 'clips' && { hasClip: true }),
|
||||
...(this._mediaType === 'snapshots' && { hasSnapshot: true }),
|
||||
});
|
||||
}
|
||||
|
||||
protected async _refreshEvents(
|
||||
hass: HomeAssistant,
|
||||
window: TimelineWindow,
|
||||
): Promise<void> {
|
||||
if (
|
||||
this._eventRanges.hasCoverage({
|
||||
start: window.start,
|
||||
end: sub(capEndDate(window.end), {
|
||||
seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS,
|
||||
}),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheFriendlyWindow = this.getCacheFriendlyEventWindow(window);
|
||||
const eventQueries = this.getTimelineEventQueries(cacheFriendlyWindow);
|
||||
if (!eventQueries) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaArray = await this._cameraManager.executeMediaQueries(hass, eventQueries);
|
||||
for (const media of mediaArray ?? []) {
|
||||
const endTime = media.getEndTime();
|
||||
const startTime = media.getStartTime();
|
||||
const id = media.getID();
|
||||
if (id && startTime) {
|
||||
this._dataset.update({
|
||||
id: id,
|
||||
group: media.getCameraID(),
|
||||
content: '',
|
||||
media: media,
|
||||
start: startTime.getTime(),
|
||||
type: endTime ? 'range' : 'point',
|
||||
...(endTime && { end: endTime.getTime() }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this._eventRanges.add({
|
||||
...cacheFriendlyWindow,
|
||||
expires: add(new Date(), { seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS }),
|
||||
});
|
||||
}
|
||||
|
||||
protected async _refreshRecordings(
|
||||
hass: HomeAssistant,
|
||||
window: TimelineWindow,
|
||||
): Promise<void> {
|
||||
type FrigateCardTimelineItemWithEnd = ModifyInterface<
|
||||
FrigateCardTimelineItem,
|
||||
{ end: number }
|
||||
>;
|
||||
|
||||
const convertSegmentToRecording = (
|
||||
cameraID: string,
|
||||
segment: RecordingSegment,
|
||||
): FrigateCardTimelineItemWithEnd => {
|
||||
return {
|
||||
id: `recording-${cameraID}-${segment.id}`,
|
||||
group: cameraID,
|
||||
start: segment.start_time * 1000,
|
||||
end: segment.end_time * 1000,
|
||||
content: '',
|
||||
type: 'background',
|
||||
};
|
||||
};
|
||||
|
||||
const getExistingRecordingsForCameraID = (
|
||||
cameraID: string,
|
||||
): FrigateCardTimelineItemWithEnd[] => {
|
||||
return this._dataset.get({
|
||||
filter: (item) =>
|
||||
item.type == 'background' && item.group === cameraID && item.end !== undefined,
|
||||
}) as FrigateCardTimelineItemWithEnd[];
|
||||
};
|
||||
|
||||
const deleteRecordingsForCameraID = (cameraID: string): void => {
|
||||
this._dataset.remove(
|
||||
this._dataset.get({
|
||||
filter: (item) => item.type === 'background' && item.group === cameraID,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const addRecordings = (recordings: FrigateCardTimelineItemWithEnd[]): void => {
|
||||
this._dataset.add(recordings);
|
||||
};
|
||||
|
||||
// Calculate an end date that's slightly short of the current time to allow
|
||||
// for caching up to the freshness tolerance.
|
||||
if (
|
||||
this._recordingRanges.hasCoverage({
|
||||
start: window.start,
|
||||
end: sub(capEndDate(window.end), {
|
||||
seconds: TIMELINE_FRESHNESS_TOLERANCE_SECONDS,
|
||||
}),
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheFriendlyWindow = convertRangeToCacheFriendlyTimes(window, {
|
||||
endCap: true,
|
||||
});
|
||||
|
||||
const recordingQueries = this._cameraManager.generateDefaultRecordingSegmentsQueries(
|
||||
this._cameraIDs,
|
||||
{
|
||||
start: cacheFriendlyWindow.start,
|
||||
end: cacheFriendlyWindow.end,
|
||||
},
|
||||
);
|
||||
|
||||
if (!recordingQueries) {
|
||||
return;
|
||||
}
|
||||
const results = await this._cameraManager.getRecordingSegments(
|
||||
hass,
|
||||
recordingQueries,
|
||||
);
|
||||
|
||||
const newSegments: Map<string, RecordingSegment[]> = new Map();
|
||||
for (const [query, result] of results) {
|
||||
for (const cameraID of query.cameraIDs) {
|
||||
let destination: RecordingSegment[] | undefined = newSegments.get(cameraID);
|
||||
if (!destination) {
|
||||
destination = [];
|
||||
newSegments.set(cameraID, destination);
|
||||
}
|
||||
result.segments.forEach((segment) => destination?.push(segment));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [cameraID, segments] of newSegments.entries()) {
|
||||
const existingRecordings = getExistingRecordingsForCameraID(cameraID);
|
||||
const mergedRecordings = existingRecordings.concat(
|
||||
segments.map((segment) => convertSegmentToRecording(cameraID, segment)),
|
||||
);
|
||||
const compressedRecordings = compressRanges(
|
||||
mergedRecordings,
|
||||
TIMELINE_RECORDING_SEGMENT_CONSECUTIVE_TOLERANCE_SECONDS,
|
||||
) as FrigateCardTimelineItemWithEnd[];
|
||||
|
||||
deleteRecordingsForCameraID(cameraID);
|
||||
addRecordings(compressedRecordings);
|
||||
}
|
||||
|
||||
this._recordingRanges.add({
|
||||
start: cacheFriendlyWindow.start,
|
||||
end: cacheFriendlyWindow.end,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ViewMedia, RecordingViewMedia, EventViewMedia } from './media';
|
||||
|
||||
export class ViewMediaClassifier {
|
||||
public static isEvent(media: ViewMedia): media is EventViewMedia {
|
||||
return this.isClip(media) || this.isSnapshot(media);
|
||||
}
|
||||
public static isRecording(media: ViewMedia): media is RecordingViewMedia {
|
||||
return media.getMediaType() === 'recording';
|
||||
}
|
||||
public static isClip(media: ViewMedia): boolean {
|
||||
return media.getMediaType() === 'clip';
|
||||
}
|
||||
public static isSnapshot(media: ViewMedia): boolean {
|
||||
return media.getMediaType() === 'snapshot';
|
||||
}
|
||||
public static isVideo(media: ViewMedia): boolean {
|
||||
return this.isClip(media) || this.isRecording(media);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { EventMediaQueries, MediaQueries, RecordingMediaQueries } from './media-queries';
|
||||
|
||||
export class MediaQueriesClassifier {
|
||||
public static areEventQueries(
|
||||
queries?: MediaQueries | null,
|
||||
): queries is EventMediaQueries {
|
||||
return queries instanceof EventMediaQueries;
|
||||
}
|
||||
|
||||
public static areRecordingQueries(
|
||||
queries?: MediaQueries | null,
|
||||
): queries is RecordingMediaQueries {
|
||||
return queries instanceof RecordingMediaQueries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
|
||||
constructor(results?: ViewMedia[], selectedIndex?: number | null) {
|
||||
if (results) {
|
||||
this.setResults(results);
|
||||
}
|
||||
this.selectResult(selectedIndex ?? 0);
|
||||
}
|
||||
|
||||
public clone(): MediaQueriesResults {
|
||||
// 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
|
||||
// operation).
|
||||
return clone(this);
|
||||
}
|
||||
|
||||
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()));
|
||||
|
||||
if (
|
||||
!thisMediaIDs ||
|
||||
!thatMediaIDs ||
|
||||
// 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.
|
||||
thisMediaIDs.has(null) ||
|
||||
thatMediaIDs.has(null)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return isSuperset(thisMediaIDs, thatMediaIDs);
|
||||
}
|
||||
|
||||
public getResults(): ViewMedia[] | null {
|
||||
return this._results;
|
||||
}
|
||||
public getResultsCount(): number {
|
||||
return this._results?.length ?? 0;
|
||||
}
|
||||
public hasResults(): boolean {
|
||||
return !!this._results;
|
||||
}
|
||||
public setResults(results: ViewMedia[]) {
|
||||
this._results = results;
|
||||
this._resultsTimestamp = new Date();
|
||||
}
|
||||
public getResult(index?: number): ViewMedia | null {
|
||||
if (!this._results || index === undefined) {
|
||||
return null;
|
||||
}
|
||||
return this._results[index];
|
||||
}
|
||||
public getSelectedResult(): ViewMedia | null {
|
||||
return this._selectedIndex === null ? null : this.getResult(this._selectedIndex);
|
||||
}
|
||||
public getSelectedIndex(): number | null {
|
||||
return this._selectedIndex;
|
||||
}
|
||||
public hasSelectedResult(): boolean {
|
||||
return this.getSelectedResult() !== null;
|
||||
}
|
||||
public resetSelectedResult(): MediaQueriesResults {
|
||||
this._selectedIndex = null;
|
||||
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;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
public selectResultIfFound(func: (media: ViewMedia) => boolean): MediaQueriesResults {
|
||||
for (const [index, result] of this._results?.entries() ?? []) {
|
||||
if (func(result)) {
|
||||
this._selectedIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
public selectBestResult(
|
||||
func: (media: ViewMedia[]) => number | null,
|
||||
): MediaQueriesResults {
|
||||
if (this._results) {
|
||||
const resultIndex = func(this._results);
|
||||
if (resultIndex !== null) {
|
||||
this._selectedIndex = resultIndex;
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import cloneDeep from 'lodash-es/cloneDeep.js';
|
||||
import { EventQuery, MediaQuery, RecordingQuery } from '../camera-manager/types.js';
|
||||
|
||||
export type MediaQueries = EventMediaQueries | RecordingMediaQueries;
|
||||
|
||||
class MediaQueriesBase<T extends MediaQuery> {
|
||||
protected _queries: T[] | null = null;
|
||||
|
||||
public constructor(queries?: T[]) {
|
||||
if (queries) {
|
||||
this._queries = queries;
|
||||
}
|
||||
}
|
||||
|
||||
public clone(): MediaQueriesBase<T> {
|
||||
return cloneDeep(this);
|
||||
}
|
||||
|
||||
public getQueries(): T[] | null {
|
||||
return this._queries;
|
||||
}
|
||||
|
||||
public setQueries(queries: T[]): void {
|
||||
this._queries = queries;
|
||||
}
|
||||
}
|
||||
|
||||
export class EventMediaQueries extends MediaQueriesBase<EventQuery> {
|
||||
public convertToClipsQueries(): void {
|
||||
for (const query of this._queries ?? []) {
|
||||
delete query.hasSnapshot;
|
||||
query.hasClip = true;
|
||||
}
|
||||
}
|
||||
|
||||
public clone(): EventMediaQueries {
|
||||
return cloneDeep(this);
|
||||
}
|
||||
}
|
||||
|
||||
export class RecordingMediaQueries extends MediaQueriesBase<RecordingQuery> {}
|
||||
@@ -0,0 +1,67 @@
|
||||
export type ViewMediaType = 'clip' | 'snapshot' | 'recording';
|
||||
|
||||
export class ViewMedia {
|
||||
protected _mediaType: ViewMediaType;
|
||||
protected _cameraID: string;
|
||||
|
||||
constructor(mediaType: ViewMediaType, cameraID: string) {
|
||||
this._mediaType = mediaType;
|
||||
this._cameraID = cameraID;
|
||||
}
|
||||
public getContentType(): 'image' | 'video' {
|
||||
return this._mediaType === 'snapshot' ? 'image' : 'video';
|
||||
}
|
||||
public getCameraID(): string {
|
||||
return this._cameraID;
|
||||
}
|
||||
public getMediaType(): ViewMediaType {
|
||||
return this._mediaType;
|
||||
}
|
||||
public getID(): string | null {
|
||||
return null;
|
||||
}
|
||||
public getStartTime(): Date | null {
|
||||
return null;
|
||||
}
|
||||
public getEndTime(): Date | null {
|
||||
return null;
|
||||
}
|
||||
public getContentID(): string | null {
|
||||
return null;
|
||||
}
|
||||
public getTitle(): string | null {
|
||||
return null;
|
||||
}
|
||||
public getThumbnail(): string | null {
|
||||
return null;
|
||||
}
|
||||
public isFavorite(): boolean | null {
|
||||
return null;
|
||||
}
|
||||
public includesTime(seek: Date): boolean {
|
||||
const startTime = this.getStartTime();
|
||||
const endTime = this.getEndTime();
|
||||
return !!startTime && !!endTime && seek >= startTime && seek <= endTime;
|
||||
}
|
||||
|
||||
// Sets the favorite attribute (if any). This purely sets the media item as a
|
||||
// favorite in JS.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public setFavorite(_favorite: boolean): void {
|
||||
return;
|
||||
}
|
||||
public getWhere(): string[] | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface EventViewMedia extends ViewMedia {
|
||||
getScore(): number | null;
|
||||
getWhat(): string[] | null;
|
||||
isGroupableWith(that: EventViewMedia): boolean;
|
||||
hasClip(): boolean | null;
|
||||
}
|
||||
|
||||
export interface RecordingViewMedia extends ViewMedia {
|
||||
getEventCount(): number | null;
|
||||
}
|
||||
@@ -1,22 +1,23 @@
|
||||
import { ViewContext } from 'view';
|
||||
import {
|
||||
FrigateBrowseMediaSource,
|
||||
FrigateCardUserSpecifiedView,
|
||||
FrigateCardView,
|
||||
FRIGATE_CARD_VIEWS_USER_SPECIFIED,
|
||||
FRIGATE_CARD_VIEW_DEFAULT,
|
||||
} from './types.js';
|
||||
import { dispatchFrigateCardEvent } from './utils/basic.js';
|
||||
} from '../types.js';
|
||||
import { dispatchFrigateCardEvent } from '../utils/basic.js';
|
||||
import { MediaQueries } from './media-queries';
|
||||
import { MediaQueriesResults } from './media-queries-results';
|
||||
|
||||
export interface ViewEvolveParameters {
|
||||
interface ViewEvolveParameters {
|
||||
view?: FrigateCardView;
|
||||
camera?: string;
|
||||
target?: FrigateBrowseMediaSource | null;
|
||||
childIndex?: number | null;
|
||||
query?: MediaQueries | null;
|
||||
queryResults?: MediaQueriesResults | null;
|
||||
context?: ViewContext | null;
|
||||
}
|
||||
|
||||
export interface ViewParameters extends ViewEvolveParameters {
|
||||
interface ViewParameters extends ViewEvolveParameters {
|
||||
view: FrigateCardView;
|
||||
camera: string;
|
||||
}
|
||||
@@ -24,15 +25,15 @@ export interface ViewParameters extends ViewEvolveParameters {
|
||||
export class View {
|
||||
public view: FrigateCardView;
|
||||
public camera: string;
|
||||
public target: FrigateBrowseMediaSource | null;
|
||||
public childIndex: number | null;
|
||||
public query: MediaQueries | null;
|
||||
public queryResults: MediaQueriesResults | null;
|
||||
public context: ViewContext | null;
|
||||
|
||||
constructor(params: ViewParameters) {
|
||||
this.view = params.view;
|
||||
this.camera = params.camera;
|
||||
this.target = params.target ?? null;
|
||||
this.childIndex = params.childIndex ?? null;
|
||||
this.query = params.query ?? null;
|
||||
this.queryResults = params.queryResults ?? null;
|
||||
this.context = params.context ?? null;
|
||||
}
|
||||
|
||||
@@ -71,10 +72,13 @@ export class View {
|
||||
!curr ||
|
||||
prev.view !== curr.view ||
|
||||
prev.camera !== curr.camera ||
|
||||
// When in the live view, the target/childIndex are the events that
|
||||
// happened in the past -- not reflective of the actual live media viewer.
|
||||
// When in the live view, the queryResults contain the events that
|
||||
// happened in the past -- not reflective of the actual live media viewer
|
||||
// the user is seeing.
|
||||
(curr.view !== 'live' &&
|
||||
(prev.target !== curr.target || prev.childIndex !== curr.childIndex))
|
||||
(prev.queryResults !== curr.queryResults ||
|
||||
prev.queryResults?.getSelectedResult() !==
|
||||
curr.queryResults?.getSelectedResult()))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,8 +89,8 @@ export class View {
|
||||
return new View({
|
||||
view: this.view,
|
||||
camera: this.camera,
|
||||
target: this.target,
|
||||
childIndex: this.childIndex,
|
||||
query: this.query?.clone() ?? null,
|
||||
queryResults: this.queryResults?.clone() ?? null,
|
||||
context: this.context,
|
||||
});
|
||||
}
|
||||
@@ -100,8 +104,11 @@ export class View {
|
||||
return new View({
|
||||
view: params.view !== undefined ? params.view : this.view,
|
||||
camera: params.camera !== undefined ? params.camera : this.camera,
|
||||
target: params.target !== undefined ? params.target : this.target,
|
||||
childIndex: params.childIndex !== undefined ? params.childIndex : this.childIndex,
|
||||
query: params.query !== undefined ? params.query : this.query?.clone() ?? null,
|
||||
queryResults:
|
||||
params.queryResults !== undefined
|
||||
? params.queryResults
|
||||
: this.queryResults?.clone() ?? null,
|
||||
context: params.context !== undefined ? params.context : this.context,
|
||||
});
|
||||
}
|
||||
@@ -123,7 +130,7 @@ export class View {
|
||||
*/
|
||||
public removeContext(key: keyof ViewContext): View {
|
||||
if (this.context) {
|
||||
delete(this.context[key]);
|
||||
delete this.context[key];
|
||||
}
|
||||
return this;
|
||||
}
|
||||
@@ -142,20 +149,6 @@ export class View {
|
||||
return ['clips', 'snapshots', 'recordings'].includes(this.view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the viewer view given a gallery view.
|
||||
*/
|
||||
public getViewerViewForGalleryView(): 'clip' | 'snapshot' | 'recording' | null {
|
||||
if (this.is('clips')) {
|
||||
return 'clip';
|
||||
} else if (this.is('snapshots')) {
|
||||
return 'snapshot';
|
||||
} else if (this.is('recordings')) {
|
||||
return 'recording';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a view is of a piece of media (including the media viewer,
|
||||
* live view, image view -- anything that can create a MediaLoadedInfo event).
|
||||
@@ -172,49 +165,19 @@ export class View {
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a view is related to a clip or clips.
|
||||
*/
|
||||
public isClipRelatedView(): boolean {
|
||||
return ['clip', 'clips'].includes(this.view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a view is related to a snapshot or snapshots.
|
||||
*/
|
||||
public isSnapshotRelatedView(): boolean {
|
||||
return ['snapshot', 'snapshots'].includes(this.view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a view is related to a recording or recordings.
|
||||
*/
|
||||
public isRecordingRelatedView(): boolean {
|
||||
return ['recording', 'recordings'].includes(this.view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the media type for this view if available.
|
||||
* @returns Whether the media is `clips`, `snapshots`, `recordings` or unknown
|
||||
* Get the default media type for this view if available.
|
||||
* @returns Whether the default media is `clips`, `snapshots`, `recordings` or unknown
|
||||
* (`null`).
|
||||
*/
|
||||
public getMediaType(): 'clips' | 'snapshots' | 'recordings' | null {
|
||||
return this.isClipRelatedView()
|
||||
? 'clips'
|
||||
: this.isSnapshotRelatedView()
|
||||
? 'snapshots'
|
||||
: this.isRecordingRelatedView()
|
||||
? 'recordings'
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the media item that should be played.
|
||||
**/
|
||||
get media(): FrigateBrowseMediaSource | null {
|
||||
if (this.target) {
|
||||
if (this.target.children && this.childIndex !== null) {
|
||||
return this.target.children[this.childIndex] ?? null;
|
||||
}
|
||||
public getDefaultMediaType(): 'clips' | 'snapshots' | 'recordings' | null {
|
||||
if (['clip', 'clips'].includes(this.view)) {
|
||||
return 'clips';
|
||||
}
|
||||
if (['snapshot', 'snapshots'].includes(this.view)) {
|
||||
return 'snapshots';
|
||||
}
|
||||
if (['recording', 'recordings'].includes(this.view)) {
|
||||
return 'recordings';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
// imported by a custom card directly.
|
||||
"globalTags": [
|
||||
"ha-card",
|
||||
"ha-combo-box",
|
||||
"ha-icon",
|
||||
"ha-icon-button",
|
||||
"ha-button-menu",
|
||||
|
||||
Reference in New Issue
Block a user