Merge pull request #1013 from dermotduffy/viewer-provider

Refactor viewer to support other kinds of media players (for non-Frigate media)
This commit is contained in:
Dermot Duffy
2023-03-18 14:01:48 -07:00
committed by GitHub
7 changed files with 285 additions and 290 deletions
+13 -22
View File
@@ -55,6 +55,7 @@ import { HomeAssistant } from 'custom-card-helpers';
import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js'; import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js';
import { HassEntity } from 'home-assistant-js-websocket'; import { HassEntity } from 'home-assistant-js-websocket';
import { CameraEndpoints } from '../../camera-manager/types.js'; import { CameraEndpoints } from '../../camera-manager/types.js';
import { playMediaMutingIfNecessary } from '../../utils/media.js';
interface LiveViewContext { interface LiveViewContext {
// A cameraID override (used for dependencies/substreams to force a different // A cameraID override (used for dependencies/substreams to force a different
@@ -717,40 +718,30 @@ export class FrigateCardLiveProvider
@state() @state()
protected _isVideoMediaLoaded = false; protected _isVideoMediaLoaded = false;
protected _providerRef: Ref<Element & FrigateCardMediaPlayer> = createRef(); protected _refProvider: Ref<Element & FrigateCardMediaPlayer> = createRef();
public async play(): Promise<void> { public async play(): Promise<void> {
// If the play call fails, and the media is not already muted, mute it first playMediaMutingIfNecessary(this._refProvider.value)
// and then try again. This works around some browsers that prevent
// auto-play unless the video is muted.
if (this._providerRef.value?.play) {
this._providerRef.value?.play().catch((ev) => {
if (ev.name === 'NotAllowedError' && !this.isMuted()) {
this.mute();
this._providerRef.value?.play().catch();
}
});
}
} }
public pause(): void { public pause(): void {
this._providerRef.value?.pause(); this._refProvider.value?.pause();
} }
public mute(): void { public mute(): void {
this._providerRef.value?.mute(); this._refProvider.value?.mute();
} }
public unmute(): void { public unmute(): void {
this._providerRef.value?.unmute(); this._refProvider.value?.unmute();
} }
public isMuted(): boolean { public isMuted(): boolean {
return this._providerRef.value?.isMuted() ?? true; return this._refProvider.value?.isMuted() ?? true;
} }
public seek(seconds: number): void { public seek(seconds: number): void {
this._providerRef.value?.seek(seconds); this._refProvider.value?.seek(seconds);
} }
/** /**
@@ -860,7 +851,7 @@ export class FrigateCardLiveProvider
return html` return html`
${showImageDuringLoading || provider === 'image' ${showImageDuringLoading || provider === 'image'
? html`<frigate-card-live-image ? html`<frigate-card-live-image
${ref(this._providerRef)} ${ref(this._refProvider)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${this.cameraConfig} .cameraConfig=${this.cameraConfig}
@frigate-card:media:loaded=${(ev: Event) => { @frigate-card:media:loaded=${(ev: Event) => {
@@ -878,7 +869,7 @@ export class FrigateCardLiveProvider
: html``} : html``}
${provider === 'ha' ${provider === 'ha'
? html` <frigate-card-live-ha ? html` <frigate-card-live-ha
${ref(this._providerRef)} ${ref(this._refProvider)}
class=${classMap(providerClasses)} class=${classMap(providerClasses)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${this.cameraConfig} .cameraConfig=${this.cameraConfig}
@@ -887,7 +878,7 @@ export class FrigateCardLiveProvider
</frigate-card-live-ha>` </frigate-card-live-ha>`
: provider === 'go2rtc' : provider === 'go2rtc'
? html`<frigate-card-live-go2rtc ? html`<frigate-card-live-go2rtc
${ref(this._providerRef)} ${ref(this._refProvider)}
class=${classMap(providerClasses)} class=${classMap(providerClasses)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${this.cameraConfig} .cameraConfig=${this.cameraConfig}
@@ -897,7 +888,7 @@ export class FrigateCardLiveProvider
</frigate-card-live-webrtc-card>` </frigate-card-live-webrtc-card>`
: provider === 'webrtc-card' : provider === 'webrtc-card'
? html`<frigate-card-live-webrtc-card ? html`<frigate-card-live-webrtc-card
${ref(this._providerRef)} ${ref(this._refProvider)}
class=${classMap(providerClasses)} class=${classMap(providerClasses)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${this.cameraConfig} .cameraConfig=${this.cameraConfig}
@@ -908,7 +899,7 @@ export class FrigateCardLiveProvider
</frigate-card-live-webrtc-card>` </frigate-card-live-webrtc-card>`
: provider === 'jsmpeg' : provider === 'jsmpeg'
? html` <frigate-card-live-jsmpeg ? html` <frigate-card-live-jsmpeg
${ref(this._providerRef)} ${ref(this._refProvider)}
class=${classMap(providerClasses)} class=${classMap(providerClasses)}
.hass=${this.hass} .hass=${this.hass}
.cameraConfig=${this.cameraConfig} .cameraConfig=${this.cameraConfig}
-20
View File
@@ -12,7 +12,6 @@ import type {
} from '../types.js'; } from '../types.js';
import { dispatchFrigateCardEvent } from '../utils/basic'; import { dispatchFrigateCardEvent } from '../utils/basic';
import { import {
createMediaLoadedInfo,
dispatchExistingMediaLoadedInfoAsEvent, dispatchExistingMediaLoadedInfoAsEvent,
isValidMediaLoadedInfo, isValidMediaLoadedInfo,
} from '../utils/media-info.js'; } from '../utils/media-info.js';
@@ -24,10 +23,6 @@ import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import { FrigateCardTitleControl } from './title-control.js'; import { FrigateCardTitleControl } from './title-control.js';
import debounce from 'lodash-es/debounce'; import debounce from 'lodash-es/debounce';
const getEmptyImageSrc = (width: number, height: number) =>
`data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}"%3E%3C/svg%3E`;
export const IMG_EMPTY = getEmptyImageSrc(16, 9);
interface CarouselMediaLoadedInfo { interface CarouselMediaLoadedInfo {
slide: number; slide: number;
mediaLoadedInfo: MediaLoadedInfo; mediaLoadedInfo: MediaLoadedInfo;
@@ -85,21 +80,6 @@ export const wrapMediaLoadedEventForCarousel = (
}); });
}; };
/**
* Turn a (raw, e.g. img) media load event into a CarouselMediaLoadedInfo.
* @param slide The slide number.
* @param event The MediaShowEvent.
*/
export const wrapRawMediaLoadedEventForCarousel = (slide: number, event: Event) => {
const mediaLoadedInfo = createMediaLoadedInfo(event);
if (mediaLoadedInfo) {
dispatchFrigateCardCarouselMediaLoaded(event.composedPath()[0], {
slide: slide,
mediaLoadedInfo: mediaLoadedInfo,
});
}
};
/** /**
* Turn a MediaUnloadedInfo into a CarouselMediaUnloadedInfo. * Turn a MediaUnloadedInfo into a CarouselMediaUnloadedInfo.
* @param slide The slide number. * @param slide The slide number.
+214 -238
View File
@@ -1,4 +1,3 @@
import { Task } from '@lit-labs/task';
import { EmblaPluginType } from 'embla-carousel'; import { EmblaPluginType } from 'embla-carousel';
import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures'; import { WheelGesturesPlugin } from 'embla-carousel-wheel-gestures';
import { import {
@@ -10,18 +9,17 @@ import {
unsafeCSS, unsafeCSS,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js';
import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js'; import { dispatchMessageEvent, renderProgressIndicator } from '../components/message.js';
import viewerStyle from '../scss/viewer.scss';
import viewerCarouselStyle from '../scss/viewer-carousel.scss'; import viewerCarouselStyle from '../scss/viewer-carousel.scss';
import viewerProviderStyle from '../scss/viewer-provider.scss';
import viewerStyle from '../scss/viewer.scss';
import { import {
CardWideConfig, CardWideConfig,
ExtendedHomeAssistant, ExtendedHomeAssistant,
frigateCardConfigDefaults, frigateCardConfigDefaults,
FrigateCardMediaPlayer, FrigateCardMediaPlayer,
MediaLoadedInfo, MediaLoadedInfo,
ResolvedMedia,
TransitionEffect, TransitionEffect,
ViewerConfig, ViewerConfig,
} from '../types.js'; } from '../types.js';
@@ -34,8 +32,6 @@ import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import { Lazyload } from './embla-plugins/lazyload.js'; import { Lazyload } from './embla-plugins/lazyload.js';
import { import {
FrigateCardMediaCarousel, FrigateCardMediaCarousel,
IMG_EMPTY,
wrapRawMediaLoadedEventForCarousel,
wrapMediaLoadedEventForCarousel, wrapMediaLoadedEventForCarousel,
} from './media-carousel.js'; } from './media-carousel.js';
import type { CarouselSelect } from './carousel.js'; import type { CarouselSelect } from './carousel.js';
@@ -43,7 +39,6 @@ import './next-prev-control.js';
import './title-control.js'; import './title-control.js';
import '../patches/ha-hls-player'; import '../patches/ha-hls-player';
import './surround.js'; import './surround.js';
import { renderTask } from '../utils/task.js';
import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js'; import { updateElementStyleFromMediaLayoutConfig } from '../utils/media-layout.js';
import { CameraManager } from '../camera-manager/manager.js'; import { CameraManager } from '../camera-manager/manager.js';
import { import {
@@ -55,6 +50,9 @@ 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';
import { MediaQueriesResults } from '../view/media-queries-results.js'; 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';
export interface MediaViewerViewContext { export interface MediaViewerViewContext {
seek?: Date; seek?: Date;
@@ -197,38 +195,6 @@ export class FrigateCardViewerCarousel extends LitElement {
protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef(); protected _refMediaCarousel: Ref<FrigateCardMediaCarousel> = createRef();
// A task to resolve target media if lazy loading is disabled.
protected _mediaResolutionTask = new Task<
[ViewerConfig | undefined, View | undefined],
void
>(
this,
async ([viewerConfig, view]: [
ViewerConfig | undefined,
View | undefined,
]): Promise<void> => {
if (
!this.hass ||
!viewerConfig?.lazy_load ||
!view ||
!view.queryResults?.hasResults()
) {
return;
}
const promises: Promise<ResolvedMedia | null>[] = [];
view.queryResults?.getResults()?.forEach((media: ViewMedia) => {
const mediaContentID = media.getContentID();
if (this.hass && mediaContentID) {
promises.push(
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache),
);
}
});
await Promise.all(promises);
},
() => [this.viewerConfig, this.view],
);
/** /**
* The updated lifecycle callback for this element. * The updated lifecycle callback for this element.
* @param changedProperties The properties that were changed in this render. * @param changedProperties The properties that were changed in this render.
@@ -293,7 +259,7 @@ export class FrigateCardViewerCarousel extends LitElement {
: []), : []),
Lazyload({ Lazyload({
...(this.viewerConfig?.lazy_load && { ...(this.viewerConfig?.lazy_load && {
lazyLoadCallback: this._lazyloadSlide.bind(this), lazyLoadCallback: (_index, slide) => this._lazyloadSlide(slide),
}), }),
}), }),
AutoMediaPlugin({ AutoMediaPlugin({
@@ -335,65 +301,6 @@ export class FrigateCardViewerCarousel extends LitElement {
return [previous, next]; return [previous, next];
} }
/**
* Dispatch a clip view that matches the current (snapshot) query.
* @param index The index of the selected media.
*/
protected async _dispatchRelatedClipView(index: number): Promise<void> {
const media = this.view?.queryResults?.getResult(index);
if (
!this.hass ||
!this.view ||
!this.cameraManager ||
!media ||
// If this specific media item has no clip, then do nothing (even if all
// the other media items do).
!ViewMediaClassifier.isEvent(media) ||
// If the event certainly has no clip, don't bother going further. If
// we're not sure for this camera type (i.e. hasClip() === null) the query
// will proceed anyway.
media.hasClip() === false ||
!MediaQueriesClassifier.areEventQueries(this.view.query)
) {
return;
}
// Convert the query to a clips equivalent.
const clipQuery = this.view.query.clone();
clipQuery.convertToClipsQueries();
const queries = clipQuery.getQueries();
if (!queries) {
return;
}
let mediaArray: ViewMedia[] | null;
try {
mediaArray = await this.cameraManager.executeMediaQueries(this.hass, queries);
} catch (e) {
errorToConsole(e as Error);
return;
}
if (!mediaArray) {
return;
}
const results = new MediaQueriesResults(mediaArray);
results.selectResultIfFound((clipMedia) => clipMedia.getID() === media.getID());
if (!results.hasSelectedResult()) {
return;
}
this.view
.evolve({
view: 'media',
query: clipQuery,
queryResults: results,
})
.dispatchChangeEvent(this);
}
protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void { protected _setViewHandler(ev: CustomEvent<CarouselSelect>): void {
this._setViewSelectedIndex(ev.detail.index); this._setViewSelectedIndex(ev.detail.index);
} }
@@ -427,55 +334,21 @@ export class FrigateCardViewerCarousel extends LitElement {
.dispatchChangeEvent(this); .dispatchChangeEvent(this);
} }
/**
* Ensure media URLs use the correct HA URL (relevant for Chromecast where the
* default location will be the Chromecast receiver, not HA).
* @param url The media URL
*/
protected _canonicalizeHAURL(url?: string): string | null {
if (this.hass && url && url.startsWith('/')) {
return this.hass.hassUrl(url);
}
return url ?? null;
}
/** /**
* Lazy load a slide. * Lazy load a slide.
* @param index The index of the slide to lazy load.
* @param slide The slide to lazy load. * @param slide The slide to lazy load.
*/ */
protected _lazyloadSlide(index: number, slide: HTMLElement): void { protected _lazyloadSlide(slide: Element): void {
if (!this.hass || !this.view || !this.view.query) { if (slide instanceof HTMLSlotElement) {
return; slide = slide.assignedElements({ flatten: true })[0];
} }
const media = this.view.queryResults?.getResult(index); const viewerProvider = slide?.querySelector(
const mediaContentID = media ? media.getContentID() : null; 'frigate-card-viewer-provider',
if (!mediaContentID) { ) as FrigateCardViewerProvider | null;
return; if (viewerProvider) {
viewerProvider.disabled = false;
} }
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(
(resolvedMedia) => {
if (!resolvedMedia) {
return;
}
// Snapshots.
const img = slide.querySelector('img') as HTMLImageElement;
// Frigate >= 0.9.0+ clips.
const hls_player = this._getPlayer(slide) as FrigateCardMediaPlayer & {
url: string;
};
if (img) {
img.src = this._canonicalizeHAURL(resolvedMedia.url) ?? '';
} else if (hls_player) {
hls_player.url = this._canonicalizeHAURL(resolvedMedia.url) ?? '';
}
},
);
} }
/** /**
@@ -500,22 +373,6 @@ export class FrigateCardViewerCarousel extends LitElement {
return slides; return slides;
} }
/**
* Determine if all the media in the carousel are resolved.
*/
protected _isMediaFullyResolved(): boolean {
if (!this.resolvedMediaCache) {
return false;
}
for (const media of this.view?.queryResults?.getResults() ?? []) {
const mediaContentID = media.getContentID();
if (mediaContentID && !this.resolvedMediaCache.has(mediaContentID)) {
return false;
}
}
return true;
}
/** /**
* Called when an update will occur. * Called when an update will occur.
* @param changedProps The changed properties * @param changedProps The changed properties
@@ -526,25 +383,7 @@ export class FrigateCardViewerCarousel extends LitElement {
} }
} }
/**
* Render the element, resolving the media first if necessary.
*/
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
// If lazy loading is not enabled, wait for the media resolver task to
// complete and show a progress indictator until this.
if (!this.viewerConfig?.lazy_load && !this._isMediaFullyResolved()) {
return renderTask(this, this._mediaResolutionTask, this._render.bind(this), {
cardWideConfig: this.cardWideConfig,
});
}
return this._render();
}
/**
* Render the element.
* @returns A template to display to the user.
*/
protected _render(): TemplateResult | void {
const resultCount = this.view?.queryResults?.getResultsCount() ?? 0; const resultCount = this.view?.queryResults?.getResultsCount() ?? 0;
if (!resultCount) { if (!resultCount) {
return dispatchMessageEvent(this, localize('common.no_media'), 'info', { return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
@@ -646,79 +485,215 @@ export class FrigateCardViewerCarousel extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null { protected _renderMediaItem(media: ViewMedia, index: number): TemplateResult | null {
// Skip folders as they cannot be rendered by this viewer.
if (!this.hass || !this.view || !this.viewerConfig) { if (!this.hass || !this.view || !this.viewerConfig) {
return null; return null;
} }
const lazyLoad = this.viewerConfig.lazy_load; return html` <div class="embla__slide">
const mediaContentID = media.getContentID(); <frigate-card-viewer-provider
const resolvedMedia = mediaContentID .hass=${this.hass}
? this.resolvedMediaCache?.get(mediaContentID) .view=${this.view}
: null; .media=${media}
if (!resolvedMedia && !lazyLoad) { .viewerConfig=${this.viewerConfig}
return null; .resolvedMediaCache=${this.resolvedMediaCache}
} .cameraManager=${this.cameraManager}
.disabled=${this.viewerConfig.lazy_load}
.cardWideConfig=${this.cardWideConfig}
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => {
wrapMediaLoadedEventForCarousel(index, e);
}}
></frigate-card-viewer-provider>
</div>`;
}
// The media is attached to the player as '.media' which is used in static get styles(): CSSResultGroup {
// `_selectSlideMediaShowHandler` (and not used by the player itself). return unsafeCSS(viewerCarouselStyle);
return html` }
<div class="embla__slide"> }
${ViewMediaClassifier.isVideo(media)
? html`<frigate-card-ha-hls-player @customElement('frigate-card-viewer-provider')
allow-exoplayer export class FrigateCardViewerProvider
aria-label="${media.getTitle() ?? ''}" extends LitElement
?autoplay=${false} implements FrigateCardMediaPlayer
controls {
muted @property({ attribute: false })
playsinline public hass?: ExtendedHomeAssistant;
title="${media.getTitle() ?? ''}"
url=${ifDefined( @property({ attribute: false })
lazyLoad ? undefined : this._canonicalizeHAURL(resolvedMedia?.url) ?? '', public view?: Readonly<View>;
)}
.hass=${this.hass} @property({ attribute: false })
@frigate-card:media:loaded=${(e: CustomEvent<MediaLoadedInfo>) => { public media?: ViewMedia;
wrapMediaLoadedEventForCarousel(index, e);
}} @property({ attribute: false })
> public viewerConfig?: ViewerConfig;
</frigate-card-ha-hls-player>`
: html`<img @property({ attribute: false })
aria-label="${media.getTitle() ?? ''}" public resolvedMediaCache?: ResolvedMediaCache;
src=${ifDefined(
lazyLoad ? IMG_EMPTY : this._canonicalizeHAURL(resolvedMedia?.url) ?? '', // Whether or not to disable this entity. If `true`, no contents are rendered
)} // until this attribute is set to `false` (this is useful for lazy loading).
title="${media.getTitle() ?? ''}" @property({ attribute: false })
@click=${() => { public disabled = false;
if (this.viewerConfig?.snapshot_click_plays_clip) {
this._dispatchRelatedClipView(index); @property({ attribute: false })
} public cameraManager?: CameraManager;
}}
@load="${(e: Event) => { @property({ attribute: false })
const lazyloadPlugin = this._refMediaCarousel.value public cardWideConfig?: CardWideConfig;
?.frigateCardCarousel()
?.getCarouselPlugins()?.lazyload; protected _refVideoProvider: Ref<Element & FrigateCardMediaPlayer> = createRef();
if (
// This handler will be called on the empty image (including public async play(): Promise<void> {
// an updated empty image that is the same dimensions large as playMediaMutingIfNecessary(this._refVideoProvider.value)
// the previously fully loaded image -- see the note on dummy }
// images in media-carousel.ts). Here we need to only call the
// media load handler on a 'real' load. public pause(): void {
!lazyLoad || this._refVideoProvider.value?.pause();
lazyloadPlugin?.hasLazyloaded(index) }
) {
wrapRawMediaLoadedEventForCarousel(index, e); public mute(): void {
} this._refVideoProvider.value?.mute();
}}" }
/>`}
</div> public unmute(): void {
`; this._refVideoProvider.value?.unmute();
}
public isMuted(): boolean {
return this._refVideoProvider.value?.isMuted() ?? true;
}
public seek(seconds: number): void {
this._refVideoProvider.value?.seek(seconds);
} }
/** /**
* Get element styles. * Dispatch a clip view that matches the current (snapshot) query.
*/ */
protected async _dispatchRelatedClipView(): Promise<void> {
if (
!this.hass ||
!this.view ||
!this.cameraManager ||
!this.media ||
// 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;
}
// Convert the query to a clips equivalent.
const clipQuery = this.view.query.clone();
clipQuery.convertToClipsQueries();
const queries = clipQuery.getQueries();
if (!queries) {
return;
}
let mediaArray: ViewMedia[] | null;
try {
mediaArray = await this.cameraManager.executeMediaQueries(this.hass, queries);
} catch (e) {
errorToConsole(e as Error);
return;
}
if (!mediaArray) {
return;
}
const results = new MediaQueriesResults(mediaArray);
results.selectResultIfFound(
(clipMedia) => clipMedia.getID() === this.media?.getID(),
);
if (!results.hasSelectedResult()) {
return;
}
this.view
.evolve({
view: 'media',
query: clipQuery,
queryResults: results,
})
.dispatchChangeEvent(this);
}
protected willUpdate(changedProps: PropertyValues): void {
const mediaContentID = this.media ? this.media.getContentID() : null;
if (
(changedProps.has('disabled') ||
changedProps.has('media') ||
changedProps.has('viewerConfig') ||
changedProps.has('resolvedMediaCache') ||
changedProps.has('hass')) &&
this.hass &&
mediaContentID &&
!this.resolvedMediaCache?.has(mediaContentID) &&
(!this.viewerConfig?.lazy_load || !this.disabled)
) {
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => {
this.requestUpdate();
});
}
}
protected render(): TemplateResult | void {
if (this.disabled || !this.media || !this.hass || !this.view || !this.viewerConfig) {
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.
return renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
});
}
return ViewMediaClassifier.isVideo(this.media)
? html`<frigate-card-ha-hls-player
${ref(this._refVideoProvider)}
allow-exoplayer
aria-label="${this.media.getTitle() ?? ''}"
?autoplay=${false}
controls
muted
playsinline
title="${this.media.getTitle() ?? ''}"
url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
.hass=${this.hass}
>
</frigate-card-ha-hls-player>`
: html`<img
aria-label="${this.media.getTitle() ?? ''}"
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
title="${this.media.getTitle() ?? ''}"
@click=${() => {
if (this.viewerConfig?.snapshot_click_plays_clip) {
this._dispatchRelatedClipView();
}
}}
@load=${(e: Event) => {
dispatchMediaLoadedEvent(this, e);
}}
/>`;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return unsafeCSS(viewerCarouselStyle); return unsafeCSS(viewerProviderStyle);
} }
} }
@@ -726,5 +701,6 @@ declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'frigate-card-viewer-carousel': FrigateCardViewerCarousel; 'frigate-card-viewer-carousel': FrigateCardViewerCarousel;
'frigate-card-viewer': FrigateCardViewer; 'frigate-card-viewer': FrigateCardViewer;
'frigate-card-viewer-provider': FrigateCardViewerProvider;
} }
} }
-10
View File
@@ -1,14 +1,4 @@
@use 'media-layout.scss';
.embla__slide { .embla__slide {
height: 100%; height: 100%;
flex: 0 0 100%; flex: 0 0 100%;
} }
.embla__slide img {
display: block;
width: 100%;
height: 100%;
@include media-layout.media-layout();
}
+21
View File
@@ -0,0 +1,21 @@
@use 'media-layout.scss';
:host {
display: block;
width: 100%;
height: 100%;
}
img,
frigate-card-ha-hls-player {
display: block;
width: 100%;
height: 100%;
@include media-layout.media-layout();
}
frigate-card-progress-indicator {
padding: 30px;
box-sizing: border-box;
}
+15
View File
@@ -348,3 +348,18 @@ export const isCardInPanel = (card: HTMLElement): boolean => {
parent.host.tagName === 'HUI-PANEL-VIEW' parent.host.tagName === 'HUI-PANEL-VIEW'
); );
}; };
/**
* Ensure URLs use the correct HA URL (relevant for Chromecast where the default
* location will be the Chromecast receiver, not HA).
* @param url The media URL
*/
export const canonicalizeHAURL = (
hass: ExtendedHomeAssistant,
url?: string,
): string | null => {
if (hass && url && url.startsWith('/')) {
return hass.hassUrl(url);
}
return url ?? null;
};
+22
View File
@@ -1,3 +1,5 @@
import { FrigateCardMediaPlayer } from '../types';
// The number of seconds to hide the video controls for after loading (in order // The number of seconds to hide the video controls for after loading (in order
// to give a cleaner UI appearance, see: // to give a cleaner UI appearance, see:
// https://github.com/dermotduffy/frigate-hass-card/issues/856 // https://github.com/dermotduffy/frigate-hass-card/issues/856
@@ -27,3 +29,23 @@ export const hideMediaControlsTemporarily = (
delete element._controlsHideTimeoutID; delete element._controlsHideTimeoutID;
}, seconds * 1000); }, seconds * 1000);
}; };
/**
* Play a piece of media, muting it if necessary.
* @param underlyingPlayer
*/
export const playMediaMutingIfNecessary = async (
player?: FrigateCardMediaPlayer,
): Promise<void> => {
// 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 (ev.name === 'NotAllowedError' && !player.isMuted()) {
player.mute();
player.play().catch();
}
});
}
};