@@ -266,11 +269,9 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
const rawEndTime = this.media.getEndTime();
- const endTime = rawStartTime
- ? rawEndTime
- ? getDurationString(rawStartTime, rawEndTime)
- : localize('event.in_progress')
- : null;
+ const duration =
+ rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
+ const inProgress = this.media.inProgress() ? localize('recording.in_progress') : null;
const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null;
@@ -284,17 +285,24 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
${startTime
? html`
-
+
${startTime}
-
-
- ${endTime}
-
`
+ ${duration || inProgress
+ ? html`
+
+ ${duration ? html`${duration}` : ''}
+ ${inProgress
+ ? html`${inProgress}`
+ : ''}
+
`
+ : ''}`
: ''}
${seek
? html`
diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts
index 8fcd38aa..11d53568 100644
--- a/src/components/timeline-core.ts
+++ b/src/components/timeline-core.ts
@@ -525,10 +525,13 @@ export class FrigateCardTimelineCore extends LitElement {
.selectResultIfFound((media) => media.getID() === properties.item);
if (!newResults || !newResults.hasSelectedResult()) {
- // This can happen if this is a recording query (with recorded hours)
- // and an event is clicked on the timeline, or if the current thumbnails
- // is a filtered view from the media gallery (i.e. any case where the
- // thumbnails may not be match the events on the timeline).
+ // This can happen in a few situations:
+ // - If this is a recording query (with recorded hours) and an event is
+ // clicked 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(
this._createEventMediaQuerys(),
{
@@ -551,10 +554,7 @@ export class FrigateCardTimelineCore extends LitElement {
}
if (view) {
- view
- // If the user is clicking something in the timeline, don't
- // subsequently shift the window (it's pretty jarring).
- .dispatchChangeEvent(this);
+ view.dispatchChangeEvent(this);
if (this.view?.is('timeline')) {
dispatchFrigateCardEvent(this, 'thumbnails:open');
@@ -895,8 +895,10 @@ export class FrigateCardTimelineCore extends LitElement {
const mediaStartTime = media?.getStartTime();
const mediaEndTime = media?.getEndTime();
const mediaWindow: TimelineWindow | null =
- media && mediaStartTime && mediaEndTime
- ? { start: mediaStartTime, end: mediaEndTime }
+ media && mediaStartTime
+ // 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;
const context = this.view.context?.timeline;
diff --git a/src/components/title-control.ts b/src/components/title-control.ts
index bdd524e1..16f14c9e 100644
--- a/src/components/title-control.ts
+++ b/src/components/title-control.ts
@@ -1,7 +1,6 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { customElement, property } from 'lit/decorators.js';
-
import { TitleControlConfig } from '../types.js';
import titleStyle from '../scss/title-control.scss';
@@ -21,6 +20,9 @@ export class FrigateCardTitleControl extends LitElement {
@property({ attribute: false })
public fitInto?: HTMLElement;
+ @property({ attribute: false })
+ public logo?: string;
+
protected _toastRef: Ref
= createRef();
/**
@@ -44,6 +46,7 @@ export class FrigateCardTitleControl extends LitElement {
.text="${this.text}"
.fitInto=${this.fitInto}
>
+ ${this.logo ? html`
` : ''}
`;
}
@@ -58,7 +61,7 @@ export class FrigateCardTitleControl extends LitElement {
/**
* Show the toast.
*/
- public hide(): void {
+ public hide(): void {
if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets.
this._toastRef.value.opened = false;
@@ -85,7 +88,7 @@ export class FrigateCardTitleControl extends LitElement {
}
declare global {
- interface HTMLElementTagNameMap {
- "frigate-card-title-control": FrigateCardTitleControl
- }
+ interface HTMLElementTagNameMap {
+ 'frigate-card-title-control': FrigateCardTitleControl;
+ }
}
diff --git a/src/components/viewer.ts b/src/components/viewer.ts
index 36029fd6..309c6d78 100644
--- a/src/components/viewer.ts
+++ b/src/components/viewer.ts
@@ -45,7 +45,7 @@ import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
} 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 { guard } from 'lit/directives/guard.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 { dispatchMediaLoadedEvent } from '../utils/media-info.js';
import { playMediaMutingIfNecessary } from '../utils/media.js';
+import {
+ hideMediaControlsTemporarily,
+ MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
+} from '../utils/media.js';
export interface MediaViewerViewContext {
seek?: Date;
@@ -399,7 +403,13 @@ export class FrigateCardViewerCarousel extends LitElement {
const media =
this.view?.queryResults?.getSelectedResult() ??
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;
}
@@ -416,6 +426,11 @@ export class FrigateCardViewerCarousel extends LitElement {
}
};
+ const cameraMetadata = this.cameraManager.getCameraMetadata(
+ this.hass,
+ media.getCameraID(),
+ );
+
return html` ({
@@ -426,6 +441,7 @@ export class FrigateCardViewerCarousel extends LitElement {
this._getPlugins.bind(this),
)}
.label=${media.getTitle() ?? undefined}
+ .logo=${cameraMetadata?.engineLogo}
.titlePopupConfig=${this.viewerConfig?.controls.title}
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
transitionEffect=${this._getTransitionEffect()}
@@ -544,30 +560,53 @@ export class FrigateCardViewerProvider
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
- protected _refVideoProvider: Ref = createRef();
+ protected _refFrigateCardMediaPlayer: Ref =
+ createRef();
+ protected _refVideoProvider: Ref = createRef();
public async play(): Promise {
- playMediaMutingIfNecessary(this._refVideoProvider.value);
+ playMediaMutingIfNecessary(
+ this,
+ this._refFrigateCardMediaPlayer.value ?? this._refVideoProvider.value,
+ );
}
public pause(): void {
- this._refVideoProvider.value?.pause();
+ (this._refFrigateCardMediaPlayer.value || this._refVideoProvider.value)?.pause();
}
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 {
- 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 {
- 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 {
- 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
// the other media items do).
!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)
) {
return;
@@ -666,19 +701,47 @@ export class FrigateCardViewerProvider
}
return ViewMediaClassifier.isVideo(this.media)
- ? html`
- `
+ ? this.media.getVideoContentType() === VideoContentType.HLS
+ ? html`
+ `
+ : html`
+
+ `
: html`
`
: icon.path
- ? html` `
+ ? html`
+
+ `
: ``}
${localize(labelPath)}
@@ -1411,7 +1424,42 @@ 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(
MENU_CAMERAS_LIVE_PROVIDER,
diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json
index 12931232..f1bc2bc0 100644
--- a/src/localize/languages/en.json
+++ b/src/localize/languages/en.json
@@ -57,6 +57,18 @@
"image": "Home Assistant images",
"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)",
"triggers": {
"entities": "Trigger from other entities",
@@ -428,14 +440,15 @@
"whens": {
"past_month": "Past Month",
"past_week": "Past Week",
- "today": "Today",
- "yesterday": "Yesterday"
+ "today": "Today",
+ "yesterday": "Yesterday"
},
"where": "Where"
},
"recording": {
"camera": "Camera",
"duration": "Duration",
+ "in_progress": "In Progress",
"events": "Events",
"seek": "Seek",
"start": "Start"
diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json
index 94040d26..802aab00 100644
--- a/src/localize/languages/it.json
+++ b/src/localize/languages/it.json
@@ -57,6 +57,18 @@
"image": "",
"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à)",
"triggers": {
"entities": "Trigger da altre entità",
@@ -427,6 +439,7 @@
"camera": "",
"duration": "",
"events": "Eventi",
+ "in_progress": "In corso",
"seek": "Cercare",
"start": ""
},
diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json
index 9a788ea8..6498842a 100644
--- a/src/localize/languages/pt-BR.json
+++ b/src/localize/languages/pt-BR.json
@@ -57,6 +57,18 @@
"image": "",
"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)",
"triggers": {
"entities": "Acionar a partir de outras entidades",
@@ -427,6 +439,7 @@
"camera": "",
"duration": "",
"events": "Eventos",
+ "in_progress": "Em andamento",
"seek": "Procurar",
"start": ""
},
diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts
index 99e6c204..d805963d 100644
--- a/src/patches/ha-camera-stream.ts
+++ b/src/patches/ha-camera-stream.ts
@@ -28,14 +28,6 @@ customElements.whenDefined('ha-camera-stream').then(() => {
const computeMJPEGStreamUrl = (entity: CameraEntity): string =>
`/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_WEB_RTC = 'web_rtc';
@@ -100,7 +92,6 @@ customElements.whenDefined('ha-camera-stream').then(() => {
.src=${typeof this._connected == 'undefined' || this._connected
? computeMJPEGStreamUrl(this.stateObj)
: ''}
- .alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`}
/>
`;
}
diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts
index 39c96113..b797da40 100644
--- a/src/patches/ha-hls-player.ts
+++ b/src/patches/ha-hls-player.ts
@@ -57,7 +57,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
}
public isMuted(): boolean {
- return this._video?.muted() ?? true;
+ return this._video?.muted ?? true;
}
public seek(seconds: number): void {
diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss
index 2a691227..f6a230ec 100644
--- a/src/scss/gallery.scss
+++ b/src/scss/gallery.scss
@@ -1,6 +1,7 @@
:host {
width: 100%;
- height: auto;
+ height: 100%;
+ display: block;
overflow: auto;
// Hide scrollbar: IE and Edge
@@ -11,7 +12,9 @@
--frigate-card-gallery-gap: 3px;
--frigate-card-gallery-columns: 4;
+}
+.grid {
display: grid;
grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr));
grid-auto-rows: min-content;
diff --git a/src/scss/message.scss b/src/scss/message.scss
index 3340f886..17486702 100644
--- a/src/scss/message.scss
+++ b/src/scss/message.scss
@@ -1,8 +1,10 @@
@use 'dotdotdot.scss';
:host {
- min-height: 100%;
+ display: block;
+ height: 100%;
width: 100%;
+
display: flex;
flex-direction: column;
justify-content: center;
diff --git a/src/scss/thumbnail-feature-event.scss b/src/scss/thumbnail-feature-event.scss
index 82851164..b10149f5 100644
--- a/src/scss/thumbnail-feature-event.scss
+++ b/src/scss/thumbnail-feature-event.scss
@@ -1,6 +1,12 @@
:host {
display: block;
overflow: hidden;
+
+ aspect-ratio: 1 / 1;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
}
img {
diff --git a/src/scss/title-control.scss b/src/scss/title-control.scss
index b8d2a862..6f2c9264 100644
--- a/src/scss/title-control.scss
+++ b/src/scss/title-control.scss
@@ -6,4 +6,11 @@
paper-toast {
max-width: unset;
min-width: unset;
+ display: flex;
+ align-items: center;
+}
+
+paper-toast img {
+ max-height: 24px;
+ padding-left: 10px;
}
\ No newline at end of file
diff --git a/src/scss/viewer-provider.scss b/src/scss/viewer-provider.scss
index f9adee75..6e5f94e6 100644
--- a/src/scss/viewer-provider.scss
+++ b/src/scss/viewer-provider.scss
@@ -7,6 +7,7 @@
}
img,
+video,
frigate-card-ha-hls-player {
display: block;
width: 100%;
diff --git a/src/types.ts b/src/types.ts
index e3c67717..dc4e4a76 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,7 +1,6 @@
import {
CallServiceActionConfig,
ConfirmationRestrictionConfig,
- CustomActionConfig,
HomeAssistant,
LovelaceCardConfig,
MoreInfoActionConfig,
@@ -95,7 +94,7 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
export type AutoUnmuteCondition = (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 {
context?: unknown;
@@ -182,15 +181,13 @@ const moreInfoActionSchema = schemaForType<
action: z.literal('more-info'),
}),
);
-const customActionSchema = schemaForType<
- CustomActionConfig & ExtendedConfirmationRestrictionConfig
->()(
- actionBaseSchema
- .extend({
- action: z.literal('fire-dom-event'),
- })
- .passthrough(),
-);
+
+const customActionSchema = actionBaseSchema
+ .extend({
+ action: z.literal('fire-dom-event'),
+ })
+ .passthrough();
+
const noActionSchema = schemaForType<
NoActionConfig & ExtendedConfirmationRestrictionConfig
>()(
@@ -450,19 +447,29 @@ const jsmpegConfigSchema = z.object({
* Camera configuration section
*/
const cameraConfigDefault = {
- live_provider: 'auto' as const,
- engine: 'auto' as const,
- frigate: {
- client_id: 'frigate' as const,
- },
dependencies: {
all_cameras: false,
cameras: [],
},
+ engine: 'auto' as const,
+ frigate: {
+ client_id: 'frigate' as const,
+ },
+ hide: false,
image: {
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: {
motion: false,
occupancy: true,
@@ -505,7 +512,6 @@ const cameraConfigSchema = z
engine: z.enum(ENGINES).default('auto'),
frigate: z
.object({
- // No URL validation to allow relative URLs within HA (e.g. Frigate addon).
url: z.string().optional(),
client_id: z.string().default(cameraConfigDefault.frigate.client_id),
camera_name: z.string().optional(),
@@ -513,6 +519,35 @@ const cameraConfigSchema = z
zones: z.string().array().optional(),
})
.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: 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
// that be easily used in a URL.
- card_id: z
- .string()
- .regex(/^\w+$/)
- .optional(),
+ card_id: z.string().regex(/^\w+$/).optional(),
// Stock lovelace card config.
type: z.string(),
diff --git a/src/utils/basic.ts b/src/utils/basic.ts
index e3d227a3..29872a47 100644
--- a/src/utils/basic.ts
+++ b/src/utils/basic.ts
@@ -196,3 +196,7 @@ export const isSuperset = (superset: Set
, subset: Set) => {
export const sleep = async (seconds: number) => {
await new Promise((r) => setTimeout(r, seconds * 1000));
};
+
+export const isValidDate = (date: Date): boolean => {
+ return !isNaN(date.getTime());
+}
\ No newline at end of file
diff --git a/src/utils/download.ts b/src/utils/download.ts
index fe93acd9..574a574e 100644
--- a/src/utils/download.ts
+++ b/src/utils/download.ts
@@ -10,23 +10,32 @@ export const downloadMedia = async (
cameraManager: CameraManager,
media: ViewMedia,
): Promise => {
- const path = cameraManager.getMediaDownloadPath(media);
- if (!path) {
+ const download = await cameraManager.getMediaDownloadPath(hass, media);
+ if (!download) {
throw new FrigateCardError(localize('error.download_no_media'));
}
- let response: string | null | undefined;
- try {
- response = await homeAssistantSignPath(hass, path);
- } catch (e) {
- errorToConsole(e as Error);
+ let finalURL = download.endpoint;
+ if (download.sign) {
+ let response: string | null | undefined;
+ try {
+ response = await homeAssistantSignPath(hass, download.endpoint);
+ } catch (e) {
+ errorToConsole(e as Error);
+ }
+
+ if (!response) {
+ throw new FrigateCardError(localize('error.download_sign_failed'));
+ }
+ finalURL = response;
}
- if (!response) {
- throw new FrigateCardError(localize('error.download_sign_failed'));
- }
+ // 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 (
+ !isSameOrigin ||
navigator.userAgent.startsWith('Home Assistant/') ||
navigator.userAgent.startsWith('HomeAssistant/')
) {
@@ -36,13 +45,13 @@ export const downloadMedia = async (
// 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
// - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75
- window.open(response, '_blank');
+ window.open(finalURL, '_blank');
} else {
// Use the HTML5 download attribute to prevent a new window from
// temporarily opening.
const link = document.createElement('a');
- link.setAttribute('download', '');
- link.href = response;
+ link.setAttribute('download', 'download');
+ link.href = finalURL;
link.click();
link.remove();
}
diff --git a/src/utils/endpoint.ts b/src/utils/endpoint.ts
index 58e5605b..e90c5c0f 100644
--- a/src/utils/endpoint.ts
+++ b/src/utils/endpoint.ts
@@ -11,23 +11,22 @@ export const getEndpointAddressOrDispatchError = async (
endpoint: CameraEndpoint,
expires?: number,
): Promise => {
- let address: string | null;
if (!endpoint.sign) {
- address = endpoint.endpoint;
- } else {
- let response: string | null | undefined;
- try {
- response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
- } catch (e) {
- errorToConsole(e as Error);
- return null;
- }
- address = response ? response.replace(/^http/i, 'ws') : null;
+ return endpoint.endpoint;
}
- if (!address) {
+ let response: string | null | undefined;
+ try {
+ response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
+ } catch (e) {
+ errorToConsole(e as Error);
+ return null;
+ }
+
+ if (!response) {
dispatchErrorMessageEvent(element, localize('error.failed_sign'));
return null;
}
- return address;
+
+ return response.replace(/^http/i, 'ws');
};
diff --git a/src/utils/ha/browse-media/browse-media-manager.ts b/src/utils/ha/browse-media/browse-media-manager.ts
new file mode 100644
index 00000000..d46d988a
--- /dev/null
+++ b/src/utils/ha/browse-media/browse-media-manager.ts
@@ -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 = MemoryRequestCache>;
+type RichMetadataGenerator = (
+ media: BrowseMedia,
+ parent?: RichBrowseMedia,
+) => M | null;
+
+export type BrowseMediaTarget = string | RichBrowseMedia;
+type RichBrowseMediaPredicate = (media: RichBrowseMedia) => boolean;
+
+export interface BrowseMediaStep {
+ // The targets to start the media walk from.
+ targets: BrowseMediaTarget[];
+
+ // All children of the target have the metadata generator applied to them
+ // first.
+ metadataGenerator?: RichMetadataGenerator;
+
+ // If those children pass this matcher, then they will be included in the
+ // output.
+ matcher: RichBrowseMediaPredicate;
+
+ // 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;
+}
+
+type BrowseMediaStepAdvancer = (media: RichBrowseMedia[]) => BrowseMediaStep[];
+
+export class BrowseMediaManager {
+ protected _cache: BrowseMediaCache;
+
+ constructor(cache: BrowseMediaCache) {
+ this._cache = cache;
+ }
+
+ // Walk down a browse media tree according to instructions included in `steps`.
+ public async walkBrowseMedias(
+ hass: HomeAssistant,
+ steps: BrowseMediaStep[] | null,
+ options?: {
+ useCache?: boolean;
+ },
+ ): Promise[]> {
+ 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,
+ options?: {
+ useCache?: boolean;
+ },
+ ): Promise[]> {
+ const media = await allPromises(
+ step.targets,
+ async (target) =>
+ await this._browseMedia(hass, target, {
+ useCache: options?.useCache,
+ metadataGenerator: step.metadataGenerator,
+ }),
+ );
+
+ const newTargets: RichBrowseMedia[] = [];
+ 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[] = [];
+
+ // 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,
+ options?: {
+ useCache?: boolean;
+ metadataGenerator?: RichMetadataGenerator;
+ },
+ ): Promise> {
+ 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;
+
+ 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;
+ }
+}
diff --git a/src/utils/ha/browse-media/types.ts b/src/utils/ha/browse-media/types.ts
new file mode 100644
index 00000000..15c3109c
--- /dev/null
+++ b/src/utils/ha/browse-media/types.ts
@@ -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 = 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 extends BrowseMedia {
+ _metadata?: M;
+ children?: RichBrowseMedia[] | 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;
diff --git a/src/utils/ha/entity-registry/types.ts b/src/utils/ha/entity-registry/types.ts
index 80fedd79..702b6b04 100644
--- a/src/utils/ha/entity-registry/types.ts
+++ b/src/utils/ha/entity-registry/types.ts
@@ -2,6 +2,7 @@ import { z } from 'zod';
export const entitySchema = z.object({
config_entry_id: z.string().nullable(),
+ device_id: z.string().nullable(),
disabled_by: z.string().nullable(),
entity_id: z.string(),
hidden_by: z.string().nullable(),
diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts
index f8bc3720..880291d5 100644
--- a/src/utils/ha/index.ts
+++ b/src/utils/ha/index.ts
@@ -354,12 +354,13 @@ export const isCardInPanel = (card: HTMLElement): boolean => {
* location will be the Chromecast receiver, not HA).
* @param url The media URL
*/
-export const canonicalizeHAURL = (
+export function canonicalizeHAURL(hass: ExtendedHomeAssistant, url: string): string;
+export function canonicalizeHAURL(
hass: ExtendedHomeAssistant,
url?: string,
-): string | null => {
+): string | null {
if (hass && url && url.startsWith('/')) {
return hass.hassUrl(url);
}
return url ?? null;
-};
+}
diff --git a/src/utils/media.ts b/src/utils/media.ts
index be16f46a..541baa0d 100644
--- a/src/utils/media.ts
+++ b/src/utils/media.ts
@@ -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 (
- player?: FrigateCardMediaPlayer,
+ player: FrigateCardMediaPlayer,
+ video?: HTMLVideoElement | FrigateCardMediaPlayer,
): Promise => {
// 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
// auto-play unless the video is muted.
- if (player?.play) {
- player.play().catch((ev) => {
+ if (video?.play) {
+ video.play().catch((ev) => {
if (ev.name === 'NotAllowedError' && !player.isMuted()) {
player.mute();
- player.play().catch();
+ video.play().catch();
}
});
}
diff --git a/src/utils/thumbnail.ts b/src/utils/thumbnail.ts
index b13dd57d..1c370cb7 100644
--- a/src/utils/thumbnail.ts
+++ b/src/utils/thumbnail.ts
@@ -2,6 +2,11 @@ import { Task } from '@lit-labs/task';
import { ReactiveControllerHost } from '@lit/reactive-element';
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.
* @param hass Home Assistant object.
@@ -12,10 +17,10 @@ const fetchThumbnail = async (
hass: HomeAssistant,
thumbnailURL: string,
): Promise => {
- if (!hass) {
+ if (!hass || !thumbnailURL) {
return null;
}
- if (thumbnailURL?.startsWith('data:')) {
+ if (thumbnailURL.startsWith('data:') || thumbnailURL.match(ABSOLUTE_URL_REGEX)) {
return thumbnailURL;
}
return new Promise((resolve, reject) => {
@@ -57,21 +62,18 @@ export const createFetchThumbnailTask = (
getThumbnailURL: () => string | undefined,
autoRun = true,
): Task => {
- return new Task(
- host,
- {
- // Do not re-run the task if hass changes, unless it was previously undefined.
- args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
- task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
- string | null
- > => {
- const hass = getHASS();
- if (!haveHASS || !hass || !thumbnailURL) {
- return null;
- }
- return fetchThumbnail(hass, thumbnailURL);
- },
- autoRun: autoRun,
+ return new Task(host, {
+ // Do not re-run the task if hass changes, unless it was previously undefined.
+ args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
+ task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
+ string | null
+ > => {
+ const hass = getHASS();
+ if (!haveHASS || !hass || !thumbnailURL) {
+ return null;
+ }
+ return fetchThumbnail(hass, thumbnailURL);
},
- );
+ autoRun: autoRun,
+ });
};
diff --git a/src/view/media.ts b/src/view/media.ts
index ee9bd832..5af18c04 100644
--- a/src/view/media.ts
+++ b/src/view/media.ts
@@ -1,5 +1,10 @@
export type ViewMediaType = 'clip' | 'snapshot' | 'recording';
+export enum VideoContentType {
+ MP4 = "mp4",
+ HLS = "hls",
+}
+
export class ViewMedia {
protected _mediaType: ViewMediaType;
protected _cameraID: string;
@@ -17,6 +22,9 @@ export class ViewMedia {
public getMediaType(): ViewMediaType {
return this._mediaType;
}
+ public getVideoContentType(): VideoContentType | null {
+ return null;
+ }
public getID(): string | null {
return null;
}
@@ -26,6 +34,9 @@ export class ViewMedia {
public getEndTime(): Date | null {
return null;
}
+ public inProgress(): boolean | null {
+ return null;
+ }
public getContentID(): string | null {
return null;
}
@@ -60,7 +71,6 @@ export interface EventViewMedia extends ViewMedia {
getWhat(): string[] | null;
getTags(): string[] | null;
isGroupableWith(that: EventViewMedia): boolean;
- hasClip(): boolean | null;
}
export interface RecordingViewMedia extends ViewMedia {
diff --git a/yarn.lock b/yarn.lock
index 77bbc94c..465531ab 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -689,15 +689,18 @@ __metadata:
languageName: node
linkType: hard
-"@rollup/plugin-image@npm:^2.1.1":
- version: 2.1.1
- resolution: "@rollup/plugin-image@npm:2.1.1"
+"@rollup/plugin-image@npm:^3.0.2":
+ version: 3.0.2
+ resolution: "@rollup/plugin-image@npm:3.0.2"
dependencies:
- "@rollup/pluginutils": ^3.1.0
- mini-svg-data-uri: ^1.2.3
+ "@rollup/pluginutils": ^5.0.1
+ mini-svg-data-uri: ^1.4.4
peerDependencies:
- rollup: ^1.20.0 || ^2.0.0
- checksum: a629c8f22233ca159c23655fdbc3449dab3c939372178ed4462fc9c525cc4ecd8b11fae359eb94be4f769d26f48b85fb18eb16ce1fbc33ed16b6a7c1f84391f6
+ rollup: ^1.20.0||^2.0.0||^3.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+ checksum: f9d8f587f10c51398fa8c23f1543e3073f969cf7e4acd7f401e02a3e3752702a9eb289ddb14009733ce37d04474c549aba9e7d13ebf50e26226266ba51546b69
languageName: node
linkType: hard
@@ -763,6 +766,22 @@ __metadata:
languageName: node
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":
version: 2.22.2
resolution: "@stencil/core@npm:2.22.2"
@@ -814,7 +833,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/estree@npm:*":
+"@types/estree@npm:*, @types/estree@npm:^1.0.0":
version: 1.0.0
resolution: "@types/estree@npm:1.0.0"
checksum: 910d97fb7092c6738d30a7430ae4786a38542023c6302b95d46f49420b797f21619cdde11fa92b338366268795884111c2eb10356e4bd2c8ad5b92941e9e6443
@@ -2251,7 +2270,7 @@ __metadata:
languageName: node
linkType: hard
-"estree-walker@npm:^2.0.1":
+"estree-walker@npm:^2.0.1, estree-walker@npm:^2.0.2":
version: 2.0.2
resolution: "estree-walker@npm:2.0.2"
checksum: 6151e6f9828abe2259e57f5fd3761335bb0d2ebd76dc1a01048ccee22fabcfef3c0859300f6d83ff0d1927849368775ec5a6d265dde2f6de5a1be1721cd94efc
@@ -2418,7 +2437,7 @@ __metadata:
"@lit-labs/task": ^1.1.3
"@rollup/plugin-babel": ^5.3.1
"@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-node-resolve": ^13.3.0
"@rollup/plugin-replace": ^4.0.0
@@ -2464,7 +2483,7 @@ __metadata:
vis-util: ^5.0.2
web-dialog: ^0.0.11
xss: ^1.0.14
- zod: ^3.20.6
+ zod: ^3.21.4
languageName: unknown
linkType: soft
@@ -3504,7 +3523,7 @@ __metadata:
languageName: node
linkType: hard
-"mini-svg-data-uri@npm:^1.2.3":
+"mini-svg-data-uri@npm:^1.4.4":
version: 1.4.4
resolution: "mini-svg-data-uri@npm:1.4.4"
bin:
@@ -5483,9 +5502,9 @@ __metadata:
languageName: node
linkType: hard
-"zod@npm:^3.20.6":
- version: 3.20.6
- resolution: "zod@npm:3.20.6"
- checksum: 804b1934b8b5e2fa3750bec90043e8118b201f330b9957b8b768389a971acadf812d2060cf62921086512dab4af691d10490acb03333da58fc485c0791893c89
+"zod@npm:^3.21.4":
+ version: 3.21.4
+ resolution: "zod@npm:3.21.4"
+ checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f
languageName: node
linkType: hard