feat: Add experimental reolink media support (#1694)

* feat: Add experimental rich reolink support.

* Formatting fix
This commit is contained in:
Dermot Duffy
2024-11-25 20:19:12 -08:00
committed by GitHub
parent e898b73d63
commit dada65e008
63 changed files with 3402 additions and 483 deletions
+13 -17
View File
@@ -48,35 +48,31 @@ export class FrigateCardNextPreviousControl extends LitElement {
return html``;
}
const renderIcon =
!this.thumbnail || ['chevrons', 'icons'].includes(this._controlConfig.style);
const classes = {
controls: true,
previous: this.direction === 'previous',
next: this.direction === 'next',
thumbnails: this._controlConfig.style === 'thumbnails',
icons: ['chevrons', 'icons'].includes(this._controlConfig.style),
button: ['chevrons', 'icons'].includes(this._controlConfig.style),
thumbnails: !renderIcon,
icons: renderIcon,
button: renderIcon,
};
if (['chevrons', 'icons'].includes(this._controlConfig.style)) {
let icon: string;
if (this._controlConfig.style === 'chevrons') {
icon = this.direction === 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right';
} else {
if (!this.icon) {
return html``;
}
icon = this.icon;
}
if (renderIcon) {
const icon =
!this.thumbnail || !this.icon || this._controlConfig.style === 'chevrons'
? this.direction === 'previous'
? 'mdi:chevron-left'
: 'mdi:chevron-right'
: this.icon;
return html` <ha-icon-button class="${classMap(classes)}" .label=${this.label}>
<ha-icon icon=${icon}></ha-icon>
</ha-icon-button>`;
}
if (!this.thumbnail) {
return html``;
}
return renderTask(
this,
this._embedThumbnailTask,
+44 -29
View File
@@ -11,11 +11,13 @@ import {
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { CameraManager } from '../camera-manager/manager.js';
import { CameraManagerCameraMetadata } from '../camera-manager/types.js';
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
import { ViewManagerEpoch } from '../card-controller/view/types.js';
import { localize } from '../localize/localize.js';
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
import thumbnailFeatureTextStyle from '../scss/thumbnail-feature-text.scss';
import thumbnailFeatureThumbnailStyle from '../scss/thumbnail-feature-thumbnail.scss';
import thumbnailStyle from '../scss/thumbnail.scss';
import type { ExtendedHomeAssistant } from '../types.js';
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
@@ -31,13 +33,12 @@ import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumb
import { ViewMediaClassifier } from '../view/media-classifier.js';
import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js';
import { dispatchFrigateCardErrorEvent } from './message.js';
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
// The minimum width of a thumbnail with details enabled.
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
@customElement('frigate-card-thumbnail-feature-event')
export class FrigateCardThumbnailFeatureEvent extends LitElement {
@customElement('frigate-card-thumbnail-feature-thumbnail')
export class FrigateCardThumbnailFeatureThumbnail extends LitElement {
@property({ attribute: false })
public thumbnail?: string;
@@ -101,14 +102,15 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
}
protected render(): TemplateResult | void {
if (!this._embedThumbnailTask) {
return;
}
const imageOff = html`<ha-icon
icon="mdi:image-off"
title=${localize('thumbnail.no_thumbnail')}
></ha-icon> `;
if (!this._embedThumbnailTask) {
return imageOff;
}
return html`${this.thumbnail
? renderTask(
this,
@@ -121,31 +123,41 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailFeatureEventStyle);
return unsafeCSS(thumbnailFeatureThumbnailStyle);
}
}
@customElement('frigate-card-thumbnail-feature-recording')
export class FrigateCardThumbnailFeatureRecording extends LitElement {
@customElement('frigate-card-thumbnail-feature-text')
export class FrigateCardThumbnailFeatureText extends LitElement {
@property({ attribute: false })
public date?: Date;
@property({ attribute: false })
public cameraTitle?: string;
public cameraMetadata?: CameraManagerCameraMetadata;
@property({ attribute: false })
public showCameraTitle?: boolean;
protected render(): TemplateResult | void {
if (!this.date) {
return;
}
return html`
<div class="title">${format(this.date, 'HH:mm')}</div>
<div class="subtitle">${format(this.date, 'MMM do')}</div>
${this.cameraTitle ? html`<div class="camera">${this.cameraTitle}</div>` : html``}
${this.cameraMetadata?.engineLogo
? html`<img class="background" src="${this.cameraMetadata.engineLogo}" />`
: ''}
<div class="content">
<div class="title">${format(this.date, 'HH:mm')}</div>
<div class="subtitle">${format(this.date, 'MMM do')}</div>
${this.showCameraTitle && this.cameraMetadata?.title
? html`<div class="camera">${this.cameraMetadata.title}</div>`
: html``}
</div>
`;
}
static get styles(): CSSResult {
return unsafeCSS(thumbnailFeatureRecordingStyle);
return unsafeCSS(thumbnailFeatureTextStyle);
}
}
@@ -405,25 +417,28 @@ export class FrigateCardThumbnail extends LitElement {
this.media.getID() &&
mediaCapabilities?.canDownload;
const cameraTitle = this.cameraManager.getCameraMetadata(
const cameraMetadata = this.cameraManager.getCameraMetadata(
this.media.getCameraID(),
)?.title;
);
return html`
${ViewMediaClassifier.isEvent(this.media)
? html`<frigate-card-thumbnail-feature-event
${ViewMediaClassifier.isEvent(this.media) && thumbnail
? html`<frigate-card-thumbnail-feature-thumbnail
aria-label="${title ?? ''}"
title=${title}
.hass=${this.hass}
.date=${this.media.getStartTime() ?? undefined}
.thumbnail=${thumbnail ?? undefined}
></frigate-card-thumbnail-feature-event>`
: ViewMediaClassifier.isRecording(this.media)
? html`<frigate-card-thumbnail-feature-recording
></frigate-card-thumbnail-feature-thumbnail>`
: ViewMediaClassifier.isEvent(this.media) ||
ViewMediaClassifier.isRecording(this.media)
? html`<frigate-card-thumbnail-feature-text
aria-label="${title ?? ''}"
title="${title ?? ''}"
.cameraTitle=${this.details ? undefined : cameraTitle}
.cameraMetadata=${cameraMetadata}
.showCameraTitle=${!this.details}
.date=${this.media.getStartTime() ?? undefined}
></frigate-card-thumbnail-feature-recording>`
></frigate-card-thumbnail-feature-text>`
: html``}
${shouldShowFavoriteControl
? html` <ha-icon
@@ -450,13 +465,13 @@ export class FrigateCardThumbnail extends LitElement {
${this.details && ViewMediaClassifier.isEvent(this.media)
? html`<frigate-card-thumbnail-details-event
.media=${this.media ?? undefined}
.cameraTitle=${cameraTitle}
.cameraTitle=${cameraMetadata?.title}
.seek=${this.seek}
></frigate-card-thumbnail-details-event>`
: this.details && ViewMediaClassifier.isRecording(this.media)
? html`<frigate-card-thumbnail-details-recording
.media=${this.media ?? undefined}
.cameraTitle=${cameraTitle}
.cameraTitle=${cameraMetadata?.title}
.seek=${this.seek}
></frigate-card-thumbnail-details-recording>`
: html``}
@@ -516,7 +531,7 @@ declare global {
'frigate-card-thumbnail': FrigateCardThumbnail;
'frigate-card-thumbnail-details-recording': FrigateCardThumbnailDetailsRecording;
'frigate-card-thumbnail-details-event': FrigateCardThumbnailDetailsEvent;
'frigate-card-thumbnail-feature-recording': FrigateCardThumbnailFeatureRecording;
'frigate-card-thumbnail-feature-event': FrigateCardThumbnailFeatureEvent;
'frigate-card-thumbnail-feature-text': FrigateCardThumbnailFeatureText;
'frigate-card-thumbnail-feature-thumbnail': FrigateCardThumbnailFeatureThumbnail;
}
}
+93 -30
View File
@@ -6,7 +6,7 @@ import {
TemplateResult,
unsafeCSS,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { customElement, property, state } from 'lit/decorators.js';
import { guard } from 'lit/directives/guard.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { CameraManager } from '../../camera-manager/manager.js';
@@ -16,11 +16,24 @@ import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-
import { CardWideConfig, ViewerConfig } from '../../config/types.js';
import '../../patches/ha-hls-player.js';
import viewerProviderStyle from '../../scss/viewer-provider.scss';
import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js';
import {
ExtendedHomeAssistant,
FrigateCardMediaPlayer,
ResolvedMedia,
} from '../../types.js';
import { mayHaveAudio } from '../../utils/audio.js';
import { aspectRatioToString } from '../../utils/basic.js';
import { canonicalizeHAURL } from '../../utils/ha/index.js';
import { aspectRatioToString, errorToConsole } from '../../utils/basic.js';
import {
canonicalizeHAURL,
homeAssistantSignPath,
isHARelativeURL,
} from '../../utils/ha/index.js';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media.js';
import {
addDynamicProxyURL,
getWebProxiedURL,
shouldUseWebProxy,
} from '../../utils/ha/web-proxy.js';
import {
dispatchMediaLoadedEvent,
dispatchMediaPauseEvent,
@@ -77,6 +90,9 @@ export class FrigateCardViewerProvider
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
protected _refImageProvider: Ref<HTMLImageElement> = createRef();
@state()
protected _url: string | null = null;
public async play(): Promise<void> {
await playMediaMutingIfNecessary(
this,
@@ -191,21 +207,77 @@ export class FrigateCardViewerProvider
});
}
protected willUpdate(changedProps: PropertyValues): void {
const mediaContentID = this.media ? this.media.getContentID() : null;
protected async _setURL(): Promise<void> {
const mediaContentID = this.media?.getContentID();
if (
(changedProps.has('load') ||
changedProps.has('media') ||
changedProps.has('viewerConfig') ||
changedProps.has('resolvedMediaCache') ||
changedProps.has('hass')) &&
this.hass &&
mediaContentID &&
!this.resolvedMediaCache?.has(mediaContentID) &&
(!this.viewerConfig?.lazy_load || this.load)
!this.media ||
!mediaContentID ||
!this.hass ||
(this.viewerConfig?.lazy_load && !this.load)
) {
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => {
return;
}
let resolvedMedia: ResolvedMedia | null =
this.resolvedMediaCache?.get(mediaContentID) ?? null;
if (!resolvedMedia) {
resolvedMedia = await resolveMedia(
this.hass,
mediaContentID,
this.resolvedMediaCache,
);
}
if (!resolvedMedia) {
return;
}
const unsignedURL = resolvedMedia.url;
if (isHARelativeURL(unsignedURL)) {
// No need to proxy or sign local resolved URLs.
this._url = canonicalizeHAURL(this.hass, unsignedURL);
return;
}
const camera = this.cameraManager?.getStore().getCamera(this.media.getCameraID());
const proxyConfig = camera?.getProxyConfig();
if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'media')) {
if (proxyConfig.dynamic) {
// Don't use URL() parsing, since that will strip the port number if
// it's the default, just need to strip any hash part of the URL.
const urlWithoutQSorHash = unsignedURL.split(/#/)[0];
await addDynamicProxyURL(this.hass, urlWithoutQSorHash, {
sslVerification: proxyConfig.ssl_verification,
sslCiphers: proxyConfig.ssl_ciphers,
// The link may need to be opened multiple times.
openLimit: 0,
});
}
try {
this._url = await homeAssistantSignPath(
this.hass,
getWebProxiedURL(unsignedURL),
);
} catch (e) {
errorToConsole(e as Error);
}
} else {
this._url = unsignedURL;
}
}
protected willUpdate(changedProps: PropertyValues): void {
if (
changedProps.has('load') ||
changedProps.has('media') ||
changedProps.has('viewerConfig') ||
changedProps.has('resolvedMediaCache') ||
changedProps.has('hass')
) {
this._setURL().then(() => {
this.requestUpdate();
});
}
@@ -262,13 +334,7 @@ export class FrigateCardViewerProvider
return;
}
const mediaContentID = this.media.getContentID();
const resolvedMedia = mediaContentID
? this.resolvedMediaCache?.get(mediaContentID)
: null;
if (!resolvedMedia) {
// Media will be resolved with the call in willUpdate() then this will be
// re-rendered.
if (!this._url) {
return renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
});
@@ -288,7 +354,7 @@ export class FrigateCardViewerProvider
muted
playsinline
title="${this.media.getTitle() ?? ''}"
url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
url=${this._url}
.hass=${this.hass}
?controls=${this.viewerConfig.controls.builtin}
>
@@ -325,16 +391,13 @@ export class FrigateCardViewerProvider
@play=${() => dispatchMediaPlayEvent(this)}
@pause=${() => dispatchMediaPauseEvent(this)}
>
<source
src=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
type="video/mp4"
/>
<source src=${this._url} type="video/mp4" />
</video>
`
: html`<img
${ref(this._refImageProvider)}
aria-label="${this.media.getTitle() ?? ''}"
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
src="${this._url}"
title="${this.media.getTitle() ?? ''}"
@click=${() => {
if (this.viewerConfig?.snapshot_click_plays_clip) {
+1
View File
@@ -21,6 +21,7 @@ import viewsStyle from '../scss/views.scss';
import { ExtendedHomeAssistant } from '../types.js';
import { DeviceRegistryManager } from '../utils/ha/registry/device/index.js';
import { ResolvedMediaCache } from '../utils/ha/resolved-media.js';
import './surround.js';
// As a special case: The diagnostics view is not dynamically loaded in case
// something goes wrong.