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:
+13
-22
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user