Merge pull request #1022 from dermotduffy/motioneye
Add support for MotionEye [Proof of concept for richer support for non-Frigate cameras]
This commit is contained in:
@@ -137,6 +137,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras)
|
|||||||
| - | - | - | - | - | - | - | - |
|
| - | - | - | - | - | - | - | - |
|
||||||
|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: |
|
|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: |
|
||||||
|`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: |
|
|`generic`| :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: |
|
||||||
|
|`motioneye`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: |
|
||||||
|
|
||||||
##### Live providers supported per Engine
|
##### Live providers supported per Engine
|
||||||
|
|
||||||
@@ -144,6 +145,7 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras)
|
|||||||
| - | - | - | - | - | - |
|
| - | - | - | - | - | - |
|
||||||
|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
|`frigate`| :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: | :white_check_mark: |
|
||||||
|`generic`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: |
|
|`generic`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :white_check_mark: |
|
||||||
|
|`motioneye`| :white_check_mark: | :white_check_mark: | :heavy_multiplication_x: | :heavy_multiplication_x: | :heavy_multiplication_x: |
|
||||||
|
|
||||||
<a name="camera-frigate-configuration"></a>
|
<a name="camera-frigate-configuration"></a>
|
||||||
|
|
||||||
@@ -159,11 +161,49 @@ cameras:
|
|||||||
| Option | Default | Overridable | Description |
|
| Option | Default | Overridable | Description |
|
||||||
| - | - | - | - |
|
| - | - | - | - |
|
||||||
| `camera_name` | Autodetected from `camera_entity` if that is specified. | :white_check_mark: | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view.|
|
| `camera_name` | Autodetected from `camera_entity` if that is specified. | :white_check_mark: | The Frigate camera name to use when communicating with the Frigate server, e.g. for viewing clips/snapshots or the JSMPEG live view.|
|
||||||
| `url` | | :white_check_mark: | The URL of the frigate server. If set, this value will be (exclusively) used for a `Frigate UI` menu button. All other communication with Frigate goes via Home Assistant. |
|
| `url` | | :white_check_mark: | The URL of the frigate server. If set, this value will be (exclusively) used for a `Camera UI` menu button. All other communication with Frigate goes via Home Assistant. |
|
||||||
| `labels` | | :white_check_mark: | An array of Frigate labels used to filter events (clips & snapshots), e.g. [`person`, `car`].|
|
| `labels` | | :white_check_mark: | An array of Frigate labels used to filter events (clips & snapshots), e.g. [`person`, `car`].|
|
||||||
| `zones` | | :white_check_mark: | An array of Frigates zones used to filter events (clips & snapshots), e.g. [`front_door`, `front_steps`].|
|
| `zones` | | :white_check_mark: | An array of Frigates zones used to filter events (clips & snapshots), e.g. [`front_door`, `front_steps`].|
|
||||||
| `client_id` | `frigate` | :white_check_mark: | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://docs.frigate.video/integrations/home-assistant/#multiple-instance-support).|
|
| `client_id` | `frigate` | :white_check_mark: | The Frigate client id to use. If this Home Assistant server has multiple Frigate server backends configured, this selects which server should be used. It should be set to the MQTT client id configured for this server, see [Frigate Integration Multiple Instance Support](https://docs.frigate.video/integrations/home-assistant/#multiple-instance-support).|
|
||||||
|
|
||||||
|
#### Camera MotionEye configuration
|
||||||
|
|
||||||
|
The `motioneye` block configures options for a MotionEye camera. This configuration is included as part of a camera entry in the `cameras` array.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
- motioneye:
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Overridable | Description |
|
||||||
|
| - | - | - | - |
|
||||||
|
| `url` | | :white_check_mark: | The URL of the MotionEye server. If set, this value will be (exclusively) used for a `Camera UI` menu button. |
|
||||||
|
| `images` | | :white_check_mark: | Configure how MotionEye images are consumed. See below. |
|
||||||
|
| `movies` | | :white_check_mark: | Configure how MotionEye movies are consumed. See below. |
|
||||||
|
|
||||||
|
#### Camera MotionEye images and movies configuration
|
||||||
|
|
||||||
|
The `images` and `movies` block configures options for a MotionEye camera. All
|
||||||
|
options for `images` and `movies` are under their respective blocks. The options
|
||||||
|
for both are the same.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
- motioneye:
|
||||||
|
images:
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cameras:
|
||||||
|
- motioneye:
|
||||||
|
movies:
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Default | Overridable | Description |
|
||||||
|
| - | - | - | - |
|
||||||
|
| `directory_pattern` | `%Y-%m-%d` | :white_check_mark: | The directory that motionEye is configured to store media into. May contain multiple sub-directories separated by `/`. Path must encode the date of the media using MotionEye patterns such as `%Y`, `%m`, `%d`, `%H`, `%M`, `%S` (at least one pattern is required). Consult MotionEye help text for information on these substitutions. |
|
||||||
|
| `file_pattern` | `%H-%M-%S` | :white_check_mark: | Within a directory (as matched by `directory_pattern`) the media items must exist and match this pattern. `file_pattern` must encode the time of the media using MotionEye patterns such as `%Y`, `%m`, `%d`, `%H`, `%M`, `%S` (at least one pattern is required). Consult MotionEye help text for information on these substitutions. |
|
||||||
|
|
||||||
#### Live Provider: Camera go2rtc configuration
|
#### Live Provider: Camera go2rtc configuration
|
||||||
|
|
||||||
The `go2rtc` block configures use of the `go2rtc` live provider. This configuration is included as part of a camera entry in the `cameras` array.
|
The `go2rtc` block configures use of the `go2rtc` live provider. This configuration is included as part of a camera entry in the `cameras` array.
|
||||||
@@ -1458,6 +1498,14 @@ cameras:
|
|||||||
live_provider: image
|
live_provider: image
|
||||||
image:
|
image:
|
||||||
refresh_seconds: 1
|
refresh_seconds: 1
|
||||||
|
- camera_entity: camera.office_motioneye
|
||||||
|
motioneye:
|
||||||
|
images:
|
||||||
|
directory_pattern: '%Y-%m-%d'
|
||||||
|
file_pattern: '%H-%M-%S'
|
||||||
|
movies:
|
||||||
|
directory_pattern: '%Y-%m-%d'
|
||||||
|
file_pattern: '%H-%M-%S'
|
||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -43,7 +43,7 @@
|
|||||||
"vis-util": "^5.0.2",
|
"vis-util": "^5.0.2",
|
||||||
"web-dialog": "^0.0.11",
|
"web-dialog": "^0.0.11",
|
||||||
"xss": "^1.0.14",
|
"xss": "^1.0.14",
|
||||||
"zod": "^3.20.6"
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.19.0",
|
"@babel/core": "^7.19.0",
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"@babel/plugin-proposal-decorators": "^7.19.0",
|
"@babel/plugin-proposal-decorators": "^7.19.0",
|
||||||
"@rollup/plugin-babel": "^5.3.1",
|
"@rollup/plugin-babel": "^5.3.1",
|
||||||
"@rollup/plugin-commonjs": "^22.0.2",
|
"@rollup/plugin-commonjs": "^22.0.2",
|
||||||
"@rollup/plugin-image": "^2.1.1",
|
"@rollup/plugin-image": "^3.0.2",
|
||||||
"@rollup/plugin-json": "^4.1.0",
|
"@rollup/plugin-json": "^4.1.0",
|
||||||
"@rollup/plugin-node-resolve": "^13.3.0",
|
"@rollup/plugin-node-resolve": "^13.3.0",
|
||||||
"@rollup/plugin-replace": "^4.0.0",
|
"@rollup/plugin-replace": "^4.0.0",
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import { CameraConfig, ExtendedHomeAssistant } from '../../types';
|
||||||
|
import { ViewMedia } from '../../view/media';
|
||||||
|
import {
|
||||||
|
CameraManagerMediaCapabilities,
|
||||||
|
DataQuery,
|
||||||
|
EventQuery,
|
||||||
|
PartialEventQuery,
|
||||||
|
CameraConfigs,
|
||||||
|
CameraManagerCameraCapabilities,
|
||||||
|
QueryType,
|
||||||
|
CameraEndpoint,
|
||||||
|
} from '../types';
|
||||||
|
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||||
|
import { CameraManagerEngine } from '../engine';
|
||||||
|
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||||
|
import { CameraInitializationError } from '../error';
|
||||||
|
import { localize } from '../../localize/localize';
|
||||||
|
import { Entity } from '../../utils/ha/entity-registry/types';
|
||||||
|
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
|
||||||
|
import {
|
||||||
|
BROWSE_MEDIA_CACHE_SECONDS,
|
||||||
|
MEDIA_CLASS_IMAGE,
|
||||||
|
MEDIA_CLASS_VIDEO,
|
||||||
|
RichBrowseMedia,
|
||||||
|
} from '../../utils/ha/browse-media/types';
|
||||||
|
import { BrowseMediaMetadata } from './types';
|
||||||
|
import { rangesOverlap } from '../range';
|
||||||
|
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
|
||||||
|
import { canonicalizeHAURL } from '../../utils/ha';
|
||||||
|
import { RequestCache } from '../cache';
|
||||||
|
import { BrowseMediaViewMediaFactory } from './media';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A utility method to determine if a browse media object matches against a
|
||||||
|
* start and end date.
|
||||||
|
* @param media The browse media object (with rich metadata).
|
||||||
|
* @param start The optional start date.
|
||||||
|
* @param end The optional end date.
|
||||||
|
* @returns `true` if the media falls within the provided dates.
|
||||||
|
*/
|
||||||
|
export const isMediaWithinDates = (
|
||||||
|
media: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
start?: Date,
|
||||||
|
end?: Date,
|
||||||
|
): boolean => {
|
||||||
|
// If no date is specified at all, everything matches.
|
||||||
|
const dateReference = start ?? end;
|
||||||
|
if (!dateReference) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's no metadata, nothing matches.
|
||||||
|
if (!media._metadata) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if:
|
||||||
|
// - The media starts within the query timeframe.
|
||||||
|
// - The media ends within the query timeframe.
|
||||||
|
// - The media entirely encompasses the query timeframe.
|
||||||
|
return rangesOverlap(
|
||||||
|
{
|
||||||
|
start: media._metadata.startDate,
|
||||||
|
end: media._metadata.endDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: start ?? dateReference,
|
||||||
|
end: end ?? dateReference,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getViewMediaFromBrowseMediaArray = (
|
||||||
|
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[],
|
||||||
|
): ViewMedia[] | null => {
|
||||||
|
const lookup: Map<string, ViewMedia> = new Map();
|
||||||
|
for (const browseMediaItem of browseMedia) {
|
||||||
|
const cameraID = browseMediaItem._metadata?.cameraID;
|
||||||
|
if (!cameraID) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaType =
|
||||||
|
browseMediaItem.media_class === MEDIA_CLASS_VIDEO
|
||||||
|
? 'clip'
|
||||||
|
: browseMediaItem.media_class === MEDIA_CLASS_IMAGE
|
||||||
|
? 'snapshot'
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!mediaType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const media = BrowseMediaViewMediaFactory.createEventViewMedia(
|
||||||
|
mediaType,
|
||||||
|
browseMediaItem,
|
||||||
|
cameraID,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (media) {
|
||||||
|
const id = media.getID();
|
||||||
|
const existing = lookup.get(id);
|
||||||
|
// De-duplicate events with precisely the same ID (same
|
||||||
|
// hour/minute/second) choosing clip > snapshot.
|
||||||
|
if (
|
||||||
|
!existing ||
|
||||||
|
(existing.getMediaType() === 'snapshot' && media.getMediaType() === 'clip')
|
||||||
|
) {
|
||||||
|
lookup.set(id, media);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...lookup.values()];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A base class for cameras that read events from HA BrowseMedia interface.
|
||||||
|
*/
|
||||||
|
export class BrowseMediaCameraManagerEngine
|
||||||
|
extends GenericCameraManagerEngine
|
||||||
|
implements CameraManagerEngine
|
||||||
|
{
|
||||||
|
protected _cameraEntities: Map<string, Entity> = new Map();
|
||||||
|
protected _browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>;
|
||||||
|
protected _resolvedMediaCache: ResolvedMediaCache;
|
||||||
|
protected _requestCache: RequestCache;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>,
|
||||||
|
resolvedMediaCache: ResolvedMediaCache,
|
||||||
|
requestCache: RequestCache,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
this._browseMediaManager = browseMediaManager;
|
||||||
|
this._resolvedMediaCache = resolvedMediaCache;
|
||||||
|
this._requestCache = requestCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initializeCamera(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entityRegistryManager: EntityRegistryManager,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
): Promise<CameraConfig> {
|
||||||
|
const entity = cameraConfig.camera_entity
|
||||||
|
? await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity)
|
||||||
|
: null;
|
||||||
|
if (!entity || !cameraConfig.camera_entity) {
|
||||||
|
throw new CameraInitializationError(
|
||||||
|
localize('error.no_camera_entity'),
|
||||||
|
cameraConfig,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._cameraEntities.set(cameraConfig.camera_entity, entity);
|
||||||
|
return cameraConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateDefaultEventQuery(
|
||||||
|
_cameras: CameraConfigs,
|
||||||
|
cameraIDs: Set<string>,
|
||||||
|
query: PartialEventQuery,
|
||||||
|
): EventQuery[] | null {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: QueryType.Event,
|
||||||
|
cameraIDs: cameraIDs,
|
||||||
|
...query,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMediaDownloadPath(
|
||||||
|
hass: ExtendedHomeAssistant,
|
||||||
|
_cameraConfig: CameraConfig,
|
||||||
|
media: ViewMedia,
|
||||||
|
): Promise<CameraEndpoint | null> {
|
||||||
|
const contentID = media.getContentID();
|
||||||
|
if (!contentID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const resolvedMedia = await resolveMedia(hass, contentID, this._resolvedMediaCache);
|
||||||
|
return resolvedMedia
|
||||||
|
? { endpoint: canonicalizeHAURL(hass, resolvedMedia.url) }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getQueryResultMaxAge(query: DataQuery): number | null {
|
||||||
|
if (query.type === QueryType.Event) {
|
||||||
|
return BROWSE_MEDIA_CACHE_SECONDS;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCameraCapabilities(
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
): CameraManagerCameraCapabilities | null {
|
||||||
|
const parentCapabilities = super.getCameraCapabilities(cameraConfig);
|
||||||
|
if (!parentCapabilities) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...parentCapabilities,
|
||||||
|
supportsClips: true,
|
||||||
|
supportsSnapshots: true,
|
||||||
|
supportsTimeline: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities {
|
||||||
|
return {
|
||||||
|
canFavorite: false,
|
||||||
|
canDownload: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import format from 'date-fns/format';
|
||||||
|
import isEqual from 'lodash-es/isEqual';
|
||||||
|
import { formatDateAndTime } from '../../utils/basic';
|
||||||
|
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
|
||||||
|
import {
|
||||||
|
ViewMedia,
|
||||||
|
EventViewMedia,
|
||||||
|
ViewMediaType,
|
||||||
|
VideoContentType,
|
||||||
|
} from '../../view/media';
|
||||||
|
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||||
|
|
||||||
|
class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||||
|
protected _browseMedia: RichBrowseMedia<BrowseMediaMetadata>;
|
||||||
|
protected _id: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
mediaType: ViewMediaType,
|
||||||
|
cameraID: string,
|
||||||
|
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
) {
|
||||||
|
super(mediaType, cameraID);
|
||||||
|
this._browseMedia = browseMedia;
|
||||||
|
|
||||||
|
// Generate a custom ID that uses the start date (to allow multiple
|
||||||
|
// BrowseMedia objects (e.g. images and movies) to be de-duplicated).
|
||||||
|
if (browseMedia._metadata?.startDate) {
|
||||||
|
this._id = `${cameraID}/${format(
|
||||||
|
browseMedia._metadata.startDate,
|
||||||
|
'yyyy-MM-dd HH:mm:ss',
|
||||||
|
)}`;
|
||||||
|
} else {
|
||||||
|
this._id = browseMedia.media_content_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStartTime(): Date | null {
|
||||||
|
return this._browseMedia._metadata?.startDate ?? null;
|
||||||
|
}
|
||||||
|
public getEndTime(): Date | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public getVideoContentType(): VideoContentType | null {
|
||||||
|
return VideoContentType.MP4;
|
||||||
|
}
|
||||||
|
public getID(): string {
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
public getContentID(): string {
|
||||||
|
return this._browseMedia.media_content_id;
|
||||||
|
}
|
||||||
|
public getTitle(): string | null {
|
||||||
|
const startTime = this.getStartTime();
|
||||||
|
return startTime ? formatDateAndTime(startTime) : this._browseMedia.title;
|
||||||
|
}
|
||||||
|
public getThumbnail(): string | null {
|
||||||
|
return this._browseMedia.thumbnail;
|
||||||
|
}
|
||||||
|
public getWhat(): string[] | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public getScore(): number | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public getTags(): string[] | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
public isGroupableWith(that: EventViewMedia): boolean {
|
||||||
|
return (
|
||||||
|
this.getMediaType() === that.getMediaType() &&
|
||||||
|
isEqual(this.getWhere(), that.getWhere()) &&
|
||||||
|
isEqual(this.getWhat(), that.getWhat())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrowseMediaViewMediaFactory {
|
||||||
|
static createEventViewMedia(
|
||||||
|
mediaType: 'clip' | 'snapshot',
|
||||||
|
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
cameraID: string,
|
||||||
|
): BrowseMediaEventViewMedia | null {
|
||||||
|
return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface BrowseMediaMetadata {
|
||||||
|
cameraID: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ interface CameraManagerCache<Request, Response> {
|
|||||||
set(request: Request, response: Response, expiry?: Date): void;
|
set(request: Request, response: Response, expiry?: Date): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class MemoryRequestCache<Request, Response>
|
export class MemoryRequestCache<Request, Response>
|
||||||
implements CameraManagerCache<Request, Response>
|
implements CameraManagerCache<Request, Response>
|
||||||
{
|
{
|
||||||
protected _data: RequestCacheItem<Request, Response>[] = [];
|
protected _data: RequestCacheItem<Request, Response>[] = [];
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { localize } from '../localize/localize';
|
import { localize } from '../localize/localize';
|
||||||
import { CameraConfig, CardWideConfig } from '../types';
|
import { CameraConfig, CardWideConfig } from '../types';
|
||||||
|
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
|
||||||
|
import { BrowseMedia } from '../utils/ha/browse-media/types';
|
||||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||||
import { Entity } from '../utils/ha/entity-registry/types';
|
import { Entity } from '../utils/ha/entity-registry/types';
|
||||||
import { RecordingSegmentsCache, RequestCache } from './cache';
|
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||||
|
import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cache';
|
||||||
import { CameraManagerEngine } from './engine';
|
import { CameraManagerEngine } from './engine';
|
||||||
import { CameraInitializationError } from './error';
|
import { CameraInitializationError } from './error';
|
||||||
import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
|
import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
|
||||||
import { GenericCameraManagerEngine } from './generic/engine-generic';
|
import { GenericCameraManagerEngine } from './generic/engine-generic';
|
||||||
|
import { MotionEyeCameraManagerEngine } from './motioneye/engine-motioneye';
|
||||||
import { Engine } from './types';
|
import { Engine } from './types';
|
||||||
|
|
||||||
export class CameraManagerEngineFactory {
|
export class CameraManagerEngineFactory {
|
||||||
protected _entityRegistryManager: EntityRegistryManager;
|
protected _entityRegistryManager: EntityRegistryManager;
|
||||||
|
protected _resolvedMediaCache: ResolvedMediaCache;
|
||||||
protected _cardWideConfig: CardWideConfig;
|
protected _cardWideConfig: CardWideConfig;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
entityRegistryManager: EntityRegistryManager,
|
entityRegistryManager: EntityRegistryManager,
|
||||||
|
resolvedMediaCache: ResolvedMediaCache,
|
||||||
cardWideConfig: CardWideConfig,
|
cardWideConfig: CardWideConfig,
|
||||||
) {
|
) {
|
||||||
this._entityRegistryManager = entityRegistryManager;
|
this._entityRegistryManager = entityRegistryManager;
|
||||||
this._cardWideConfig = cardWideConfig;
|
this._cardWideConfig = cardWideConfig;
|
||||||
|
this._resolvedMediaCache = resolvedMediaCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
public createEngine(engine: Engine): CameraManagerEngine | null {
|
public createEngine(engine: Engine): CameraManagerEngine | null {
|
||||||
@@ -35,6 +42,12 @@ export class CameraManagerEngineFactory {
|
|||||||
new RequestCache(),
|
new RequestCache(),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case Engine.MotionEye:
|
||||||
|
cameraManagerEngine = new MotionEyeCameraManagerEngine(
|
||||||
|
new BrowseMediaManager(new MemoryRequestCache<string, BrowseMedia>()),
|
||||||
|
this._resolvedMediaCache,
|
||||||
|
new RequestCache(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return cameraManagerEngine;
|
return cameraManagerEngine;
|
||||||
}
|
}
|
||||||
@@ -50,6 +63,8 @@ export class CameraManagerEngineFactory {
|
|||||||
let engine: Engine | null = null;
|
let engine: Engine | null = null;
|
||||||
if (cameraConfig.engine === 'frigate') {
|
if (cameraConfig.engine === 'frigate') {
|
||||||
engine = Engine.Frigate;
|
engine = Engine.Frigate;
|
||||||
|
} else if (cameraConfig.engine === 'motioneye') {
|
||||||
|
engine = Engine.MotionEye;
|
||||||
} else if (cameraConfig.engine === 'auto') {
|
} else if (cameraConfig.engine === 'auto') {
|
||||||
const cameraEntity = cameraConfig.camera_entity;
|
const cameraEntity = cameraConfig.camera_entity;
|
||||||
|
|
||||||
@@ -70,6 +85,9 @@ export class CameraManagerEngineFactory {
|
|||||||
case 'frigate':
|
case 'frigate':
|
||||||
engine = Engine.Frigate;
|
engine = Engine.Frigate;
|
||||||
break;
|
break;
|
||||||
|
case 'motioneye':
|
||||||
|
engine = Engine.MotionEye;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
engine = Engine.Generic;
|
engine = Engine.Generic;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { CameraConfig } from '../types';
|
import { CameraConfig, ExtendedHomeAssistant } from '../types';
|
||||||
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
import { EntityRegistryManager } from '../utils/ha/entity-registry';
|
||||||
import { ViewMedia } from '../view/media';
|
import { ViewMedia } from '../view/media';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
MediaMetadataQuery,
|
MediaMetadataQuery,
|
||||||
MediaMetadataQueryResultsMap,
|
MediaMetadataQueryResultsMap,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
|
CameraEndpoint,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
|
||||||
@@ -90,7 +91,11 @@ export interface CameraManagerEngine {
|
|||||||
results: QueryReturnType<RecordingQuery>,
|
results: QueryReturnType<RecordingQuery>,
|
||||||
): ViewMedia[] | null;
|
): ViewMedia[] | null;
|
||||||
|
|
||||||
getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null;
|
getMediaDownloadPath(
|
||||||
|
hass: ExtendedHomeAssistant,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
media: ViewMedia,
|
||||||
|
): Promise<CameraEndpoint | null>;
|
||||||
|
|
||||||
favoriteMedia(
|
favoriteMedia(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M130 446.5C131.6 459.3 145 468 137 470C129 472 94 406.5 86 378.5C78 350.5 73.5 319 75.4999 301C77.4999 283 181 255 181 247.5C181 240 147.5 247 146 241C144.5 235 171.3 238.6 178.5 229C189.75 214 204 216.5 213 208.5C222 200.5 233 170 235 157C237 144 215 129 209 119C203 109 222 102 268 83C314 64 460 22 462 27C464 32 414 53 379 66C344 79 287 104 287 111C287 118 290 123.5 288 139.5C286 155.5 285.76 162.971 282 173.5C279.5 180.5 277 197 282 212C286 224 299 233 305 235C310 235.333 323.8 235.8 339 235C358 234 385 236 385 241C385 246 344 243 344 250C344 257 386 249 385 256C384 263 350 260 332 260C317.6 260 296.333 259.333 287 256L285 263C281.667 263 274.7 265 267.5 265C258.5 265 258 268 241.5 268C225 268 230 267 215 266C200 265 144 308 134 322C124 336 130 370 130 385.5C130 399.428 128 430.5 130 446.5Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 936 B |
@@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers';
|
|||||||
import add from 'date-fns/add';
|
import add from 'date-fns/add';
|
||||||
import endOfHour from 'date-fns/endOfHour';
|
import endOfHour from 'date-fns/endOfHour';
|
||||||
import startOfHour from 'date-fns/startOfHour';
|
import startOfHour from 'date-fns/startOfHour';
|
||||||
import { CameraConfig, CardWideConfig } from '../../types';
|
import { CameraConfig, CardWideConfig, ExtendedHomeAssistant } from '../../types';
|
||||||
import { ViewMedia } from '../../view/media';
|
import { ViewMedia } from '../../view/media';
|
||||||
import { RecordingSegmentsCache, RequestCache } from '../cache';
|
import { RecordingSegmentsCache, RequestCache } from '../cache';
|
||||||
import {
|
import {
|
||||||
@@ -80,6 +80,7 @@ import { localize } from '../../localize/localize';
|
|||||||
import uniq from 'lodash-es/uniq';
|
import uniq from 'lodash-es/uniq';
|
||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||||
|
import frigateLogo from './assets/frigate-logo-dark.svg';
|
||||||
|
|
||||||
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||||
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
|
||||||
@@ -302,26 +303,32 @@ export class FrigateCameraManagerEngine
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMediaDownloadPath(
|
public async getMediaDownloadPath(
|
||||||
|
_hass: ExtendedHomeAssistant,
|
||||||
cameraConfig: CameraConfig,
|
cameraConfig: CameraConfig,
|
||||||
media: ViewMedia,
|
media: ViewMedia,
|
||||||
): string | null {
|
): Promise<CameraEndpoint | null> {
|
||||||
let path: string | null = null;
|
|
||||||
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
|
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
|
||||||
path =
|
return {
|
||||||
|
endpoint:
|
||||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||||
`/notifications/${media.getID()}/` +
|
`/notifications/${media.getID()}/` +
|
||||||
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
|
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
|
||||||
`?download=true`;
|
`?download=true`,
|
||||||
|
sign: true,
|
||||||
|
};
|
||||||
} else if (FrigateViewMediaClassifier.isFrigateRecording(media)) {
|
} else if (FrigateViewMediaClassifier.isFrigateRecording(media)) {
|
||||||
path =
|
return {
|
||||||
|
endpoint:
|
||||||
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
`/api/frigate/${cameraConfig.frigate.client_id}` +
|
||||||
`/recording/${cameraConfig.frigate.camera_name}` +
|
`/recording/${cameraConfig.frigate.camera_name}` +
|
||||||
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
|
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
|
||||||
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
|
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
|
||||||
`?download=true`;
|
`?download=true`,
|
||||||
|
sign: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return path;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public generateDefaultEventQuery(
|
public generateDefaultEventQuery(
|
||||||
@@ -1097,6 +1104,7 @@ export class FrigateCameraManagerEngine
|
|||||||
cameraConfig.id ??
|
cameraConfig.id ??
|
||||||
'',
|
'',
|
||||||
icon: metadata.icon,
|
icon: metadata.icon,
|
||||||
|
engineLogo: frigateLogo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,7 +1186,7 @@ export class FrigateCameraManagerEngine
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getWebRTCCard = (): CameraEndpoint | null => {
|
const getWebRTCCard = (): CameraEndpoint | null => {
|
||||||
// By defaykt use the frigate camera name which is the default recommended
|
// By default use the frigate camera name which is the default recommended
|
||||||
// setup as per:
|
// setup as per:
|
||||||
// https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/
|
// https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/
|
||||||
//
|
//
|
||||||
@@ -1194,14 +1202,11 @@ export class FrigateCameraManagerEngine
|
|||||||
const jsmpeg = getJSMPEG();
|
const jsmpeg = getJSMPEG();
|
||||||
const webrtcCard = getWebRTCCard();
|
const webrtcCard = getWebRTCCard();
|
||||||
|
|
||||||
return ui || go2rtc || jsmpeg
|
return {
|
||||||
? {
|
|
||||||
...(ui && { ui: ui }),
|
...(ui && { ui: ui }),
|
||||||
...(go2rtc && { go2rtc: go2rtc }),
|
...(go2rtc && { go2rtc: go2rtc }),
|
||||||
...(jsmpeg && { jsmpeg: jsmpeg }),
|
...(jsmpeg && { jsmpeg: jsmpeg }),
|
||||||
...(jsmpeg && { jsmpeg: jsmpeg }),
|
|
||||||
...(webrtcCard && { webrtcCard: webrtcCard }),
|
...(webrtcCard && { webrtcCard: webrtcCard }),
|
||||||
}
|
};
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
EventViewMedia,
|
EventViewMedia,
|
||||||
RecordingViewMedia,
|
RecordingViewMedia,
|
||||||
ViewMediaType,
|
ViewMediaType,
|
||||||
|
VideoContentType,
|
||||||
} from '../../view/media';
|
} from '../../view/media';
|
||||||
import { FrigateEvent, FrigateRecording } from './types';
|
import { FrigateEvent, FrigateRecording } from './types';
|
||||||
import {
|
import {
|
||||||
@@ -42,15 +43,20 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
|
|||||||
this._subLabels = subLabels ?? null;
|
this._subLabels = subLabels ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasClip(): boolean {
|
|
||||||
return !!this._event.has_clip;
|
|
||||||
}
|
|
||||||
public getStartTime(): Date {
|
public getStartTime(): Date {
|
||||||
return fromUnixTime(this._event.start_time);
|
return fromUnixTime(this._event.start_time);
|
||||||
}
|
}
|
||||||
public getEndTime(): Date | null {
|
public getEndTime(): Date | null {
|
||||||
return this._event.end_time ? fromUnixTime(this._event.end_time) : null;
|
return this._event.end_time ? fromUnixTime(this._event.end_time) : null;
|
||||||
}
|
}
|
||||||
|
public inProgress(): boolean | null {
|
||||||
|
// In Frigate, events/recordings always have end times unless they are in
|
||||||
|
// progress.
|
||||||
|
return !this.getEndTime();
|
||||||
|
}
|
||||||
|
public getVideoContentType(): VideoContentType | null {
|
||||||
|
return VideoContentType.HLS;
|
||||||
|
}
|
||||||
public getID(): string {
|
public getID(): string {
|
||||||
return this._event.id;
|
return this._event.id;
|
||||||
}
|
}
|
||||||
@@ -123,6 +129,11 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
|
|||||||
public getEndTime(): Date {
|
public getEndTime(): Date {
|
||||||
return this._recording.endTime;
|
return this._recording.endTime;
|
||||||
}
|
}
|
||||||
|
public inProgress(): boolean | null {
|
||||||
|
// In Frigate, events/recordings always have end times unless they are in
|
||||||
|
// progress.
|
||||||
|
return !this.getEndTime();
|
||||||
|
}
|
||||||
public getContentID(): string | null {
|
public getContentID(): string | null {
|
||||||
return this._contentID;
|
return this._contentID;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export interface FrigateRecording {
|
|||||||
export const eventSummarySchema = z
|
export const eventSummarySchema = z
|
||||||
.object({
|
.object({
|
||||||
camera: z.string(),
|
camera: z.string(),
|
||||||
|
// Days in RFC3339 format.
|
||||||
day: z.string(),
|
day: z.string(),
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
sub_label: z.string().nullable(),
|
sub_label: z.string().nullable(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
|
|
||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { CameraConfig } from '../../types';
|
import { CameraConfig, ExtendedHomeAssistant } from '../../types';
|
||||||
import { ViewMedia } from '../../view/media';
|
import { ViewMedia } from '../../view/media';
|
||||||
import {
|
import {
|
||||||
CameraManagerCameraMetadata,
|
CameraManagerCameraMetadata,
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
MediaMetadataQuery,
|
MediaMetadataQuery,
|
||||||
MediaMetadataQueryResultsMap,
|
MediaMetadataQueryResultsMap,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
|
CameraEndpoint,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
|
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
|
||||||
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
|
||||||
@@ -112,10 +113,11 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMediaDownloadPath(
|
public async getMediaDownloadPath(
|
||||||
|
_hass: ExtendedHomeAssistant,
|
||||||
_cameraConfig: CameraConfig,
|
_cameraConfig: CameraConfig,
|
||||||
_media: ViewMedia,
|
_media: ViewMedia,
|
||||||
): string | null {
|
): Promise<CameraEndpoint | null> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +181,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
|
|||||||
supportsRecordings: false,
|
supportsRecordings: false,
|
||||||
supportsSnapshots: false,
|
supportsSnapshots: false,
|
||||||
supportsTimeline: false,
|
supportsTimeline: false,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null {
|
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { CameraConfig, CamerasConfig, CardWideConfig } from '../types.js';
|
import {
|
||||||
|
CameraConfig,
|
||||||
|
CamerasConfig,
|
||||||
|
CardWideConfig,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
|
} from '../types.js';
|
||||||
import { allPromises, arrayify, setify } from '../utils/basic.js';
|
import { allPromises, arrayify, setify } from '../utils/basic.js';
|
||||||
import {
|
import {
|
||||||
CameraManagerCameraCapabilities,
|
CameraManagerCameraCapabilities,
|
||||||
@@ -34,11 +39,10 @@ import {
|
|||||||
MediaMetadataQuery,
|
MediaMetadataQuery,
|
||||||
MediaMetadataQueryResults,
|
MediaMetadataQueryResults,
|
||||||
EngineOptions,
|
EngineOptions,
|
||||||
|
CameraEndpoint,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import orderBy from 'lodash-es/orderBy';
|
|
||||||
import { CameraManagerEngineFactory } from './engine-factory.js';
|
import { CameraManagerEngineFactory } from './engine-factory.js';
|
||||||
import { ViewMedia } from '../view/media.js';
|
import { ViewMedia } from '../view/media.js';
|
||||||
import uniqBy from 'lodash-es/uniqBy';
|
|
||||||
import { CameraManagerEngine } from './engine.js';
|
import { CameraManagerEngine } from './engine.js';
|
||||||
import sum from 'lodash-es/sum';
|
import sum from 'lodash-es/sum';
|
||||||
import add from 'date-fns/add';
|
import add from 'date-fns/add';
|
||||||
@@ -50,6 +54,7 @@ import { CameraInitializationError } from './error.js';
|
|||||||
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
|
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
|
||||||
import cloneDeep from 'lodash-es/cloneDeep';
|
import cloneDeep from 'lodash-es/cloneDeep';
|
||||||
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
|
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
|
||||||
|
import { sortMedia } from './util.js';
|
||||||
|
|
||||||
class QueryClassifier {
|
class QueryClassifier {
|
||||||
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
|
||||||
@@ -356,7 +361,7 @@ export class CameraManager {
|
|||||||
concreteQueries.push(query as PartialQueryConcreteType<PQT>);
|
concreteQueries.push(query as PartialQueryConcreteType<PQT>);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return concreteQueries;
|
return concreteQueries.length ? concreteQueries : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getEvents(
|
public async getEvents(
|
||||||
@@ -459,20 +464,31 @@ export class CameraManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const outputMedia = sortMedia(results.concat(newChunkMedia));
|
||||||
|
|
||||||
|
// If the media did not _ACTUALLY_ get longer, there is no new media despite
|
||||||
|
// the increased limit, so just return null.
|
||||||
|
if (outputMedia.length === results.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
queries: extendedQueries,
|
queries: extendedQueries,
|
||||||
results: this._sortMedia(results.concat(newChunkMedia)),
|
results: outputMedia,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMediaDownloadPath(media: ViewMedia): string | null {
|
public async getMediaDownloadPath(
|
||||||
|
hass: ExtendedHomeAssistant,
|
||||||
|
media: ViewMedia,
|
||||||
|
): Promise<CameraEndpoint | null> {
|
||||||
const cameraConfig = this._store.getCameraConfigForMedia(media);
|
const cameraConfig = this._store.getCameraConfigForMedia(media);
|
||||||
const engine = this._store.getEngineForMedia(media);
|
const engine = this._store.getEngineForMedia(media);
|
||||||
|
|
||||||
if (!cameraConfig || !engine) {
|
if (!cameraConfig || !engine) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return engine.getMediaDownloadPath(cameraConfig, media);
|
return await engine.getMediaDownloadPath(hass, cameraConfig, media);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
|
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
|
||||||
@@ -623,7 +639,7 @@ export class CameraManager {
|
|||||||
await Promise.all(_queries.map((query) => processQuery(query)));
|
await Promise.all(_queries.map((query) => processQuery(query)));
|
||||||
|
|
||||||
const cachedOutputQueries = sum(
|
const cachedOutputQueries = sum(
|
||||||
Array.from(results.values()).map((result) => Number(result.cached)),
|
Array.from(results.values()).map((result) => Number(result.cached ?? 0)),
|
||||||
);
|
);
|
||||||
|
|
||||||
log(
|
log(
|
||||||
@@ -681,20 +697,7 @@ export class CameraManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this._sortMedia(mediaArray);
|
return 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 oldest -> youngest (so media is loaded in this
|
|
||||||
// order in the viewer which matches the left-to-right timeline order).
|
|
||||||
(media) => media.getStartTime(),
|
|
||||||
'asc',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCameraEndpoints(
|
public getCameraEndpoints(
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
id="svg2"
|
||||||
|
version="1.1"
|
||||||
|
inkscape:version="0.91 r13725"
|
||||||
|
width="64"
|
||||||
|
height="64"
|
||||||
|
xml:space="preserve"
|
||||||
|
sodipodi:docname="motioneye-icon.svg"
|
||||||
|
inkscape:export-filename="/home/ccrisan/projects/motioneye/static/img/motioneye-logo.png"
|
||||||
|
inkscape:export-xdpi="960"
|
||||||
|
inkscape:export-ydpi="960"><metadata
|
||||||
|
id="metadata8"><rdf:RDF><cc:Work
|
||||||
|
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
|
||||||
|
id="defs6"><linearGradient
|
||||||
|
id="linearGradient4351"
|
||||||
|
inkscape:collect="always"><stop
|
||||||
|
id="stop4353"
|
||||||
|
offset="0"
|
||||||
|
style="stop-color:#737373;stop-opacity:1" /><stop
|
||||||
|
id="stop4355"
|
||||||
|
offset="1"
|
||||||
|
style="stop-color:#585858;stop-opacity:1" /></linearGradient><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
id="linearGradient4205"><stop
|
||||||
|
style="stop-color:#4aa3e0;stop-opacity:1"
|
||||||
|
offset="0"
|
||||||
|
id="stop4207" /><stop
|
||||||
|
style="stop-color:#3096db;stop-opacity:1"
|
||||||
|
offset="1"
|
||||||
|
id="stop4209" /></linearGradient><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
id="linearGradient4197"><stop
|
||||||
|
style="stop-color:#787878;stop-opacity:1"
|
||||||
|
offset="0"
|
||||||
|
id="stop4199" /><stop
|
||||||
|
style="stop-color:#585858;stop-opacity:1"
|
||||||
|
offset="1"
|
||||||
|
id="stop4201" /></linearGradient><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient4351"
|
||||||
|
id="linearGradient4203"
|
||||||
|
x1="26.445793"
|
||||||
|
y1="47.517574"
|
||||||
|
x2="26.445793"
|
||||||
|
y2="3.8183768"
|
||||||
|
gradientUnits="userSpaceOnUse" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient4205"
|
||||||
|
id="linearGradient4211"
|
||||||
|
x1="26.602072"
|
||||||
|
y1="43.034946"
|
||||||
|
x2="26.602072"
|
||||||
|
y2="29.466328"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
gradientTransform="matrix(0.96428571,0,0,0.96428571,0.91428571,0.91428571)" /><filter
|
||||||
|
style="color-interpolation-filters:sRGB;"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
id="filter4285"><feFlood
|
||||||
|
flood-opacity="0.588235"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
result="flood"
|
||||||
|
id="feFlood4287" /><feComposite
|
||||||
|
in="flood"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
operator="in"
|
||||||
|
result="composite1"
|
||||||
|
id="feComposite4289" /><feGaussianBlur
|
||||||
|
in="composite1"
|
||||||
|
stdDeviation="0.6"
|
||||||
|
result="blur"
|
||||||
|
id="feGaussianBlur4291" /><feOffset
|
||||||
|
dx="0"
|
||||||
|
dy="-1"
|
||||||
|
result="offset"
|
||||||
|
id="feOffset4293" /><feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="offset"
|
||||||
|
operator="over"
|
||||||
|
result="composite2"
|
||||||
|
id="feComposite4295" /></filter><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient4197"
|
||||||
|
id="linearGradient4309"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="26.445793"
|
||||||
|
y1="47.517574"
|
||||||
|
x2="26.445793"
|
||||||
|
y2="3.8183768" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient4197"
|
||||||
|
id="linearGradient4311"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="26.445793"
|
||||||
|
y1="47.517574"
|
||||||
|
x2="26.445793"
|
||||||
|
y2="3.8183768" /><linearGradient
|
||||||
|
inkscape:collect="always"
|
||||||
|
xlink:href="#linearGradient4197"
|
||||||
|
id="linearGradient4313"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="26.445793"
|
||||||
|
y1="47.517574"
|
||||||
|
x2="26.445793"
|
||||||
|
y2="3.8183768" /><filter
|
||||||
|
style="color-interpolation-filters:sRGB;"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
id="filter4315"
|
||||||
|
x="-0.10000000000000001"
|
||||||
|
y="-0.16000000000000003"><feFlood
|
||||||
|
flood-opacity="0.588235"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
result="flood"
|
||||||
|
id="feFlood4317" /><feComposite
|
||||||
|
in="flood"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
operator="in"
|
||||||
|
result="composite1"
|
||||||
|
id="feComposite4319" /><feGaussianBlur
|
||||||
|
in="composite1"
|
||||||
|
stdDeviation="0.6"
|
||||||
|
result="blur"
|
||||||
|
id="feGaussianBlur4321" /><feOffset
|
||||||
|
dx="0"
|
||||||
|
dy="-1"
|
||||||
|
result="offset"
|
||||||
|
id="feOffset4323" /><feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="offset"
|
||||||
|
operator="over"
|
||||||
|
result="composite2"
|
||||||
|
id="feComposite4325" /></filter><filter
|
||||||
|
style="color-interpolation-filters:sRGB;"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
id="filter4327"><feFlood
|
||||||
|
flood-opacity="0.588235"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
result="flood"
|
||||||
|
id="feFlood4329" /><feComposite
|
||||||
|
in="flood"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
operator="in"
|
||||||
|
result="composite1"
|
||||||
|
id="feComposite4331" /><feGaussianBlur
|
||||||
|
in="composite1"
|
||||||
|
stdDeviation="0.6"
|
||||||
|
result="blur"
|
||||||
|
id="feGaussianBlur4333" /><feOffset
|
||||||
|
dx="0"
|
||||||
|
dy="-1"
|
||||||
|
result="offset"
|
||||||
|
id="feOffset4335" /><feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="offset"
|
||||||
|
operator="over"
|
||||||
|
result="composite2"
|
||||||
|
id="feComposite4337" /></filter><filter
|
||||||
|
style="color-interpolation-filters:sRGB;"
|
||||||
|
inkscape:label="Drop Shadow"
|
||||||
|
id="filter4339"><feFlood
|
||||||
|
flood-opacity="0.588235"
|
||||||
|
flood-color="rgb(0,0,0)"
|
||||||
|
result="flood"
|
||||||
|
id="feFlood4341" /><feComposite
|
||||||
|
in="flood"
|
||||||
|
in2="SourceGraphic"
|
||||||
|
operator="in"
|
||||||
|
result="composite1"
|
||||||
|
id="feComposite4343" /><feGaussianBlur
|
||||||
|
in="composite1"
|
||||||
|
stdDeviation="0.2"
|
||||||
|
result="blur"
|
||||||
|
id="feGaussianBlur4345" /><feOffset
|
||||||
|
dx="0"
|
||||||
|
dy="-0.5"
|
||||||
|
result="offset"
|
||||||
|
id="feOffset4347" /><feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="offset"
|
||||||
|
operator="over"
|
||||||
|
result="composite2"
|
||||||
|
id="feComposite4349" /></filter></defs><sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1025"
|
||||||
|
id="namedview4"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2"
|
||||||
|
inkscape:cx="-94.597631"
|
||||||
|
inkscape:cy="10.226517"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="27"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="g10"
|
||||||
|
showguides="true"
|
||||||
|
inkscape:guide-bbox="true" /><g
|
||||||
|
id="g10"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
inkscape:label="ink_ext_XXXXXX"
|
||||||
|
transform="matrix(1.25,0,0,-1.25,0,64)"><g
|
||||||
|
id="g4170"
|
||||||
|
style="fill:url(#linearGradient4203);fill-opacity:1;filter:url(#filter4327)"
|
||||||
|
transform="matrix(0.96428571,0,0,0.96428571,0.91428571,0.91428571)"><path
|
||||||
|
id="path4244"
|
||||||
|
d="M 8.9346154,40.515385 C 5.3647588,36.547307 3.2,31.357779 3.2,25.6 3.2,13.228821 13.228821,3.2 25.6,3.2 37.971179,3.2 48,13.228821 48,25.6 c 0,5.736682 -2.161128,10.952493 -5.707692,14.915385 -1.695935,-0.623286 -3.387833,-1.349065 -5.061539,-2.288462 3.2394,-0.937363 5.6,-3.937988 5.6,-7.457692 0,-4.260339 -3.469626,-7.753846 -7.753846,-7.753846 -3.633936,0 -6.690552,2.51055 -7.538461,5.869231 l -3.876924,0 c -0.840685,-3.360193 -3.903443,-5.869231 -7.538461,-5.869231 -4.284219,0 -7.7807693,3.493507 -7.7807693,7.753846 0,3.56112 2.4570323,6.5856 5.7615383,7.484616 -1.676267,0.912203 -3.404813,1.620556 -5.1692306,2.261538 z M 25.6,26.461538 c 0.532632,-1.981435 1.101793,-3.947553 3.446154,-5.16923 L 25.6,16.123077 22.153846,21.292308 c 2.053593,1.454966 3.000771,3.237758 3.446154,5.16923 z"
|
||||||
|
style="fill:url(#linearGradient4309);fill-opacity:1;stroke:none"
|
||||||
|
inkscape:connector-curvature="0" /><path
|
||||||
|
id="path4242"
|
||||||
|
d="m 16.123077,33.353847 c -1.427443,0 -2.584616,-1.157173 -2.584616,-2.584616 0,-1.427444 1.157173,-2.584615 2.584616,-2.584615 1.427444,0 2.584615,1.157171 2.584615,2.584615 0,1.427443 -1.157171,2.584616 -2.584615,2.584616 z"
|
||||||
|
style="fill:url(#linearGradient4311);fill-opacity:1;stroke:none"
|
||||||
|
inkscape:connector-curvature="0" /><path
|
||||||
|
id="path4240"
|
||||||
|
d="m 35.076923,33.353847 c -1.427443,0 -2.584615,-1.157173 -2.584615,-2.584616 0,-1.427444 1.157172,-2.584615 2.584615,-2.584615 1.427443,0 2.584616,1.157171 2.584616,2.584615 0,1.427443 -1.157173,2.584616 -2.584616,2.584616 z"
|
||||||
|
style="fill:url(#linearGradient4313);fill-opacity:1;stroke:none"
|
||||||
|
inkscape:connector-curvature="0" /></g><path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:#737373;fill-opacity:1;stroke:none;filter:url(#filter4339)"
|
||||||
|
d="m 25.6,47.2 c -4.373944,0 -8.437159,-1.399808 -11.838461,-3.634616 3.677605,-0.394237 7.305921,-1.342945 11.423077,-3.375 4.166157,2.122533 8.434154,3.008875 12.279808,3.452886 C 34.057131,45.890032 29.986674,47.2 25.6,47.2 Z"
|
||||||
|
id="path4248" /><path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
style="fill:url(#linearGradient4211);fill-opacity:1;stroke:none;filter:url(#filter4315)"
|
||||||
|
d="M 39.723077,42.552884 C 35.394064,42.5242 29.479588,40.397223 25.184616,38.432418 20.668821,40.064102 16.035448,42.649343 10.801923,42.526924 10.453022,42.51873 10.118061,42.50105 9.7634616,42.475 L 5.6615384,42.1375 9.5557693,40.839424 c 5.3417977,-1.74056 10.0398397,-2.851302 14.1749997,-10.025963 0.959101,0 2.845924,-4.15e-4 3.738462,-4.15e-4 4.11884,7.134039 9.059296,8.324614 14.149039,10.026378 L 45.460577,42.085577 41.4625,42.475 c -0.544847,0.05181 -1.120992,0.08198 -1.739423,0.07788 z"
|
||||||
|
id="path4246"
|
||||||
|
sodipodi:nodetypes="cccccccccccc" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,403 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import { CameraConfig } from '../../types';
|
||||||
|
import { ViewMedia } from '../../view/media';
|
||||||
|
import {
|
||||||
|
CameraConfigs,
|
||||||
|
CameraEndpoint,
|
||||||
|
CameraEndpoints,
|
||||||
|
CameraEndpointsContext,
|
||||||
|
CameraManagerCameraMetadata,
|
||||||
|
Engine,
|
||||||
|
EngineOptions,
|
||||||
|
EventQuery,
|
||||||
|
EventQueryResults,
|
||||||
|
EventQueryResultsMap,
|
||||||
|
MediaMetadataQuery,
|
||||||
|
MediaMetadataQueryResults,
|
||||||
|
MediaMetadataQueryResultsMap,
|
||||||
|
QueryResults,
|
||||||
|
QueryResultsType,
|
||||||
|
QueryReturnType,
|
||||||
|
} from '../types';
|
||||||
|
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||||
|
import {
|
||||||
|
BrowseMediaStep,
|
||||||
|
BrowseMediaTarget,
|
||||||
|
} from '../../utils/ha/browse-media/browse-media-manager';
|
||||||
|
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||||
|
import endOfDay from 'date-fns/endOfDay';
|
||||||
|
import {
|
||||||
|
BROWSE_MEDIA_CACHE_SECONDS,
|
||||||
|
BrowseMedia,
|
||||||
|
MEDIA_CLASS_IMAGE,
|
||||||
|
MEDIA_CLASS_VIDEO,
|
||||||
|
RichBrowseMedia,
|
||||||
|
} from '../../utils/ha/browse-media/types';
|
||||||
|
import parse from 'date-fns/parse';
|
||||||
|
import { MotionEyeEventQueryResults } from './types';
|
||||||
|
import orderBy from 'lodash-es/orderBy';
|
||||||
|
import startOfDay from 'date-fns/startOfDay';
|
||||||
|
import add from 'date-fns/add';
|
||||||
|
import {
|
||||||
|
BrowseMediaCameraManagerEngine,
|
||||||
|
getViewMediaFromBrowseMediaArray,
|
||||||
|
isMediaWithinDates,
|
||||||
|
} from '../browse-media/engine-browse-media';
|
||||||
|
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||||
|
import motioneyeLogo from './assets/motioneye-logo.svg';
|
||||||
|
|
||||||
|
class MotionEyeQueryResultsClassifier {
|
||||||
|
public static isMotionEyeEventQueryResults(
|
||||||
|
results: QueryResults,
|
||||||
|
): results is MotionEyeEventQueryResults {
|
||||||
|
return (
|
||||||
|
results.engine === Engine.MotionEye && results.type === QueryResultsType.Event
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOTIONEYE_REPL_SUBSTITUTIONS: Record<string, string> = {
|
||||||
|
'%Y': 'yyyy',
|
||||||
|
'%m': 'MM',
|
||||||
|
'%d': 'dd',
|
||||||
|
'%H': 'HH',
|
||||||
|
'%M': 'mm',
|
||||||
|
'%S': 'SS',
|
||||||
|
};
|
||||||
|
const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g);
|
||||||
|
|
||||||
|
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||||
|
public getEngineType(): Engine {
|
||||||
|
return Engine.MotionEye;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _convertMotionEyeTimeFormatToDateFNS(part: string): string {
|
||||||
|
return part.replace(
|
||||||
|
MOTIONEYE_REPL_REGEXP,
|
||||||
|
(_, key) => MOTIONEYE_REPL_SUBSTITUTIONS[key],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metadata for a MotionEye media file.
|
||||||
|
protected _motionEyeMetadataGeneratorFile(
|
||||||
|
cameraID: string,
|
||||||
|
dateFormat: string | null,
|
||||||
|
media: BrowseMedia,
|
||||||
|
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
): BrowseMediaMetadata | null {
|
||||||
|
let startDate = parent?._metadata?.startDate ?? new Date();
|
||||||
|
if (dateFormat) {
|
||||||
|
const extensionlessTitle = media.title.replace(/\.[^/.]+$/, '');
|
||||||
|
startDate = parse(extensionlessTitle, dateFormat, startDate);
|
||||||
|
if (!isValidDate(startDate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cameraID: cameraID,
|
||||||
|
startDate: startDate,
|
||||||
|
// MotionEye only has start times, the event is effectively a 'point'
|
||||||
|
endDate: startDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get metadata for a MotionEye media directory.
|
||||||
|
protected _motionEyeMetadataGeneratorDirectory(
|
||||||
|
cameraID: string,
|
||||||
|
dateFormat: string | null,
|
||||||
|
media: BrowseMedia,
|
||||||
|
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
): BrowseMediaMetadata | null {
|
||||||
|
let startDate = parent?._metadata?.startDate ?? new Date();
|
||||||
|
if (dateFormat) {
|
||||||
|
const parsedDate = parse(media.title, dateFormat, startDate);
|
||||||
|
if (!isValidDate(parsedDate)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
startDate = startOfDay(parsedDate);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cameraID: cameraID,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: parent?._metadata?.endDate ?? endOfDay(startDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get media directories that match a given criteria.
|
||||||
|
protected async _getMatchingDirectories(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: CameraConfigs,
|
||||||
|
cameraID: string,
|
||||||
|
matchOptions?: {
|
||||||
|
start?: Date;
|
||||||
|
end?: Date;
|
||||||
|
hasClip?: boolean;
|
||||||
|
hasSnapshot?: boolean;
|
||||||
|
} | null,
|
||||||
|
engineOptions?: EngineOptions,
|
||||||
|
): Promise<RichBrowseMedia<BrowseMediaMetadata>[] | null> {
|
||||||
|
const cameraEntityID = cameras.get(cameraID)?.camera_entity;
|
||||||
|
const entity = cameraEntityID ? this._cameraEntities.get(cameraEntityID) : null;
|
||||||
|
const configID = entity?.config_entry_id;
|
||||||
|
const deviceID = entity?.device_id;
|
||||||
|
const cameraConfig = cameras.get(cameraID);
|
||||||
|
|
||||||
|
if (!configID || !deviceID || !cameraConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateNextStep = (
|
||||||
|
parts: string[],
|
||||||
|
media: BrowseMediaTarget<BrowseMediaMetadata>[],
|
||||||
|
): BrowseMediaStep<BrowseMediaMetadata>[] => {
|
||||||
|
const next = parts.shift();
|
||||||
|
if (!next) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFormat = next.includes('%')
|
||||||
|
? this._convertMotionEyeTimeFormatToDateFNS(next)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
targets: media,
|
||||||
|
metadataGenerator: (
|
||||||
|
media: BrowseMedia,
|
||||||
|
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
) =>
|
||||||
|
this._motionEyeMetadataGeneratorDirectory(
|
||||||
|
cameraID,
|
||||||
|
dateFormat,
|
||||||
|
media,
|
||||||
|
parent,
|
||||||
|
),
|
||||||
|
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||||
|
media.can_expand &&
|
||||||
|
(!!dateFormat || media.title === next) &&
|
||||||
|
isMediaWithinDates(media, matchOptions?.start, matchOptions?.end),
|
||||||
|
advance: (media) => generateNextStep(parts, media),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// For motionEye snapshots and clips are mutually exclusive.
|
||||||
|
return await this._browseMediaManager.walkBrowseMedias(
|
||||||
|
hass,
|
||||||
|
[
|
||||||
|
...(matchOptions?.hasClip !== false && !matchOptions?.hasSnapshot
|
||||||
|
? generateNextStep(
|
||||||
|
cameraConfig.motioneye.movies.directory_pattern.split('/'),
|
||||||
|
[`media-source://motioneye/${configID}#${deviceID}#movies`],
|
||||||
|
)
|
||||||
|
: []),
|
||||||
|
...(matchOptions?.hasSnapshot !== false && !matchOptions?.hasClip
|
||||||
|
? generateNextStep(
|
||||||
|
cameraConfig.motioneye.images.directory_pattern.split('/'),
|
||||||
|
[`media-source://motioneye/${configID}#${deviceID}#images`],
|
||||||
|
)
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
{
|
||||||
|
useCache: engineOptions?.useCache,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getEvents(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: CameraConfigs,
|
||||||
|
query: EventQuery,
|
||||||
|
engineOptions?: EngineOptions,
|
||||||
|
): Promise<EventQueryResultsMap | null> {
|
||||||
|
// MotionEye does not support these query types and they will never match.
|
||||||
|
if (query.favorite || query.tags?.size || query.what?.size || query.where?.size) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: EventQueryResultsMap = new Map();
|
||||||
|
const getEventsForCamera = async (cameraID: string): Promise<void> => {
|
||||||
|
const perCameraQuery = { ...query, cameraIDs: new Set([cameraID]) };
|
||||||
|
const cachedResult =
|
||||||
|
engineOptions?.useCache ?? true ? this._requestCache.get(perCameraQuery) : null;
|
||||||
|
if (cachedResult) {
|
||||||
|
output.set(perCameraQuery, cachedResult as EventQueryResults);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraConfig = cameras.get(cameraID);
|
||||||
|
if (!cameraConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = await this._getMatchingDirectories(
|
||||||
|
hass,
|
||||||
|
cameras,
|
||||||
|
cameraID,
|
||||||
|
perCameraQuery,
|
||||||
|
engineOptions,
|
||||||
|
);
|
||||||
|
if (!directories || !directories.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moviesDateFormat = this._convertMotionEyeTimeFormatToDateFNS(
|
||||||
|
cameraConfig.motioneye.movies.file_pattern,
|
||||||
|
);
|
||||||
|
const imagesDateFormat = this._convertMotionEyeTimeFormatToDateFNS(
|
||||||
|
cameraConfig.motioneye.images.file_pattern,
|
||||||
|
);
|
||||||
|
|
||||||
|
const media = await this._browseMediaManager.walkBrowseMedias(
|
||||||
|
hass,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
targets: directories,
|
||||||
|
metadataGenerator: (
|
||||||
|
media: BrowseMedia,
|
||||||
|
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
media.media_class === MEDIA_CLASS_IMAGE ||
|
||||||
|
media.media_class === MEDIA_CLASS_VIDEO
|
||||||
|
) {
|
||||||
|
return this._motionEyeMetadataGeneratorFile(
|
||||||
|
cameraID,
|
||||||
|
media.media_class === MEDIA_CLASS_IMAGE
|
||||||
|
? imagesDateFormat
|
||||||
|
: moviesDateFormat,
|
||||||
|
media,
|
||||||
|
parent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||||
|
!media.can_expand &&
|
||||||
|
isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ useCache: engineOptions?.useCache },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sort by most recent then slice at the query limit.
|
||||||
|
const sortedMedia = orderBy(
|
||||||
|
media,
|
||||||
|
(media: RichBrowseMedia<BrowseMediaMetadata>) => media._metadata?.startDate,
|
||||||
|
'desc',
|
||||||
|
).slice(0, perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT);
|
||||||
|
|
||||||
|
const result: MotionEyeEventQueryResults = {
|
||||||
|
type: QueryResultsType.Event,
|
||||||
|
engine: Engine.MotionEye,
|
||||||
|
browseMedia: sortedMedia,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (engineOptions?.useCache ?? true) {
|
||||||
|
this._requestCache.set(
|
||||||
|
perCameraQuery,
|
||||||
|
{ ...result, cached: true },
|
||||||
|
result.expiry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
output.set(perCameraQuery, result);
|
||||||
|
};
|
||||||
|
|
||||||
|
await allPromises(query.cameraIDs, (cameraID) => getEventsForCamera(cameraID));
|
||||||
|
return output.size ? output : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateMediaFromEvents(
|
||||||
|
_hass: HomeAssistant,
|
||||||
|
_cameras: CameraConfigs,
|
||||||
|
_query: EventQuery,
|
||||||
|
results: QueryReturnType<EventQuery>,
|
||||||
|
): ViewMedia[] | null {
|
||||||
|
if (!MotionEyeQueryResultsClassifier.isMotionEyeEventQueryResults(results)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return getViewMediaFromBrowseMediaArray(results.browseMedia);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMediaMetadata(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameras: CameraConfigs,
|
||||||
|
query: MediaMetadataQuery,
|
||||||
|
engineOptions?: EngineOptions,
|
||||||
|
): Promise<MediaMetadataQueryResultsMap | null> {
|
||||||
|
const output: MediaMetadataQueryResultsMap = new Map();
|
||||||
|
if ((engineOptions?.useCache ?? true) && this._requestCache.has(query)) {
|
||||||
|
const cachedResult = <MediaMetadataQueryResults | null>(
|
||||||
|
this._requestCache.get(query)
|
||||||
|
);
|
||||||
|
if (cachedResult) {
|
||||||
|
output.set(query, cachedResult as MediaMetadataQueryResults);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const days: Set<string> = new Set();
|
||||||
|
const getDaysForCamera = async (cameraID: string): Promise<void> => {
|
||||||
|
const directories = await this._getMatchingDirectories(
|
||||||
|
hass,
|
||||||
|
cameras,
|
||||||
|
cameraID,
|
||||||
|
null,
|
||||||
|
engineOptions,
|
||||||
|
);
|
||||||
|
for (const dayDirectory of directories ?? []) {
|
||||||
|
if (dayDirectory._metadata) {
|
||||||
|
days.add(formatDate(dayDirectory._metadata?.startDate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await allPromises(query.cameraIDs, (cameraID) => getDaysForCamera(cameraID));
|
||||||
|
|
||||||
|
const result: MediaMetadataQueryResults = {
|
||||||
|
type: QueryResultsType.MediaMetadata,
|
||||||
|
engine: Engine.MotionEye,
|
||||||
|
metadata: {
|
||||||
|
...(days.size && { days: days }),
|
||||||
|
},
|
||||||
|
expiry: add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
|
||||||
|
cached: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (engineOptions?.useCache ?? true) {
|
||||||
|
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||||
|
}
|
||||||
|
output.set(query, result);
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCameraMetadata(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
): CameraManagerCameraMetadata {
|
||||||
|
const metadata = super.getCameraMetadata(hass, cameraConfig);
|
||||||
|
return {
|
||||||
|
...metadata,
|
||||||
|
engineLogo: motioneyeLogo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCameraEndpoints(
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_context?: CameraEndpointsContext,
|
||||||
|
): CameraEndpoints | null {
|
||||||
|
const getUIEndpoint = (): CameraEndpoint | null => {
|
||||||
|
return cameraConfig.motioneye?.url
|
||||||
|
? {
|
||||||
|
endpoint: cameraConfig.motioneye.url,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ui = getUIEndpoint();
|
||||||
|
return {
|
||||||
|
...(ui && { ui: ui }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Converted from https://raw.githubusercontent.com/motioneye-project/motioneye/python2/motioneye/static/img/motioneye-icon.svg .
|
||||||
|
export const MOTIONEYE_ICON_SVG_VIEWBOX = '0 0 64 64';
|
||||||
|
export const MOTIONEYE_ICON_SVG_PATH =
|
||||||
|
'M 49.65,10.81 ' +
|
||||||
|
'C 44.24,10.84 36.85,13.50 31.48,15.96 ' +
|
||||||
|
'25.84,13.92 20.04,10.69 13.50,10.84 ' +
|
||||||
|
'13.07,10.85 12.65,10.87 12.20,10.91 ' +
|
||||||
|
'12.20,10.91 7.08,11.33 7.08,11.33 ' +
|
||||||
|
'7.08,11.33 11.94,12.95 11.94,12.95 ' +
|
||||||
|
'18.62,15.13 24.49,16.51 29.66,25.48 ' +
|
||||||
|
'30.86,25.48 33.22,25.48 34.34,25.48 ' +
|
||||||
|
'39.49,16.57 45.66,15.08 52.02,12.95 ' +
|
||||||
|
'52.02,12.95 56.83,11.39 56.83,11.39 ' +
|
||||||
|
'56.83,11.39 51.83,10.91 51.83,10.91 ' +
|
||||||
|
'51.15,10.84 50.43,10.80 49.65,10.81 ' +
|
||||||
|
'49.65,10.81 49.65,10.81 49.65,10.81 Z ' +
|
||||||
|
'M 32.00,5.00 ' +
|
||||||
|
'C 26.53,5.00 21.45,6.75 17.20,9.54 ' +
|
||||||
|
'21.80,10.04 26.33,11.22 31.48,13.76 ' +
|
||||||
|
'36.69,11.11 42.02,10.00 46.83,9.45 ' +
|
||||||
|
'42.57,6.64 37.48,5.00 32.00,5.00 Z ' +
|
||||||
|
'M 43.42,22.65 ' +
|
||||||
|
'C 41.70,22.65 40.31,24.05 40.31,25.77 ' +
|
||||||
|
'40.31,27.49 41.70,28.88 43.42,28.88 ' +
|
||||||
|
'45.14,28.88 46.54,27.49 46.54,25.77 ' +
|
||||||
|
'46.54,24.05 45.14,22.65 43.42,22.65 Z ' +
|
||||||
|
'M 20.58,22.65 ' +
|
||||||
|
'C 18.86,22.65 17.46,24.05 17.46,25.77 ' +
|
||||||
|
'17.46,27.49 18.86,28.88 20.58,28.88 ' +
|
||||||
|
'22.30,28.88 23.69,27.49 23.69,25.77 ' +
|
||||||
|
'23.69,24.05 22.30,22.65 20.58,22.65 Z ' +
|
||||||
|
'M 11.91,14.02 ' +
|
||||||
|
'C 7.61,18.80 5.00,25.06 5.00,32.00 ' +
|
||||||
|
'5.00,46.91 17.09,59.00 32.00,59.00 ' +
|
||||||
|
'46.91,59.00 59.00,46.91 59.00,32.00 ' +
|
||||||
|
'59.00,25.09 56.40,18.80 52.12,14.02 ' +
|
||||||
|
'50.08,14.77 48.04,15.65 46.02,16.78 ' +
|
||||||
|
'49.92,17.91 52.77,21.53 52.77,25.77 ' +
|
||||||
|
'52.77,30.90 48.59,35.12 43.42,35.12 ' +
|
||||||
|
'39.04,35.12 35.36,32.09 34.34,28.04 ' +
|
||||||
|
'34.34,28.04 29.66,28.04 29.66,28.04 ' +
|
||||||
|
'28.65,32.09 24.96,35.12 20.58,35.12 ' +
|
||||||
|
'15.41,35.12 11.20,30.90 11.20,25.77 ' +
|
||||||
|
'11.20,21.48 14.16,17.83 18.14,16.75 ' +
|
||||||
|
'16.12,15.65 14.04,14.79 11.91,14.02 ' +
|
||||||
|
'11.91,14.02 11.91,14.02 11.91,14.02 Z ' +
|
||||||
|
'M 32.00,30.96 ' +
|
||||||
|
'C 32.64,33.35 33.33,35.72 36.15,37.19 ' +
|
||||||
|
'36.15,37.19 32.00,43.42 32.00,43.42 ' +
|
||||||
|
'32.00,43.42 27.85,37.19 27.85,37.19 ' +
|
||||||
|
'30.32,35.44 31.46,33.29 32.00,30.96 Z';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
|
||||||
|
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||||
|
import { Engine, EventQueryResults } from '../types';
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// MotionEye concrete query results
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
export interface MotionEyeEventQueryResults extends EventQueryResults {
|
||||||
|
engine: Engine.MotionEye;
|
||||||
|
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[];
|
||||||
|
}
|
||||||
@@ -80,7 +80,7 @@ export const rangesOverlap = (a: DateRange, b: DateRange): boolean => {
|
|||||||
return (
|
return (
|
||||||
// a starts within the range of b.
|
// a starts within the range of b.
|
||||||
(a.start >= b.start && a.start <= b.end) ||
|
(a.start >= b.start && a.start <= b.end) ||
|
||||||
// a events within the range of b.
|
// a ends within the range of b.
|
||||||
(a.end >= b.start && a.end <= b.end) ||
|
(a.end >= b.start && a.end <= b.end) ||
|
||||||
// a encompasses the entire range of b.
|
// a encompasses the entire range of b.
|
||||||
(a.start <= b.start && a.end >= b.end)
|
(a.start <= b.start && a.end >= b.end)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export enum QueryResultsType {
|
|||||||
export enum Engine {
|
export enum Engine {
|
||||||
Frigate = 'frigate',
|
Frigate = 'frigate',
|
||||||
Generic = 'generic',
|
Generic = 'generic',
|
||||||
|
MotionEye = 'motioneye',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataQuery {
|
export interface DataQuery {
|
||||||
@@ -110,6 +111,7 @@ export interface CameraManagerMediaCapabilities {
|
|||||||
export interface CameraManagerCameraMetadata {
|
export interface CameraManagerCameraMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
engineLogo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CameraEndpointsContext {
|
export interface CameraEndpointsContext {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import startOfDay from 'date-fns/startOfDay';
|
|||||||
import endOfDay from 'date-fns/endOfDay';
|
import endOfDay from 'date-fns/endOfDay';
|
||||||
import endOfMinute from 'date-fns/endOfMinute';
|
import endOfMinute from 'date-fns/endOfMinute';
|
||||||
import { DateRange } from './range';
|
import { DateRange } from './range';
|
||||||
|
import orderBy from 'lodash-es/orderBy';
|
||||||
|
import uniqBy from 'lodash-es/uniqBy';
|
||||||
|
import { ViewMedia } from '../view/media';
|
||||||
|
|
||||||
export const convertRangeToCacheFriendlyTimes = (
|
export const convertRangeToCacheFriendlyTimes = (
|
||||||
range: DateRange,
|
range: DateRange,
|
||||||
@@ -37,3 +40,16 @@ export const capEndDate = (end: Date): Date => {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
return end > now ? now : end;
|
return end > now ? now : end;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const 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 oldest -> youngest (so media is loaded in this
|
||||||
|
// order in the viewer which matches the left-to-right timeline order).
|
||||||
|
(media) => media.getStartTime(),
|
||||||
|
'asc',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+5
-1
@@ -1091,7 +1091,11 @@ class FrigateCard extends LitElement {
|
|||||||
cardWideConfig: CardWideConfig,
|
cardWideConfig: CardWideConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this._cameraManager = new CameraManager(
|
this._cameraManager = new CameraManager(
|
||||||
new CameraManagerEngineFactory(this._entityRegistryManager, cardWideConfig),
|
new CameraManagerEngineFactory(
|
||||||
|
this._entityRegistryManager,
|
||||||
|
this._resolvedMediaCache,
|
||||||
|
cardWideConfig,
|
||||||
|
),
|
||||||
this._cardWideConfig,
|
this._cardWideConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
} from '../utils/media-to-view.js';
|
} from '../utils/media-to-view.js';
|
||||||
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
|
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
|
||||||
import { View } from '../view/view.js';
|
import { View } from '../view/view.js';
|
||||||
import { dispatchMessageEvent, renderProgressIndicator } from './message.js';
|
import { renderMessage, renderProgressIndicator } from './message.js';
|
||||||
import './thumbnail.js';
|
import './thumbnail.js';
|
||||||
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
|
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
@@ -432,13 +432,19 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) {
|
if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) {
|
||||||
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
|
// Note that this is not throwing up an error message for the card to
|
||||||
|
// handle (as typical), but rather directly rendering the message into the
|
||||||
|
// gallery. This is to allow the filter to still be available when a given
|
||||||
|
// filter selection returns no media.
|
||||||
|
return renderMessage({
|
||||||
|
type: 'info',
|
||||||
|
message: localize('common.no_media'),
|
||||||
icon: 'mdi:multimedia',
|
icon: 'mdi:multimedia',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = this.view?.queryResults?.getSelectedResult();
|
const selected = this.view?.queryResults?.getSelectedResult();
|
||||||
return html`
|
return html` <div class="grid">
|
||||||
${this._showLoaderTop
|
${this._showLoaderTop
|
||||||
? html`${renderProgressIndicator({
|
? html`${renderProgressIndicator({
|
||||||
cardWideConfig: this.cardWideConfig,
|
cardWideConfig: this.cardWideConfig,
|
||||||
@@ -490,7 +496,7 @@ export class FrigateCardGalleryCore extends LitElement {
|
|||||||
componentRef: this._refLoaderBottom,
|
componentRef: this._refLoaderBottom,
|
||||||
})}`
|
})}`
|
||||||
: ''}
|
: ''}
|
||||||
`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
public updated(changedProps: PropertyValues): void {
|
public updated(changedProps: PropertyValues): void {
|
||||||
|
|||||||
@@ -539,8 +539,9 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
<frigate-card-live-provider
|
<frigate-card-live-provider
|
||||||
?disabled=${this.liveConfig.lazy_load}
|
?disabled=${this.liveConfig.lazy_load}
|
||||||
.cameraConfig=${cameraConfig}
|
.cameraConfig=${cameraConfig}
|
||||||
.cameraEndpoints=${guard([this.cameraManager, cameraID], () =>
|
.cameraEndpoints=${guard(
|
||||||
this.cameraManager?.getCameraEndpoints(cameraID),
|
[this.cameraManager, cameraID],
|
||||||
|
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
|
||||||
)}
|
)}
|
||||||
.label=${cameraMetadata?.title ?? ''}
|
.label=${cameraMetadata?.title ?? ''}
|
||||||
.liveConfig=${config}
|
.liveConfig=${config}
|
||||||
@@ -638,6 +639,7 @@ export class FrigateCardLiveCarousel extends LitElement {
|
|||||||
.label="${cameraMetadataCurrent
|
.label="${cameraMetadataCurrent
|
||||||
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
|
||||||
: ''}"
|
: ''}"
|
||||||
|
.logo="${cameraMetadataCurrent?.engineLogo}"
|
||||||
.titlePopupConfig=${config.controls.title}
|
.titlePopupConfig=${config.controls.title}
|
||||||
.selected=${this._getSelectedCameraIndex()}
|
.selected=${this._getSelectedCameraIndex()}
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
@@ -723,7 +725,7 @@ export class FrigateCardLiveProvider
|
|||||||
protected _refProvider: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
protected _refProvider: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
public async play(): Promise<void> {
|
||||||
playMediaMutingIfNecessary(this._refProvider.value);
|
playMediaMutingIfNecessary(this, this._refProvider.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public pause(): void {
|
public pause(): void {
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ export class FrigateCardMediaCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public label?: string;
|
public label?: string;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public logo?: string;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public titlePopupConfig?: TitleControlConfig;
|
public titlePopupConfig?: TitleControlConfig;
|
||||||
|
|
||||||
@@ -420,6 +423,7 @@ export class FrigateCardMediaCarousel extends LitElement {
|
|||||||
${ref(this._titleControlRef)}
|
${ref(this._titleControlRef)}
|
||||||
.config=${this.titlePopupConfig}
|
.config=${this.titlePopupConfig}
|
||||||
.text="${this.label}"
|
.text="${this.label}"
|
||||||
|
.logo="${this.logo}"
|
||||||
.fitInto=${this as HTMLElement}
|
.fitInto=${this as HTMLElement}
|
||||||
>
|
>
|
||||||
</frigate-card-title-control> `
|
</frigate-card-title-control> `
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export class FrigateCardMenu extends LitElement {
|
|||||||
</frigate-card-submenu-select>`;
|
</frigate-card-submenu-select>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let stateParameters: StateParameters = { ...button };
|
let stateParameters = { ...button } as StateParameters;
|
||||||
const svgPath =
|
const svgPath =
|
||||||
stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
|
stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from 'lit';
|
} from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
|
||||||
import { actionHandler } from '../action-handler-directive.js';
|
import { actionHandler } from '../action-handler-directive.js';
|
||||||
import submenuStyle from '../scss/submenu.scss';
|
import submenuStyle from '../scss/submenu.scss';
|
||||||
import {
|
import {
|
||||||
@@ -39,7 +39,7 @@ export class FrigateCardSubmenu extends LitElement {
|
|||||||
if (!this.hass) {
|
if (!this.hass) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stateParameters = refreshDynamicStateParameters(this.hass, { ...item });
|
const stateParameters = refreshDynamicStateParameters(this.hass, { ...item } as StateParameters);
|
||||||
const getIcon = (stateParameters: StateParameters): TemplateResult => {
|
const getIcon = (stateParameters: StateParameters): TemplateResult => {
|
||||||
if (stateParameters.icon) {
|
if (stateParameters.icon) {
|
||||||
return html` <ha-icon
|
return html` <ha-icon
|
||||||
@@ -95,7 +95,7 @@ export class FrigateCardSubmenu extends LitElement {
|
|||||||
@click=${(ev) => stopEventFromActivatingCardWideActions(ev)}
|
@click=${(ev) => stopEventFromActivatingCardWideActions(ev)}
|
||||||
>
|
>
|
||||||
<ha-icon-button
|
<ha-icon-button
|
||||||
style="${styleMap(this.submenu.style || {})}"
|
style="${styleMap(this.submenu.style as StyleInfo || {})}"
|
||||||
class="button"
|
class="button"
|
||||||
slot="trigger"
|
slot="trigger"
|
||||||
.label=${this.submenu.title || ''}
|
.label=${this.submenu.title || ''}
|
||||||
@@ -205,7 +205,7 @@ export class FrigateCardSubmenuSelect extends LitElement {
|
|||||||
icon: domainIcon('select'),
|
icon: domainIcon('select'),
|
||||||
|
|
||||||
// Pull out the dynamic properties (like icon, and title) from the state.
|
// Pull out the dynamic properties (like icon, and title) from the state.
|
||||||
...refreshDynamicStateParameters(this.hass, this.submenuSelect),
|
...refreshDynamicStateParameters(this.hass, this.submenuSelect as StateParameters),
|
||||||
|
|
||||||
// Override it with anything explicitly set in the submenuSelect.
|
// Override it with anything explicitly set in the submenuSelect.
|
||||||
...this.submenuSelect,
|
...this.submenuSelect,
|
||||||
|
|||||||
+28
-20
@@ -170,11 +170,9 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
|||||||
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
|
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
|
||||||
|
|
||||||
const rawEndTime = this.media.getEndTime();
|
const rawEndTime = this.media.getEndTime();
|
||||||
const endTime = rawStartTime
|
const duration =
|
||||||
? rawEndTime
|
rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
|
||||||
? getDurationString(rawStartTime, rawEndTime)
|
const inProgress = this.media.inProgress() ? localize('event.in_progress') : null;
|
||||||
: localize('event.in_progress')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const what = prettifyTitle(this.media.getWhat()?.join(', ')) ?? null;
|
const what = prettifyTitle(this.media.getWhat()?.join(', ')) ?? null;
|
||||||
const where = prettifyTitle(this.media.getWhere()?.join(', ')) ?? null;
|
const where = prettifyTitle(this.media.getWhere()?.join(', ')) ?? null;
|
||||||
@@ -200,13 +198,18 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
|
|||||||
></ha-icon>
|
></ha-icon>
|
||||||
<span title="${startTime}">${startTime}</span>
|
<span title="${startTime}">${startTime}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
${duration || inProgress
|
||||||
|
? html` <div>
|
||||||
<ha-icon
|
<ha-icon
|
||||||
title=${localize('event.duration')}
|
title=${localize('event.duration')}
|
||||||
.icon=${'mdi:clock-outline'}
|
.icon=${'mdi:clock-outline'}
|
||||||
></ha-icon>
|
></ha-icon>
|
||||||
<span title="${endTime}">${endTime}</span>
|
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
|
||||||
|
${inProgress
|
||||||
|
? html`<span title="${inProgress}">${inProgress}</span>`
|
||||||
|
: ''}
|
||||||
</div>`
|
</div>`
|
||||||
|
: ''}`
|
||||||
: ''}
|
: ''}
|
||||||
${this.cameraTitle
|
${this.cameraTitle
|
||||||
? html` <div>
|
? html` <div>
|
||||||
@@ -266,11 +269,9 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
|||||||
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
|
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
|
||||||
|
|
||||||
const rawEndTime = this.media.getEndTime();
|
const rawEndTime = this.media.getEndTime();
|
||||||
const endTime = rawStartTime
|
const duration =
|
||||||
? rawEndTime
|
rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
|
||||||
? getDurationString(rawStartTime, rawEndTime)
|
const inProgress = this.media.inProgress() ? localize('recording.in_progress') : null;
|
||||||
: localize('event.in_progress')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null;
|
const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null;
|
||||||
|
|
||||||
@@ -284,17 +285,24 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
|
|||||||
<div class="details">
|
<div class="details">
|
||||||
${startTime
|
${startTime
|
||||||
? html` <div>
|
? html` <div>
|
||||||
<ha-icon title=${localize(
|
<ha-icon
|
||||||
'recording.start',
|
title=${localize('recording.start')}
|
||||||
)} .icon=${'mdi:calendar-clock-outline'}></ha-icon>
|
.icon=${'mdi:calendar-clock-outline'}
|
||||||
|
></ha-icon>
|
||||||
<span title="${startTime}">${startTime}</span>
|
<span title="${startTime}">${startTime}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
${duration || inProgress
|
||||||
<ha-icon title=${localize(
|
? html` <div>
|
||||||
'recording.duration',
|
<ha-icon
|
||||||
)} .icon=${'mdi:clock-outline'}></ha-icon>
|
title=${localize('recording.duration')}
|
||||||
<span title="${endTime}">${endTime}</span>
|
.icon=${'mdi:clock-outline'}
|
||||||
|
></ha-icon>
|
||||||
|
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
|
||||||
|
${inProgress
|
||||||
|
? html`<span title="${inProgress}">${inProgress}</span>`
|
||||||
|
: ''}
|
||||||
</div>`
|
</div>`
|
||||||
|
: ''}`
|
||||||
: ''}
|
: ''}
|
||||||
${seek
|
${seek
|
||||||
? html` <div>
|
? html` <div>
|
||||||
|
|||||||
@@ -525,10 +525,13 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
.selectResultIfFound((media) => media.getID() === properties.item);
|
.selectResultIfFound((media) => media.getID() === properties.item);
|
||||||
|
|
||||||
if (!newResults || !newResults.hasSelectedResult()) {
|
if (!newResults || !newResults.hasSelectedResult()) {
|
||||||
// This can happen if this is a recording query (with recorded hours)
|
// This can happen in a few situations:
|
||||||
// and an event is clicked on the timeline, or if the current thumbnails
|
// - If this is a recording query (with recorded hours) and an event is
|
||||||
// is a filtered view from the media gallery (i.e. any case where the
|
// clicked on the timeline
|
||||||
// thumbnails may not be match the events on the timeline).
|
// - If the current thumbnails/results is a filtered view from the media
|
||||||
|
// gallery (i.e. any case where the thumbnails may not be match the
|
||||||
|
// events on the timeline, e.g. in the snapshots viewer but
|
||||||
|
// mini-timeline showing all media).
|
||||||
const fullEventView = await this._createViewWithEventMediaQuery(
|
const fullEventView = await this._createViewWithEventMediaQuery(
|
||||||
this._createEventMediaQuerys(),
|
this._createEventMediaQuerys(),
|
||||||
{
|
{
|
||||||
@@ -551,10 +554,7 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (view) {
|
if (view) {
|
||||||
view
|
view.dispatchChangeEvent(this);
|
||||||
// If the user is clicking something in the timeline, don't
|
|
||||||
// subsequently shift the window (it's pretty jarring).
|
|
||||||
.dispatchChangeEvent(this);
|
|
||||||
|
|
||||||
if (this.view?.is('timeline')) {
|
if (this.view?.is('timeline')) {
|
||||||
dispatchFrigateCardEvent(this, 'thumbnails:open');
|
dispatchFrigateCardEvent(this, 'thumbnails:open');
|
||||||
@@ -895,8 +895,10 @@ export class FrigateCardTimelineCore extends LitElement {
|
|||||||
const mediaStartTime = media?.getStartTime();
|
const mediaStartTime = media?.getStartTime();
|
||||||
const mediaEndTime = media?.getEndTime();
|
const mediaEndTime = media?.getEndTime();
|
||||||
const mediaWindow: TimelineWindow | null =
|
const mediaWindow: TimelineWindow | null =
|
||||||
media && mediaStartTime && mediaEndTime
|
media && mediaStartTime
|
||||||
? { start: mediaStartTime, end: mediaEndTime }
|
// If this media has no end time, it's just a "point" in time so the
|
||||||
|
// range effectively starts/ends at the same time.
|
||||||
|
? { start: mediaStartTime, end: mediaEndTime ?? mediaStartTime }
|
||||||
: null;
|
: null;
|
||||||
const context = this.view.context?.timeline;
|
const context = this.view.context?.timeline;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
|
||||||
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, Ref } from 'lit/directives/ref.js';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
import { TitleControlConfig } from '../types.js';
|
import { TitleControlConfig } from '../types.js';
|
||||||
|
|
||||||
import titleStyle from '../scss/title-control.scss';
|
import titleStyle from '../scss/title-control.scss';
|
||||||
@@ -21,6 +20,9 @@ export class FrigateCardTitleControl extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public fitInto?: HTMLElement;
|
public fitInto?: HTMLElement;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public logo?: string;
|
||||||
|
|
||||||
protected _toastRef: Ref<PaperToast> = createRef();
|
protected _toastRef: Ref<PaperToast> = createRef();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,6 +46,7 @@ export class FrigateCardTitleControl extends LitElement {
|
|||||||
.text="${this.text}"
|
.text="${this.text}"
|
||||||
.fitInto=${this.fitInto}
|
.fitInto=${this.fitInto}
|
||||||
>
|
>
|
||||||
|
${this.logo ? html`<img src=${this.logo} />` : ''}
|
||||||
</paper-toast>`;
|
</paper-toast>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +89,6 @@ export class FrigateCardTitleControl extends LitElement {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HTMLElementTagNameMap {
|
interface HTMLElementTagNameMap {
|
||||||
"frigate-card-title-control": FrigateCardTitleControl
|
'frigate-card-title-control': FrigateCardTitleControl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+77
-14
@@ -45,7 +45,7 @@ import {
|
|||||||
changeViewToRecentEventsForCameraAndDependents,
|
changeViewToRecentEventsForCameraAndDependents,
|
||||||
changeViewToRecentRecordingForCameraAndDependents,
|
changeViewToRecentRecordingForCameraAndDependents,
|
||||||
} from '../utils/media-to-view.js';
|
} from '../utils/media-to-view.js';
|
||||||
import { ViewMedia } from '../view/media.js';
|
import { VideoContentType, ViewMedia } from '../view/media.js';
|
||||||
import { ViewMediaClassifier } from '../view/media-classifier';
|
import { ViewMediaClassifier } from '../view/media-classifier';
|
||||||
import { guard } from 'lit/directives/guard.js';
|
import { guard } from 'lit/directives/guard.js';
|
||||||
import { localize } from '../localize/localize.js';
|
import { localize } from '../localize/localize.js';
|
||||||
@@ -53,6 +53,10 @@ import { MediaQueriesResults } from '../view/media-queries-results.js';
|
|||||||
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
||||||
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
|
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
|
||||||
import { playMediaMutingIfNecessary } from '../utils/media.js';
|
import { playMediaMutingIfNecessary } from '../utils/media.js';
|
||||||
|
import {
|
||||||
|
hideMediaControlsTemporarily,
|
||||||
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
|
} from '../utils/media.js';
|
||||||
|
|
||||||
export interface MediaViewerViewContext {
|
export interface MediaViewerViewContext {
|
||||||
seek?: Date;
|
seek?: Date;
|
||||||
@@ -399,7 +403,13 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
const media =
|
const media =
|
||||||
this.view?.queryResults?.getSelectedResult() ??
|
this.view?.queryResults?.getSelectedResult() ??
|
||||||
this.view?.queryResults?.getResult(resultCount - 1);
|
this.view?.queryResults?.getResult(resultCount - 1);
|
||||||
if (!media || !this.view || !this.view.queryResults) {
|
if (
|
||||||
|
!this.hass ||
|
||||||
|
!this.cameraManager ||
|
||||||
|
!media ||
|
||||||
|
!this.view ||
|
||||||
|
!this.view.queryResults
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +426,11 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cameraMetadata = this.cameraManager.getCameraMetadata(
|
||||||
|
this.hass,
|
||||||
|
media.getCameraID(),
|
||||||
|
);
|
||||||
|
|
||||||
return html` <frigate-card-media-carousel
|
return html` <frigate-card-media-carousel
|
||||||
${ref(this._refMediaCarousel)}
|
${ref(this._refMediaCarousel)}
|
||||||
.carouselOptions=${guard([this.viewerConfig], () => ({
|
.carouselOptions=${guard([this.viewerConfig], () => ({
|
||||||
@@ -426,6 +441,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
|||||||
this._getPlugins.bind(this),
|
this._getPlugins.bind(this),
|
||||||
)}
|
)}
|
||||||
.label=${media.getTitle() ?? undefined}
|
.label=${media.getTitle() ?? undefined}
|
||||||
|
.logo=${cameraMetadata?.engineLogo}
|
||||||
.titlePopupConfig=${this.viewerConfig?.controls.title}
|
.titlePopupConfig=${this.viewerConfig?.controls.title}
|
||||||
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
|
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
|
||||||
transitionEffect=${this._getTransitionEffect()}
|
transitionEffect=${this._getTransitionEffect()}
|
||||||
@@ -544,30 +560,53 @@ export class FrigateCardViewerProvider
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public cardWideConfig?: CardWideConfig;
|
public cardWideConfig?: CardWideConfig;
|
||||||
|
|
||||||
protected _refVideoProvider: Ref<Element & FrigateCardMediaPlayer> = createRef();
|
protected _refFrigateCardMediaPlayer: Ref<Element & FrigateCardMediaPlayer> =
|
||||||
|
createRef();
|
||||||
|
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
public async play(): Promise<void> {
|
||||||
playMediaMutingIfNecessary(this._refVideoProvider.value);
|
playMediaMutingIfNecessary(
|
||||||
|
this,
|
||||||
|
this._refFrigateCardMediaPlayer.value ?? this._refVideoProvider.value,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public pause(): void {
|
public pause(): void {
|
||||||
this._refVideoProvider.value?.pause();
|
(this._refFrigateCardMediaPlayer.value || this._refVideoProvider.value)?.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
public mute(): void {
|
public mute(): void {
|
||||||
this._refVideoProvider.value?.mute();
|
if (this._refFrigateCardMediaPlayer.value) {
|
||||||
|
this._refFrigateCardMediaPlayer.value?.mute();
|
||||||
|
} else if (this._refVideoProvider.value) {
|
||||||
|
this._refVideoProvider.value.muted = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public unmute(): void {
|
public unmute(): void {
|
||||||
this._refVideoProvider.value?.unmute();
|
if (this._refFrigateCardMediaPlayer.value) {
|
||||||
|
this._refFrigateCardMediaPlayer.value?.mute();
|
||||||
|
} else if (this._refVideoProvider.value) {
|
||||||
|
this._refVideoProvider.value.muted = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public isMuted(): boolean {
|
public isMuted(): boolean {
|
||||||
return this._refVideoProvider.value?.isMuted() ?? true;
|
if (this._refFrigateCardMediaPlayer.value) {
|
||||||
|
return this._refFrigateCardMediaPlayer.value?.isMuted() ?? true;
|
||||||
|
} else if (this._refVideoProvider.value) {
|
||||||
|
return this._refVideoProvider.value.muted;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public seek(seconds: number): void {
|
public seek(seconds: number): void {
|
||||||
this._refVideoProvider.value?.seek(seconds);
|
if (this._refFrigateCardMediaPlayer.value) {
|
||||||
|
return this._refFrigateCardMediaPlayer.value.seek(seconds);
|
||||||
|
} else if (this._refVideoProvider.value) {
|
||||||
|
hideMediaControlsTemporarily(this._refVideoProvider.value);
|
||||||
|
this._refVideoProvider.value.currentTime = seconds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -582,10 +621,6 @@ export class FrigateCardViewerProvider
|
|||||||
// If this specific media item has no clip, then do nothing (even if all
|
// If this specific media item has no clip, then do nothing (even if all
|
||||||
// the other media items do).
|
// the other media items do).
|
||||||
!ViewMediaClassifier.isEvent(this.media) ||
|
!ViewMediaClassifier.isEvent(this.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.
|
|
||||||
this.media.hasClip() === false ||
|
|
||||||
!MediaQueriesClassifier.areEventQueries(this.view.query)
|
!MediaQueriesClassifier.areEventQueries(this.view.query)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -666,8 +701,9 @@ export class FrigateCardViewerProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ViewMediaClassifier.isVideo(this.media)
|
return ViewMediaClassifier.isVideo(this.media)
|
||||||
|
? this.media.getVideoContentType() === VideoContentType.HLS
|
||||||
? html`<frigate-card-ha-hls-player
|
? html`<frigate-card-ha-hls-player
|
||||||
${ref(this._refVideoProvider)}
|
${ref(this._refFrigateCardMediaPlayer)}
|
||||||
allow-exoplayer
|
allow-exoplayer
|
||||||
aria-label="${this.media.getTitle() ?? ''}"
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
?autoplay=${false}
|
?autoplay=${false}
|
||||||
@@ -679,6 +715,33 @@ export class FrigateCardViewerProvider
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
>
|
>
|
||||||
</frigate-card-ha-hls-player>`
|
</frigate-card-ha-hls-player>`
|
||||||
|
: html`
|
||||||
|
<video
|
||||||
|
${ref(this._refVideoProvider)}
|
||||||
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
|
title="${this.media.getTitle() ?? ''}"
|
||||||
|
muted
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
?autoplay=${false}
|
||||||
|
@loadedmetadata=${(ev: Event) => {
|
||||||
|
if (ev.target) {
|
||||||
|
hideMediaControlsTemporarily(
|
||||||
|
ev.target as HTMLVideoElement,
|
||||||
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@loadeddata=${(ev: Event) => {
|
||||||
|
dispatchMediaLoadedEvent(this, ev);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
src=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
||||||
|
type="video/mp4"
|
||||||
|
/>
|
||||||
|
</video>
|
||||||
|
`
|
||||||
: html`<img
|
: html`<img
|
||||||
aria-label="${this.media.getTitle() ?? ''}"
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
|
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
|
||||||
|
|||||||
+13
-2
@@ -11,17 +11,28 @@ export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID =
|
|||||||
export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS =
|
export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS =
|
||||||
`${CONF_CAMERAS}.#.frigate.labels` as const;
|
`${CONF_CAMERAS}.#.frigate.labels` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_FRIGATE_URL = `${CONF_CAMERAS}.#.frigate.url` as const;
|
export const CONF_CAMERAS_ARRAY_FRIGATE_URL = `${CONF_CAMERAS}.#.frigate.url` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES = `${CONF_CAMERAS}.#.frigate.zones` as const;
|
export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES =
|
||||||
|
`${CONF_CAMERAS}.#.frigate.zones` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const;
|
export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM =
|
export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM =
|
||||||
`${CONF_CAMERAS}.#.go2rtc.stream` as const;
|
`${CONF_CAMERAS}.#.go2rtc.stream` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_HIDE = `${CONF_CAMERAS}.#.hide` as const;
|
export const CONF_CAMERAS_ARRAY_HIDE = `${CONF_CAMERAS}.#.hide` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const;
|
export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS =
|
export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS =
|
||||||
`${CONF_CAMERAS}.#.image.refresh_seconds` as const;
|
`${CONF_CAMERAS}.#.image.refresh_seconds` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_IMAGE_URL = `${CONF_CAMERAS}.#.image.url` as const;
|
export const CONF_CAMERAS_ARRAY_IMAGE_URL = `${CONF_CAMERAS}.#.image.url` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN =
|
||||||
|
`${CONF_CAMERAS}.#.motioneye.images.directory_pattern` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN =
|
||||||
|
`${CONF_CAMERAS}.#.motioneye.images.file_pattern` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN =
|
||||||
|
`${CONF_CAMERAS}.#.motioneye.movies.directory_pattern` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN =
|
||||||
|
`${CONF_CAMERAS}.#.motioneye.movies.file_pattern` as const;
|
||||||
|
export const CONF_CAMERAS_ARRAY_MOTIONEYE_URL =
|
||||||
|
`${CONF_CAMERAS}.#.motioneye.url` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const;
|
export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const;
|
|
||||||
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY =
|
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY =
|
||||||
`${CONF_CAMERAS}.#.webrtc_card.entity` as const;
|
`${CONF_CAMERAS}.#.webrtc_card.entity` as const;
|
||||||
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL =
|
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL =
|
||||||
|
|||||||
Vendored
+1
@@ -1,4 +1,5 @@
|
|||||||
declare module '*.scss';
|
declare module '*.scss';
|
||||||
|
declare module '*.svg';
|
||||||
declare module '*.jpg';
|
declare module '*.jpg';
|
||||||
declare module 'view' {
|
declare module 'view' {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
|||||||
+49
-1
@@ -28,6 +28,11 @@ import {
|
|||||||
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
|
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
|
||||||
CONF_CAMERAS_ARRAY_IMAGE_URL,
|
CONF_CAMERAS_ARRAY_IMAGE_URL,
|
||||||
CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
|
CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_URL,
|
||||||
CONF_CAMERAS_ARRAY_TITLE,
|
CONF_CAMERAS_ARRAY_TITLE,
|
||||||
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
||||||
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
|
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
|
||||||
@@ -159,6 +164,10 @@ import {
|
|||||||
sideLoadHomeAssistantElements,
|
sideLoadHomeAssistantElements,
|
||||||
} from './utils/ha';
|
} from './utils/ha';
|
||||||
import { setLowPerformanceProfile } from './performance.js';
|
import { setLowPerformanceProfile } from './performance.js';
|
||||||
|
import {
|
||||||
|
MOTIONEYE_ICON_SVG_PATH,
|
||||||
|
MOTIONEYE_ICON_SVG_VIEWBOX,
|
||||||
|
} from './camera-manager/motioneye/icon.js';
|
||||||
|
|
||||||
const MENU_BUTTONS = 'buttons';
|
const MENU_BUTTONS = 'buttons';
|
||||||
const MENU_CAMERAS = 'cameras';
|
const MENU_CAMERAS = 'cameras';
|
||||||
@@ -166,6 +175,7 @@ const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
|
|||||||
const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
|
const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
|
||||||
const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
|
const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
|
||||||
const MENU_CAMERAS_IMAGE = 'cameras.image';
|
const MENU_CAMERAS_IMAGE = 'cameras.image';
|
||||||
|
const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
|
||||||
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
|
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
|
||||||
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
|
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
|
||||||
const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
|
const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
|
||||||
@@ -888,6 +898,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
icon: {
|
icon: {
|
||||||
name?: string;
|
name?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
viewBox?: string;
|
||||||
},
|
},
|
||||||
template: TemplateResult,
|
template: TemplateResult,
|
||||||
): TemplateResult {
|
): TemplateResult {
|
||||||
@@ -907,7 +918,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
${icon.name
|
${icon.name
|
||||||
? html` <ha-icon .icon=${icon.name}></ha-icon> `
|
? html` <ha-icon .icon=${icon.name}></ha-icon> `
|
||||||
: icon.path
|
: icon.path
|
||||||
? html` <ha-svg-icon .path="${icon.path}"></ha-svg-icon> `
|
? html`
|
||||||
|
<ha-svg-icon .viewBox=${icon.viewBox} .path="${icon.path}"></ha-svg-icon>
|
||||||
|
`
|
||||||
: ``}
|
: ``}
|
||||||
<span>${localize(labelPath)}</span>
|
<span>${localize(labelPath)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1411,6 +1424,41 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
`,
|
`,
|
||||||
|
)}
|
||||||
|
${this._putInSubmenu(
|
||||||
|
MENU_CAMERAS_MOTIONEYE,
|
||||||
|
cameraIndex,
|
||||||
|
'config.cameras.motioneye.editor_label',
|
||||||
|
{ path: MOTIONEYE_ICON_SVG_PATH, viewBox: MOTIONEYE_ICON_SVG_VIEWBOX },
|
||||||
|
html`
|
||||||
|
${this._renderStringInput(
|
||||||
|
getArrayConfigPath(CONF_CAMERAS_ARRAY_MOTIONEYE_URL, cameraIndex),
|
||||||
|
)}
|
||||||
|
${this._renderStringInput(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
${this._renderStringInput(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
${this._renderStringInput(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
${this._renderStringInput(
|
||||||
|
getArrayConfigPath(
|
||||||
|
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
|
||||||
|
cameraIndex,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
`,
|
||||||
)} `,
|
)} `,
|
||||||
)}
|
)}
|
||||||
${this._putInSubmenu(
|
${this._putInSubmenu(
|
||||||
|
|||||||
@@ -57,6 +57,18 @@
|
|||||||
"image": "Home Assistant images",
|
"image": "Home Assistant images",
|
||||||
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
|
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
|
||||||
},
|
},
|
||||||
|
"motioneye": {
|
||||||
|
"editor_label": "MotionEye Options",
|
||||||
|
"images": {
|
||||||
|
"directory_pattern": "Images directory pattern",
|
||||||
|
"file_pattern": "Images file pattern"
|
||||||
|
},
|
||||||
|
"movies": {
|
||||||
|
"directory_pattern": "Movies directory pattern",
|
||||||
|
"file_pattern": "Movies file pattern"
|
||||||
|
},
|
||||||
|
"url": "MotionEye UI URL"
|
||||||
|
},
|
||||||
"title": "Title for this camera (Autodetected from entity)",
|
"title": "Title for this camera (Autodetected from entity)",
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"entities": "Trigger from other entities",
|
"entities": "Trigger from other entities",
|
||||||
@@ -436,6 +448,7 @@
|
|||||||
"recording": {
|
"recording": {
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
|
"in_progress": "In Progress",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"seek": "Seek",
|
"seek": "Seek",
|
||||||
"start": "Start"
|
"start": "Start"
|
||||||
|
|||||||
@@ -57,6 +57,18 @@
|
|||||||
"image": "",
|
"image": "",
|
||||||
"webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)"
|
"webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)"
|
||||||
},
|
},
|
||||||
|
"motioneye": {
|
||||||
|
"editor_label": "",
|
||||||
|
"images": {
|
||||||
|
"directory_pattern": "",
|
||||||
|
"file_pattern": ""
|
||||||
|
},
|
||||||
|
"movies": {
|
||||||
|
"directory_pattern": "",
|
||||||
|
"file_pattern": ""
|
||||||
|
},
|
||||||
|
"url": ""
|
||||||
|
},
|
||||||
"title": "Titolo per questa telecamera (Autoidentificato dall'entità)",
|
"title": "Titolo per questa telecamera (Autoidentificato dall'entità)",
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"entities": "Trigger da altre entità",
|
"entities": "Trigger da altre entità",
|
||||||
@@ -427,6 +439,7 @@
|
|||||||
"camera": "",
|
"camera": "",
|
||||||
"duration": "",
|
"duration": "",
|
||||||
"events": "Eventi",
|
"events": "Eventi",
|
||||||
|
"in_progress": "In corso",
|
||||||
"seek": "Cercare",
|
"seek": "Cercare",
|
||||||
"start": ""
|
"start": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,6 +57,18 @@
|
|||||||
"image": "",
|
"image": "",
|
||||||
"webrtc-card": "Cartão WebRTC (de @AlexxIT)"
|
"webrtc-card": "Cartão WebRTC (de @AlexxIT)"
|
||||||
},
|
},
|
||||||
|
"motioneye": {
|
||||||
|
"editor_label": "",
|
||||||
|
"images": {
|
||||||
|
"directory_pattern": "",
|
||||||
|
"file_pattern": ""
|
||||||
|
},
|
||||||
|
"movies": {
|
||||||
|
"directory_pattern": "",
|
||||||
|
"file_pattern": ""
|
||||||
|
},
|
||||||
|
"url": ""
|
||||||
|
},
|
||||||
"title": "Título para esta câmera (detectado automaticamente pela entidade)",
|
"title": "Título para esta câmera (detectado automaticamente pela entidade)",
|
||||||
"triggers": {
|
"triggers": {
|
||||||
"entities": "Acionar a partir de outras entidades",
|
"entities": "Acionar a partir de outras entidades",
|
||||||
@@ -427,6 +439,7 @@
|
|||||||
"camera": "",
|
"camera": "",
|
||||||
"duration": "",
|
"duration": "",
|
||||||
"events": "Eventos",
|
"events": "Eventos",
|
||||||
|
"in_progress": "Em andamento",
|
||||||
"seek": "Procurar",
|
"seek": "Procurar",
|
||||||
"start": ""
|
"start": ""
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,14 +28,6 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
const computeMJPEGStreamUrl = (entity: CameraEntity): string =>
|
const computeMJPEGStreamUrl = (entity: CameraEntity): string =>
|
||||||
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`;
|
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`;
|
||||||
|
|
||||||
const computeObjectId = (entityId: string): string =>
|
|
||||||
entityId.substr(entityId.indexOf('.') + 1);
|
|
||||||
|
|
||||||
const computeStateName = (stateObj: HassEntity): string =>
|
|
||||||
stateObj.attributes.friendly_name === undefined
|
|
||||||
? computeObjectId(stateObj.entity_id).replace(/_/g, ' ')
|
|
||||||
: stateObj.attributes.friendly_name || '';
|
|
||||||
|
|
||||||
const STREAM_TYPE_HLS = 'hls';
|
const STREAM_TYPE_HLS = 'hls';
|
||||||
const STREAM_TYPE_WEB_RTC = 'web_rtc';
|
const STREAM_TYPE_WEB_RTC = 'web_rtc';
|
||||||
|
|
||||||
@@ -100,7 +92,6 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
.src=${typeof this._connected == 'undefined' || this._connected
|
.src=${typeof this._connected == 'undefined' || this._connected
|
||||||
? computeMJPEGStreamUrl(this.stateObj)
|
? computeMJPEGStreamUrl(this.stateObj)
|
||||||
: ''}
|
: ''}
|
||||||
.alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`}
|
|
||||||
/>
|
/>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public isMuted(): boolean {
|
public isMuted(): boolean {
|
||||||
return this._video?.muted() ?? true;
|
return this._video?.muted ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public seek(seconds: number): void {
|
public seek(seconds: number): void {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
:host {
|
:host {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: 100%;
|
||||||
|
display: block;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|
||||||
// Hide scrollbar: IE and Edge
|
// Hide scrollbar: IE and Edge
|
||||||
@@ -11,7 +12,9 @@
|
|||||||
|
|
||||||
--frigate-card-gallery-gap: 3px;
|
--frigate-card-gallery-gap: 3px;
|
||||||
--frigate-card-gallery-columns: 4;
|
--frigate-card-gallery-columns: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr));
|
grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr));
|
||||||
grid-auto-rows: min-content;
|
grid-auto-rows: min-content;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
@use 'dotdotdot.scss';
|
@use 'dotdotdot.scss';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
min-height: 100%;
|
display: block;
|
||||||
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
|
|||||||
@@ -6,4 +6,11 @@
|
|||||||
paper-toast {
|
paper-toast {
|
||||||
max-width: unset;
|
max-width: unset;
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
paper-toast img {
|
||||||
|
max-height: 24px;
|
||||||
|
padding-left: 10px;
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
img,
|
img,
|
||||||
|
video,
|
||||||
frigate-card-ha-hls-player {
|
frigate-card-ha-hls-player {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
+51
-19
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
CallServiceActionConfig,
|
CallServiceActionConfig,
|
||||||
ConfirmationRestrictionConfig,
|
ConfirmationRestrictionConfig,
|
||||||
CustomActionConfig,
|
|
||||||
HomeAssistant,
|
HomeAssistant,
|
||||||
LovelaceCardConfig,
|
LovelaceCardConfig,
|
||||||
MoreInfoActionConfig,
|
MoreInfoActionConfig,
|
||||||
@@ -95,7 +94,7 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
|
|||||||
export type AutoUnmuteCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
|
export type AutoUnmuteCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
|
||||||
export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
|
export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
|
||||||
|
|
||||||
const ENGINES = ['auto', 'frigate', 'generic'] as const;
|
const ENGINES = ['auto', 'frigate', 'generic', 'motioneye'] as const;
|
||||||
|
|
||||||
export class FrigateCardError extends Error {
|
export class FrigateCardError extends Error {
|
||||||
context?: unknown;
|
context?: unknown;
|
||||||
@@ -182,15 +181,13 @@ const moreInfoActionSchema = schemaForType<
|
|||||||
action: z.literal('more-info'),
|
action: z.literal('more-info'),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const customActionSchema = schemaForType<
|
|
||||||
CustomActionConfig & ExtendedConfirmationRestrictionConfig
|
const customActionSchema = actionBaseSchema
|
||||||
>()(
|
|
||||||
actionBaseSchema
|
|
||||||
.extend({
|
.extend({
|
||||||
action: z.literal('fire-dom-event'),
|
action: z.literal('fire-dom-event'),
|
||||||
})
|
})
|
||||||
.passthrough(),
|
.passthrough();
|
||||||
);
|
|
||||||
const noActionSchema = schemaForType<
|
const noActionSchema = schemaForType<
|
||||||
NoActionConfig & ExtendedConfirmationRestrictionConfig
|
NoActionConfig & ExtendedConfirmationRestrictionConfig
|
||||||
>()(
|
>()(
|
||||||
@@ -450,19 +447,29 @@ const jsmpegConfigSchema = z.object({
|
|||||||
* Camera configuration section
|
* Camera configuration section
|
||||||
*/
|
*/
|
||||||
const cameraConfigDefault = {
|
const cameraConfigDefault = {
|
||||||
live_provider: 'auto' as const,
|
|
||||||
engine: 'auto' as const,
|
|
||||||
frigate: {
|
|
||||||
client_id: 'frigate' as const,
|
|
||||||
},
|
|
||||||
dependencies: {
|
dependencies: {
|
||||||
all_cameras: false,
|
all_cameras: false,
|
||||||
cameras: [],
|
cameras: [],
|
||||||
},
|
},
|
||||||
|
engine: 'auto' as const,
|
||||||
|
frigate: {
|
||||||
|
client_id: 'frigate' as const,
|
||||||
|
},
|
||||||
|
hide: false,
|
||||||
image: {
|
image: {
|
||||||
refresh_seconds: 1,
|
refresh_seconds: 1,
|
||||||
},
|
},
|
||||||
hide: false,
|
live_provider: 'auto' as const,
|
||||||
|
motioneye: {
|
||||||
|
images: {
|
||||||
|
directory_pattern: '%Y-%m-%d' as const,
|
||||||
|
file_pattern: '%H-%M-%S' as const,
|
||||||
|
},
|
||||||
|
movies: {
|
||||||
|
directory_pattern: '%Y-%m-%d' as const,
|
||||||
|
file_pattern: '%H-%M-%S' as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
triggers: {
|
triggers: {
|
||||||
motion: false,
|
motion: false,
|
||||||
occupancy: true,
|
occupancy: true,
|
||||||
@@ -505,7 +512,6 @@ const cameraConfigSchema = z
|
|||||||
engine: z.enum(ENGINES).default('auto'),
|
engine: z.enum(ENGINES).default('auto'),
|
||||||
frigate: z
|
frigate: z
|
||||||
.object({
|
.object({
|
||||||
// No URL validation to allow relative URLs within HA (e.g. Frigate addon).
|
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
client_id: z.string().default(cameraConfigDefault.frigate.client_id),
|
client_id: z.string().default(cameraConfigDefault.frigate.client_id),
|
||||||
camera_name: z.string().optional(),
|
camera_name: z.string().optional(),
|
||||||
@@ -513,6 +519,35 @@ const cameraConfigSchema = z
|
|||||||
zones: z.string().array().optional(),
|
zones: z.string().array().optional(),
|
||||||
})
|
})
|
||||||
.default(cameraConfigDefault.frigate),
|
.default(cameraConfigDefault.frigate),
|
||||||
|
motioneye: z
|
||||||
|
.object({
|
||||||
|
url: z.string().optional(),
|
||||||
|
images: z
|
||||||
|
.object({
|
||||||
|
directory_pattern: z
|
||||||
|
.string()
|
||||||
|
.includes('%')
|
||||||
|
.default(cameraConfigDefault.motioneye.images.directory_pattern),
|
||||||
|
file_pattern: z
|
||||||
|
.string()
|
||||||
|
.includes('%')
|
||||||
|
.default(cameraConfigDefault.motioneye.images.file_pattern),
|
||||||
|
})
|
||||||
|
.default(cameraConfigDefault.motioneye.images),
|
||||||
|
movies: z
|
||||||
|
.object({
|
||||||
|
directory_pattern: z
|
||||||
|
.string()
|
||||||
|
.includes('%')
|
||||||
|
.default(cameraConfigDefault.motioneye.movies.directory_pattern),
|
||||||
|
file_pattern: z
|
||||||
|
.string()
|
||||||
|
.includes('%')
|
||||||
|
.default(cameraConfigDefault.motioneye.movies.file_pattern),
|
||||||
|
})
|
||||||
|
.default(cameraConfigDefault.motioneye.movies),
|
||||||
|
})
|
||||||
|
.default(cameraConfigDefault.motioneye),
|
||||||
|
|
||||||
// Live provider options.
|
// Live provider options.
|
||||||
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
|
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
|
||||||
@@ -1321,10 +1356,7 @@ export const frigateCardConfigSchema = z.object({
|
|||||||
|
|
||||||
// Card ID (used for query string commands). Restrict contents to only values
|
// Card ID (used for query string commands). Restrict contents to only values
|
||||||
// that be easily used in a URL.
|
// that be easily used in a URL.
|
||||||
card_id: z
|
card_id: z.string().regex(/^\w+$/).optional(),
|
||||||
.string()
|
|
||||||
.regex(/^\w+$/)
|
|
||||||
.optional(),
|
|
||||||
|
|
||||||
// Stock lovelace card config.
|
// Stock lovelace card config.
|
||||||
type: z.string(),
|
type: z.string(),
|
||||||
|
|||||||
@@ -196,3 +196,7 @@ export const isSuperset = (superset: Set<unknown>, subset: Set<unknown>) => {
|
|||||||
export const sleep = async (seconds: number) => {
|
export const sleep = async (seconds: number) => {
|
||||||
await new Promise((r) => setTimeout(r, seconds * 1000));
|
await new Promise((r) => setTimeout(r, seconds * 1000));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isValidDate = (date: Date): boolean => {
|
||||||
|
return !isNaN(date.getTime());
|
||||||
|
}
|
||||||
+15
-6
@@ -10,14 +10,16 @@ export const downloadMedia = async (
|
|||||||
cameraManager: CameraManager,
|
cameraManager: CameraManager,
|
||||||
media: ViewMedia,
|
media: ViewMedia,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const path = cameraManager.getMediaDownloadPath(media);
|
const download = await cameraManager.getMediaDownloadPath(hass, media);
|
||||||
if (!path) {
|
if (!download) {
|
||||||
throw new FrigateCardError(localize('error.download_no_media'));
|
throw new FrigateCardError(localize('error.download_no_media'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let finalURL = download.endpoint;
|
||||||
|
if (download.sign) {
|
||||||
let response: string | null | undefined;
|
let response: string | null | undefined;
|
||||||
try {
|
try {
|
||||||
response = await homeAssistantSignPath(hass, path);
|
response = await homeAssistantSignPath(hass, download.endpoint);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToConsole(e as Error);
|
errorToConsole(e as Error);
|
||||||
}
|
}
|
||||||
@@ -25,8 +27,15 @@ export const downloadMedia = async (
|
|||||||
if (!response) {
|
if (!response) {
|
||||||
throw new FrigateCardError(localize('error.download_sign_failed'));
|
throw new FrigateCardError(localize('error.download_sign_failed'));
|
||||||
}
|
}
|
||||||
|
finalURL = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The download attribute only works on the same origin.
|
||||||
|
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes
|
||||||
|
const isSameOrigin = new URL(finalURL).origin === window.location.origin;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
!isSameOrigin ||
|
||||||
navigator.userAgent.startsWith('Home Assistant/') ||
|
navigator.userAgent.startsWith('Home Assistant/') ||
|
||||||
navigator.userAgent.startsWith('HomeAssistant/')
|
navigator.userAgent.startsWith('HomeAssistant/')
|
||||||
) {
|
) {
|
||||||
@@ -36,13 +45,13 @@ export const downloadMedia = async (
|
|||||||
// User-agents are specified here:
|
// User-agents are specified here:
|
||||||
// - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107
|
// - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107
|
||||||
// - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75
|
// - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75
|
||||||
window.open(response, '_blank');
|
window.open(finalURL, '_blank');
|
||||||
} else {
|
} else {
|
||||||
// Use the HTML5 download attribute to prevent a new window from
|
// Use the HTML5 download attribute to prevent a new window from
|
||||||
// temporarily opening.
|
// temporarily opening.
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.setAttribute('download', '');
|
link.setAttribute('download', 'download');
|
||||||
link.href = response;
|
link.href = finalURL;
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export const getEndpointAddressOrDispatchError = async (
|
|||||||
endpoint: CameraEndpoint,
|
endpoint: CameraEndpoint,
|
||||||
expires?: number,
|
expires?: number,
|
||||||
): Promise<string | null> => {
|
): Promise<string | null> => {
|
||||||
let address: string | null;
|
|
||||||
if (!endpoint.sign) {
|
if (!endpoint.sign) {
|
||||||
address = endpoint.endpoint;
|
return endpoint.endpoint;
|
||||||
} else {
|
}
|
||||||
|
|
||||||
let response: string | null | undefined;
|
let response: string | null | undefined;
|
||||||
try {
|
try {
|
||||||
response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
|
response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
|
||||||
@@ -22,12 +22,11 @@ export const getEndpointAddressOrDispatchError = async (
|
|||||||
errorToConsole(e as Error);
|
errorToConsole(e as Error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
address = response ? response.replace(/^http/i, 'ws') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!address) {
|
if (!response) {
|
||||||
dispatchErrorMessageEvent(element, localize('error.failed_sign'));
|
dispatchErrorMessageEvent(element, localize('error.failed_sign'));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return address;
|
|
||||||
|
return response.replace(/^http/i, 'ws');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
import add from 'date-fns/add';
|
||||||
|
import { homeAssistantWSRequest } from '..';
|
||||||
|
import { MemoryRequestCache } from '../../../camera-manager/cache';
|
||||||
|
import { allPromises } from '../../basic';
|
||||||
|
import {
|
||||||
|
BrowseMedia,
|
||||||
|
browseMediaSchema,
|
||||||
|
BROWSE_MEDIA_CACHE_SECONDS,
|
||||||
|
RichBrowseMedia,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
type BrowseMediaCache<M> = MemoryRequestCache<string, RichBrowseMedia<M>>;
|
||||||
|
type RichMetadataGenerator<M> = (
|
||||||
|
media: BrowseMedia,
|
||||||
|
parent?: RichBrowseMedia<M>,
|
||||||
|
) => M | null;
|
||||||
|
|
||||||
|
export type BrowseMediaTarget<M> = string | RichBrowseMedia<M>;
|
||||||
|
type RichBrowseMediaPredicate<M> = (media: RichBrowseMedia<M>) => boolean;
|
||||||
|
|
||||||
|
export interface BrowseMediaStep<M> {
|
||||||
|
// The targets to start the media walk from.
|
||||||
|
targets: BrowseMediaTarget<M>[];
|
||||||
|
|
||||||
|
// All children of the target have the metadata generator applied to them
|
||||||
|
// first.
|
||||||
|
metadataGenerator?: RichMetadataGenerator<M>;
|
||||||
|
|
||||||
|
// If those children pass this matcher, then they will be included in the
|
||||||
|
// output.
|
||||||
|
matcher: RichBrowseMediaPredicate<M>;
|
||||||
|
|
||||||
|
// advance will be called to generate a next step (or null if the child should
|
||||||
|
// just be included straight through to the output with no further steps).
|
||||||
|
advance?: BrowseMediaStepAdvancer<M>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowseMediaStepAdvancer<M> = (media: RichBrowseMedia<M>[]) => BrowseMediaStep<M>[];
|
||||||
|
|
||||||
|
export class BrowseMediaManager<M> {
|
||||||
|
protected _cache: BrowseMediaCache<M>;
|
||||||
|
|
||||||
|
constructor(cache: BrowseMediaCache<M>) {
|
||||||
|
this._cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk down a browse media tree according to instructions included in `steps`.
|
||||||
|
public async walkBrowseMedias(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
steps: BrowseMediaStep<M>[] | null,
|
||||||
|
options?: {
|
||||||
|
useCache?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<RichBrowseMedia<M>[]> {
|
||||||
|
if (!steps || !steps.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
await allPromises(
|
||||||
|
steps,
|
||||||
|
async (step) => await this._walkBrowseMedia(hass, step, options),
|
||||||
|
)
|
||||||
|
).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _walkBrowseMedia(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
step: BrowseMediaStep<M>,
|
||||||
|
options?: {
|
||||||
|
useCache?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<RichBrowseMedia<M>[]> {
|
||||||
|
const media = await allPromises(
|
||||||
|
step.targets,
|
||||||
|
async (target) =>
|
||||||
|
await this._browseMedia(hass, target, {
|
||||||
|
useCache: options?.useCache,
|
||||||
|
metadataGenerator: step.metadataGenerator,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const newTargets: RichBrowseMedia<M>[] = [];
|
||||||
|
for (const parent of media) {
|
||||||
|
for (const child of parent.children ?? []) {
|
||||||
|
if (step.matcher(child)) {
|
||||||
|
newTargets.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSteps = step.advance ? step.advance(newTargets) : null;
|
||||||
|
if (!nextSteps || !nextSteps.length) {
|
||||||
|
return newTargets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetsIncludedInNextSteps = new Set(
|
||||||
|
nextSteps.map((nextStep) => nextStep.targets).flat(),
|
||||||
|
);
|
||||||
|
const finished: RichBrowseMedia<M>[] = [];
|
||||||
|
|
||||||
|
// Any new target that doesn't have a proposed 'next step' is assumed to be
|
||||||
|
// ready to return.
|
||||||
|
for (const target of newTargets) {
|
||||||
|
if (!targetsIncludedInNextSteps.has(target)) {
|
||||||
|
finished.push(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const downstream = await this.walkBrowseMedias(hass, nextSteps, options);
|
||||||
|
return finished.concat(downstream);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _browseMedia(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
target: string | RichBrowseMedia<M>,
|
||||||
|
options?: {
|
||||||
|
useCache?: boolean;
|
||||||
|
metadataGenerator?: RichMetadataGenerator<M>;
|
||||||
|
},
|
||||||
|
): Promise<RichBrowseMedia<M>> {
|
||||||
|
const mediaContentID = typeof target === 'object' ? target.media_content_id : target;
|
||||||
|
const cachedResult =
|
||||||
|
options?.useCache ?? true ? this._cache.get(mediaContentID) : null;
|
||||||
|
if (cachedResult) {
|
||||||
|
return cachedResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = {
|
||||||
|
type: 'media_source/browse_media',
|
||||||
|
media_content_id: mediaContentID,
|
||||||
|
};
|
||||||
|
const browseMedia = (await homeAssistantWSRequest(
|
||||||
|
hass,
|
||||||
|
browseMediaSchema,
|
||||||
|
request,
|
||||||
|
)) as RichBrowseMedia<M>;
|
||||||
|
|
||||||
|
if (options?.metadataGenerator) {
|
||||||
|
for (const child of browseMedia.children ?? []) {
|
||||||
|
child._metadata =
|
||||||
|
options.metadataGenerator(
|
||||||
|
child,
|
||||||
|
typeof target === 'object' ? target : undefined,
|
||||||
|
) ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.useCache ?? true) {
|
||||||
|
this._cache.set(
|
||||||
|
mediaContentID,
|
||||||
|
browseMedia,
|
||||||
|
add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return browseMedia;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// 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#L90
|
||||||
|
export interface BrowseMedia {
|
||||||
|
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?: BrowseMedia[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const browseMediaSchema: z.ZodSchema<BrowseMedia> = 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(browseMediaSchema).nullable().optional(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface RichBrowseMedia<M> extends BrowseMedia {
|
||||||
|
_metadata?: M;
|
||||||
|
children?: RichBrowseMedia<M>[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MEDIA_CLASS_VIDEO = 'video' as const;
|
||||||
|
export const MEDIA_CLASS_IMAGE = 'image' as const;
|
||||||
|
|
||||||
|
export const BROWSE_MEDIA_CACHE_SECONDS = 60 as const;
|
||||||
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const entitySchema = z.object({
|
export const entitySchema = z.object({
|
||||||
config_entry_id: z.string().nullable(),
|
config_entry_id: z.string().nullable(),
|
||||||
|
device_id: z.string().nullable(),
|
||||||
disabled_by: z.string().nullable(),
|
disabled_by: z.string().nullable(),
|
||||||
entity_id: z.string(),
|
entity_id: z.string(),
|
||||||
hidden_by: z.string().nullable(),
|
hidden_by: z.string().nullable(),
|
||||||
|
|||||||
@@ -354,12 +354,13 @@ export const isCardInPanel = (card: HTMLElement): boolean => {
|
|||||||
* location will be the Chromecast receiver, not HA).
|
* location will be the Chromecast receiver, not HA).
|
||||||
* @param url The media URL
|
* @param url The media URL
|
||||||
*/
|
*/
|
||||||
export const canonicalizeHAURL = (
|
export function canonicalizeHAURL(hass: ExtendedHomeAssistant, url: string): string;
|
||||||
|
export function canonicalizeHAURL(
|
||||||
hass: ExtendedHomeAssistant,
|
hass: ExtendedHomeAssistant,
|
||||||
url?: string,
|
url?: string,
|
||||||
): string | null => {
|
): string | null {
|
||||||
if (hass && url && url.startsWith('/')) {
|
if (hass && url && url.startsWith('/')) {
|
||||||
return hass.hassUrl(url);
|
return hass.hassUrl(url);
|
||||||
}
|
}
|
||||||
return url ?? null;
|
return url ?? null;
|
||||||
};
|
}
|
||||||
|
|||||||
+8
-6
@@ -31,20 +31,22 @@ export const hideMediaControlsTemporarily = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Play a piece of media, muting it if necessary.
|
*
|
||||||
* @param underlyingPlayer
|
* @param player The Frigate Card Media Player object.
|
||||||
|
* @param video An underlying video or media player upon which to call play.
|
||||||
*/
|
*/
|
||||||
export const playMediaMutingIfNecessary = async (
|
export const playMediaMutingIfNecessary = async (
|
||||||
player?: FrigateCardMediaPlayer,
|
player: FrigateCardMediaPlayer,
|
||||||
|
video?: HTMLVideoElement | FrigateCardMediaPlayer,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// If the play call fails, and the media is not already muted, mute it first
|
// If the play call fails, and the media is not already muted, mute it first
|
||||||
// and then try again. This works around some browsers that prevent
|
// and then try again. This works around some browsers that prevent
|
||||||
// auto-play unless the video is muted.
|
// auto-play unless the video is muted.
|
||||||
if (player?.play) {
|
if (video?.play) {
|
||||||
player.play().catch((ev) => {
|
video.play().catch((ev) => {
|
||||||
if (ev.name === 'NotAllowedError' && !player.isMuted()) {
|
if (ev.name === 'NotAllowedError' && !player.isMuted()) {
|
||||||
player.mute();
|
player.mute();
|
||||||
player.play().catch();
|
video.play().catch();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import { Task } from '@lit-labs/task';
|
|||||||
import { ReactiveControllerHost } from '@lit/reactive-element';
|
import { ReactiveControllerHost } from '@lit/reactive-element';
|
||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
|
|
||||||
|
// See: https://github.com/sindresorhus/is-absolute-url
|
||||||
|
// Scheme: https://tools.ietf.org/html/rfc3986#section-3.1
|
||||||
|
// Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3
|
||||||
|
const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a thumbnail URL and return a data URL.
|
* Fetch a thumbnail URL and return a data URL.
|
||||||
* @param hass Home Assistant object.
|
* @param hass Home Assistant object.
|
||||||
@@ -12,10 +17,10 @@ const fetchThumbnail = async (
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
thumbnailURL: string,
|
thumbnailURL: string,
|
||||||
): Promise<string | null> => {
|
): Promise<string | null> => {
|
||||||
if (!hass) {
|
if (!hass || !thumbnailURL) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (thumbnailURL?.startsWith('data:')) {
|
if (thumbnailURL.startsWith('data:') || thumbnailURL.match(ABSOLUTE_URL_REGEX)) {
|
||||||
return thumbnailURL;
|
return thumbnailURL;
|
||||||
}
|
}
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -57,9 +62,7 @@ export const createFetchThumbnailTask = (
|
|||||||
getThumbnailURL: () => string | undefined,
|
getThumbnailURL: () => string | undefined,
|
||||||
autoRun = true,
|
autoRun = true,
|
||||||
): Task<FetchThumbnailTaskArgs, string | null> => {
|
): Task<FetchThumbnailTaskArgs, string | null> => {
|
||||||
return new Task(
|
return new Task(host, {
|
||||||
host,
|
|
||||||
{
|
|
||||||
// Do not re-run the task if hass changes, unless it was previously undefined.
|
// Do not re-run the task if hass changes, unless it was previously undefined.
|
||||||
args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
|
args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
|
||||||
task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
|
task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
|
||||||
@@ -72,6 +75,5 @@ export const createFetchThumbnailTask = (
|
|||||||
return fetchThumbnail(hass, thumbnailURL);
|
return fetchThumbnail(hass, thumbnailURL);
|
||||||
},
|
},
|
||||||
autoRun: autoRun,
|
autoRun: autoRun,
|
||||||
},
|
});
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,10 @@
|
|||||||
export type ViewMediaType = 'clip' | 'snapshot' | 'recording';
|
export type ViewMediaType = 'clip' | 'snapshot' | 'recording';
|
||||||
|
|
||||||
|
export enum VideoContentType {
|
||||||
|
MP4 = "mp4",
|
||||||
|
HLS = "hls",
|
||||||
|
}
|
||||||
|
|
||||||
export class ViewMedia {
|
export class ViewMedia {
|
||||||
protected _mediaType: ViewMediaType;
|
protected _mediaType: ViewMediaType;
|
||||||
protected _cameraID: string;
|
protected _cameraID: string;
|
||||||
@@ -17,6 +22,9 @@ export class ViewMedia {
|
|||||||
public getMediaType(): ViewMediaType {
|
public getMediaType(): ViewMediaType {
|
||||||
return this._mediaType;
|
return this._mediaType;
|
||||||
}
|
}
|
||||||
|
public getVideoContentType(): VideoContentType | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
public getID(): string | null {
|
public getID(): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -26,6 +34,9 @@ export class ViewMedia {
|
|||||||
public getEndTime(): Date | null {
|
public getEndTime(): Date | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
public inProgress(): boolean | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
public getContentID(): string | null {
|
public getContentID(): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -60,7 +71,6 @@ export interface EventViewMedia extends ViewMedia {
|
|||||||
getWhat(): string[] | null;
|
getWhat(): string[] | null;
|
||||||
getTags(): string[] | null;
|
getTags(): string[] | null;
|
||||||
isGroupableWith(that: EventViewMedia): boolean;
|
isGroupableWith(that: EventViewMedia): boolean;
|
||||||
hasClip(): boolean | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordingViewMedia extends ViewMedia {
|
export interface RecordingViewMedia extends ViewMedia {
|
||||||
|
|||||||
@@ -689,15 +689,18 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@rollup/plugin-image@npm:^2.1.1":
|
"@rollup/plugin-image@npm:^3.0.2":
|
||||||
version: 2.1.1
|
version: 3.0.2
|
||||||
resolution: "@rollup/plugin-image@npm:2.1.1"
|
resolution: "@rollup/plugin-image@npm:3.0.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@rollup/pluginutils": ^3.1.0
|
"@rollup/pluginutils": ^5.0.1
|
||||||
mini-svg-data-uri: ^1.2.3
|
mini-svg-data-uri: ^1.4.4
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
rollup: ^1.20.0 || ^2.0.0
|
rollup: ^1.20.0||^2.0.0||^3.0.0
|
||||||
checksum: a629c8f22233ca159c23655fdbc3449dab3c939372178ed4462fc9c525cc4ecd8b11fae359eb94be4f769d26f48b85fb18eb16ce1fbc33ed16b6a7c1f84391f6
|
peerDependenciesMeta:
|
||||||
|
rollup:
|
||||||
|
optional: true
|
||||||
|
checksum: f9d8f587f10c51398fa8c23f1543e3073f969cf7e4acd7f401e02a3e3752702a9eb289ddb14009733ce37d04474c549aba9e7d13ebf50e26226266ba51546b69
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -763,6 +766,22 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@rollup/pluginutils@npm:^5.0.1":
|
||||||
|
version: 5.0.2
|
||||||
|
resolution: "@rollup/pluginutils@npm:5.0.2"
|
||||||
|
dependencies:
|
||||||
|
"@types/estree": ^1.0.0
|
||||||
|
estree-walker: ^2.0.2
|
||||||
|
picomatch: ^2.3.1
|
||||||
|
peerDependencies:
|
||||||
|
rollup: ^1.20.0||^2.0.0||^3.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
rollup:
|
||||||
|
optional: true
|
||||||
|
checksum: edea15e543bebc7dcac3b0ac8bc7b8e8e6dbd46e2864dbe5dd28072de1fbd5b0e10d545a610c0edaa178e8a7ac432e2a2a52e547ece1308471412caba47db8ce
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@stencil/core@npm:^2.20.0, @stencil/core@npm:^2.3.0":
|
"@stencil/core@npm:^2.20.0, @stencil/core@npm:^2.3.0":
|
||||||
version: 2.22.2
|
version: 2.22.2
|
||||||
resolution: "@stencil/core@npm:2.22.2"
|
resolution: "@stencil/core@npm:2.22.2"
|
||||||
@@ -814,7 +833,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/estree@npm:*":
|
"@types/estree@npm:*, @types/estree@npm:^1.0.0":
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
resolution: "@types/estree@npm:1.0.0"
|
resolution: "@types/estree@npm:1.0.0"
|
||||||
checksum: 910d97fb7092c6738d30a7430ae4786a38542023c6302b95d46f49420b797f21619cdde11fa92b338366268795884111c2eb10356e4bd2c8ad5b92941e9e6443
|
checksum: 910d97fb7092c6738d30a7430ae4786a38542023c6302b95d46f49420b797f21619cdde11fa92b338366268795884111c2eb10356e4bd2c8ad5b92941e9e6443
|
||||||
@@ -2251,7 +2270,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"estree-walker@npm:^2.0.1":
|
"estree-walker@npm:^2.0.1, estree-walker@npm:^2.0.2":
|
||||||
version: 2.0.2
|
version: 2.0.2
|
||||||
resolution: "estree-walker@npm:2.0.2"
|
resolution: "estree-walker@npm:2.0.2"
|
||||||
checksum: 6151e6f9828abe2259e57f5fd3761335bb0d2ebd76dc1a01048ccee22fabcfef3c0859300f6d83ff0d1927849368775ec5a6d265dde2f6de5a1be1721cd94efc
|
checksum: 6151e6f9828abe2259e57f5fd3761335bb0d2ebd76dc1a01048ccee22fabcfef3c0859300f6d83ff0d1927849368775ec5a6d265dde2f6de5a1be1721cd94efc
|
||||||
@@ -2418,7 +2437,7 @@ __metadata:
|
|||||||
"@lit-labs/task": ^1.1.3
|
"@lit-labs/task": ^1.1.3
|
||||||
"@rollup/plugin-babel": ^5.3.1
|
"@rollup/plugin-babel": ^5.3.1
|
||||||
"@rollup/plugin-commonjs": ^22.0.2
|
"@rollup/plugin-commonjs": ^22.0.2
|
||||||
"@rollup/plugin-image": ^2.1.1
|
"@rollup/plugin-image": ^3.0.2
|
||||||
"@rollup/plugin-json": ^4.1.0
|
"@rollup/plugin-json": ^4.1.0
|
||||||
"@rollup/plugin-node-resolve": ^13.3.0
|
"@rollup/plugin-node-resolve": ^13.3.0
|
||||||
"@rollup/plugin-replace": ^4.0.0
|
"@rollup/plugin-replace": ^4.0.0
|
||||||
@@ -2464,7 +2483,7 @@ __metadata:
|
|||||||
vis-util: ^5.0.2
|
vis-util: ^5.0.2
|
||||||
web-dialog: ^0.0.11
|
web-dialog: ^0.0.11
|
||||||
xss: ^1.0.14
|
xss: ^1.0.14
|
||||||
zod: ^3.20.6
|
zod: ^3.21.4
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
@@ -3504,7 +3523,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"mini-svg-data-uri@npm:^1.2.3":
|
"mini-svg-data-uri@npm:^1.4.4":
|
||||||
version: 1.4.4
|
version: 1.4.4
|
||||||
resolution: "mini-svg-data-uri@npm:1.4.4"
|
resolution: "mini-svg-data-uri@npm:1.4.4"
|
||||||
bin:
|
bin:
|
||||||
@@ -5483,9 +5502,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"zod@npm:^3.20.6":
|
"zod@npm:^3.21.4":
|
||||||
version: 3.20.6
|
version: 3.21.4
|
||||||
resolution: "zod@npm:3.20.6"
|
resolution: "zod@npm:3.21.4"
|
||||||
checksum: 804b1934b8b5e2fa3750bec90043e8118b201f330b9957b8b768389a971acadf812d2060cf62921086512dab4af691d10490acb03333da58fc485c0791893c89
|
checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|||||||
Reference in New Issue
Block a user