feat: Add experimental rewrite of go2rtc live provider (MSE/WebRTC/MP4/MJPEG) (#2580)
- Closes #2556 - Closes #2450 **Key intended features:** - go2rtc compatible - 100% test coverage to significantly improve ability to test, maintain and work around browser weirdnesses (e.g. Safari). - Written from the ground up in the style of the rest of the project. **To use:** - Change `live_provider` from `go2rtc` to `go2rtc-experimental`.
This commit is contained in:
@@ -38,7 +38,7 @@ import { getReviewedQueryFilterFromQuery } from '../../view/utils/query-filter.j
|
||||
|
||||
import '../media-filter.js';
|
||||
|
||||
import { renderNoMedia } from '../notification/no-media.js';
|
||||
import { renderNoMediaNotification } from '../notification/media.js';
|
||||
|
||||
import '../surround-basic.js';
|
||||
import '../thumbnail/thumbnail.js';
|
||||
@@ -219,11 +219,13 @@ export class AdvancedCameraCardGallery extends LitElement {
|
||||
</advanced-camera-card-media-filter>`
|
||||
: ''}
|
||||
${!hasItems
|
||||
? renderNoMedia({
|
||||
cameraID: this.viewManagerEpoch?.manager.getView()?.camera ?? null,
|
||||
cameraManager: this.cameraManager ?? null,
|
||||
loading: isLoading,
|
||||
})
|
||||
? renderNoMediaNotification(
|
||||
{
|
||||
cameraID: this.viewManagerEpoch?.manager.getView()?.camera ?? null,
|
||||
inProgress: isLoading,
|
||||
},
|
||||
this.cameraManager,
|
||||
)
|
||||
: html`<advanced-camera-card-gallery-core
|
||||
.hass=${this.hass}
|
||||
.columnWidth=${this._controller.getColumnWidth(
|
||||
|
||||
@@ -12,16 +12,17 @@ import { live } from 'lit/directives/live.js';
|
||||
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
||||
|
||||
import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity-from-config.js';
|
||||
import type { MediaUnavailableIssueReason } from '../card-controller/issues/issues/media-unavailable.js';
|
||||
import type { IssueTriggerEventData } from '../card-controller/issues/types.js';
|
||||
import { CachedValueController } from '../components-lib/cached-value-controller.js';
|
||||
import { MediaLoadedInfoSourceController } from '../components-lib/media-loaded-info-source-controller.js';
|
||||
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
|
||||
import { ImageMediaPlayerController } from '../components-lib/media-player/image.js';
|
||||
import { createMediaNotification } from '../components-lib/notification/media.js';
|
||||
import { SignedURLController } from '../components-lib/signed-url-controller.js';
|
||||
import type { Notification } from '../config/schema/actions/types.js';
|
||||
import type { CameraConfig } from '../config/schema/cameras.js';
|
||||
import { type ImageBaseConfig, type ImageMode } from '../config/schema/common/image.js';
|
||||
import type { EnabledProxyConfig } from '../config/schema/common/proxy.js';
|
||||
import { TROUBLESHOOTING_URL } from '../const.js';
|
||||
import { isHassDifferent } from '../ha/is-hass-different.js';
|
||||
import type { HomeAssistant } from '../ha/types.js';
|
||||
import defaultImage from '../images/iris-screensaver.jpg';
|
||||
@@ -38,6 +39,14 @@ import {
|
||||
import type { View } from '../view/view.js';
|
||||
import { renderNotificationBlock } from './notification/block.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
// A private signal to the immediate parent that the media failed.
|
||||
// Non-bubbling.
|
||||
'advanced-camera-card:image-updating-player:error': CustomEvent<MediaUnavailableIssueReason>;
|
||||
}
|
||||
}
|
||||
|
||||
// See TOKEN_CHANGE_INTERVAL in https://github.com/home-assistant/core/blob/dev/homeassistant/components/camera/__init__.py .
|
||||
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -91,6 +100,10 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
@property({ attribute: false })
|
||||
public targetID?: string;
|
||||
|
||||
// The camera's title, shown in error messages to identify the camera.
|
||||
@property({ attribute: false })
|
||||
public cameraTitle?: string;
|
||||
|
||||
@property({ attribute: false, hasChanged: contentsChanged })
|
||||
public proxyConfig?: EnabledProxyConfig;
|
||||
|
||||
@@ -103,6 +116,10 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
@state()
|
||||
private _imageLoadError = false;
|
||||
|
||||
// Tracks the signed/proxy error so it is reported once on the transition into
|
||||
// failure, not on every update.
|
||||
private _hasSignError = false;
|
||||
|
||||
private _refImage: Ref<HTMLImageElement> = createRef();
|
||||
|
||||
private _cachedValueController = new CachedValueController(
|
||||
@@ -144,10 +161,19 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
|
||||
private _boundVisibilityHandler = this._visibilityHandler.bind(this);
|
||||
|
||||
private _mediaPlayerController = new UpdatingImageMediaPlayerController(
|
||||
// A poll-refreshed snapshot: the cached-value timer is the pausable update
|
||||
// loop, and its cached URL is the screenshot.
|
||||
private _mediaPlayerController = new ImageMediaPlayerController(
|
||||
this,
|
||||
() => this._refImage.value ?? null,
|
||||
() => this._cachedValueController,
|
||||
{
|
||||
updateControl: {
|
||||
start: () => this._cachedValueController.startTimer(),
|
||||
stop: () => this._cachedValueController.stopTimer(),
|
||||
isRunning: () => this._cachedValueController.hasTimer(),
|
||||
},
|
||||
screenshotProvider: async () => this._cachedValueController.getValue(),
|
||||
},
|
||||
);
|
||||
|
||||
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
|
||||
@@ -218,6 +244,19 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
if (!this._cachedValueController?.getValue()) {
|
||||
this._cachedValueController?.updateValue();
|
||||
}
|
||||
|
||||
const hasSignError = !!this._signedURLController.getError();
|
||||
if (hasSignError && !this._hasSignError) {
|
||||
this._dispatchError('server_error');
|
||||
}
|
||||
this._hasSignError = hasSignError;
|
||||
}
|
||||
|
||||
private _dispatchError(reason: MediaUnavailableIssueReason): void {
|
||||
fireAdvancedCameraCardEvent(this, 'image-updating-player:error', reason, {
|
||||
bubbles: false,
|
||||
composed: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -411,24 +450,16 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
private _getDisplayNotification(): Notification | null {
|
||||
const error = this._signedURLController.getError();
|
||||
if (error) {
|
||||
return {
|
||||
heading: {
|
||||
text: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
||||
icon: 'mdi:alert-circle',
|
||||
},
|
||||
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
|
||||
context: this.proxyConfig ? [this.proxyConfig] : undefined,
|
||||
};
|
||||
return createMediaNotification({
|
||||
title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
if (this._imageLoadError) {
|
||||
return {
|
||||
heading: {
|
||||
text: localize('error.image_load_error'),
|
||||
icon: 'mdi:alert-circle',
|
||||
},
|
||||
link: { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') },
|
||||
context: this.imageConfig ? [this.imageConfig] : undefined,
|
||||
};
|
||||
return createMediaNotification({
|
||||
title: localize('error.image_load_error'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -468,6 +499,11 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
this._forceSafeImage(true);
|
||||
} else if (mode === 'url') {
|
||||
this._imageLoadError = true;
|
||||
|
||||
// Report the failure to the parent. A live context marks the
|
||||
// stream not-live so its wrapper stops covering the error with a
|
||||
// loading overlay; the plain image view ignores it.
|
||||
this._dispatchError('not_loading');
|
||||
}
|
||||
if (this.targetID) {
|
||||
fireAdvancedCameraCardEvent<IssueTriggerEventData>(
|
||||
|
||||
@@ -275,7 +275,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
.microphoneStream=${microphoneStream}
|
||||
.camera=${resolvedCamera}
|
||||
.targetID=${cameraID}
|
||||
.label=${cameraMetadata?.title ?? ''}
|
||||
.cameraTitle=${cameraMetadata?.title}
|
||||
.liveConfig=${this.liveConfig}
|
||||
.hass=${this.hass}
|
||||
.stateWatcher=${this.stateWatcher}
|
||||
@@ -284,6 +284,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
||||
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
|
||||
.forceSelected=${isSelectedSlide}
|
||||
.locked=${this.locked}
|
||||
.suppressLoadingImage=${mediaEpoch > 0}
|
||||
@advanced-camera-card:zoom:change=${(
|
||||
ev: CustomEvent<ZoomSettingsObserved>,
|
||||
) =>
|
||||
|
||||
@@ -34,7 +34,7 @@ import { getResolvedLiveProvider } from '../../utils/live-provider.js';
|
||||
|
||||
import '../icon.js';
|
||||
|
||||
import { renderNotificationBlockFromText } from '../notification/block.js';
|
||||
import { renderMediaNotification } from '../notification/media.js';
|
||||
|
||||
import './../media-dimensions-container';
|
||||
|
||||
@@ -56,9 +56,10 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
@property({ attribute: false })
|
||||
public liveConfig?: LiveConfig;
|
||||
|
||||
// Label that is used for ARIA support and as tooltip.
|
||||
// The camera's title, used for ARIA support, as tooltip, and to identify the
|
||||
// camera in error messages.
|
||||
@property({ attribute: false })
|
||||
public label = '';
|
||||
public cameraTitle?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
@@ -85,6 +86,12 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
@property({ attribute: false })
|
||||
public locked?: boolean;
|
||||
|
||||
// When true, suppress the loading snapshot (show_image_during_load). Set on a
|
||||
// media reload after a failure so the snapshot doesn't flash back in on every
|
||||
// retry; a first load still shows it.
|
||||
@property({ attribute: false })
|
||||
public suppressLoadingImage = false;
|
||||
|
||||
private _mediaLoadedInfoSinkController = new MediaLoadedInfoSinkController(this, {
|
||||
getTargetID: () => this.targetID ?? null,
|
||||
});
|
||||
@@ -127,6 +134,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
*/
|
||||
private _shouldShowImageDuringLoading(): boolean {
|
||||
return (
|
||||
!this.suppressLoadingImage &&
|
||||
!this._mediaLoadedInfoSinkController.has() &&
|
||||
!!this.camera?.getConfig()?.camera_entity &&
|
||||
!!this.hass &&
|
||||
@@ -167,6 +175,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
this._importPromises.push(import('./providers/image.js'));
|
||||
} else if (provider === 'go2rtc') {
|
||||
this._importPromises.push(import('./providers/go2rtc/index.js'));
|
||||
} else if (provider === 'go2rtc-experimental') {
|
||||
this._importPromises.push(import('./providers/go2rtc-experimental/index.js'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,57 +249,70 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
// being initialized. This can cause spurious errors (e.g. lack of resolved
|
||||
// endpoints). Instead, simply never render uninitialized cameras.
|
||||
if (!this.camera.isInitialized()) {
|
||||
return renderNotificationBlockFromText(
|
||||
`${localize('error.awaiting_live')}${this.label ? `: ${this.label}` : ''}`,
|
||||
{ icon: 'mdi:progress-helper', in_progress: true },
|
||||
);
|
||||
return renderMediaNotification({
|
||||
icon: 'mdi:progress-helper',
|
||||
title: localize('error.awaiting_live'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
|
||||
// Set title and ariaLabel from the provided label property.
|
||||
this.title = this.label;
|
||||
this.ariaLabel = this.label;
|
||||
this.title = this.cameraTitle ?? '';
|
||||
this.ariaLabel = this.cameraTitle ?? '';
|
||||
|
||||
const provider = getResolvedLiveProvider(this.camera?.getConfig());
|
||||
|
||||
// `ha`/`image` cannot stream without a camera entity, so validate that
|
||||
// here. Entity *availability* (including the always_error immediate path)
|
||||
// is owned by the liveness controller's EntityAvailabilityDetector and
|
||||
// surfaces via getPlaceholder() below, for all providers.
|
||||
// surfaces via getFailure() below, for all providers.
|
||||
if (
|
||||
provider === 'ha' ||
|
||||
provider === 'image' ||
|
||||
(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
|
||||
) {
|
||||
if (!cameraConfig?.camera_entity) {
|
||||
return renderNotificationBlockFromText(localize('error.no_live_camera'), {
|
||||
return renderMediaNotification({
|
||||
icon: 'mdi:camera',
|
||||
context: cameraConfig,
|
||||
title: localize('error.configuration_error'),
|
||||
detail: localize('error.no_live_camera'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
if (!this.hass.states[cameraConfig.camera_entity]) {
|
||||
return renderNotificationBlockFromText(localize('error.live_camera_not_found'), {
|
||||
return renderMediaNotification({
|
||||
icon: 'mdi:camera',
|
||||
context: cameraConfig,
|
||||
title: localize('error.configuration_error'),
|
||||
detail: localize('error.live_camera_not_found'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const failure = this._streamLivenessController.getFailure();
|
||||
|
||||
// A detector reports the stream is silently lost (the camera entity is
|
||||
// unavailable, or the stream stalled): render a reconnecting placeholder,
|
||||
// which unmounts the provider and unloads it via the existing media-loaded
|
||||
// abort. The message names the specific cause.
|
||||
const placeholder = this._streamLivenessController.getPlaceholder();
|
||||
if (placeholder) {
|
||||
if (failure?.renderPlaceholder) {
|
||||
const { localizationKey: textKey, icon } =
|
||||
MEDIA_UNAVAILABLE_REASONS[placeholder.reason];
|
||||
return renderNotificationBlockFromText(
|
||||
`${localize(textKey)}${this.label ? `: ${this.label}` : ''}`,
|
||||
{ icon, in_progress: true },
|
||||
);
|
||||
MEDIA_UNAVAILABLE_REASONS[failure.reason];
|
||||
return renderMediaNotification({
|
||||
icon,
|
||||
title: localize(textKey),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
|
||||
const showImageDuringLoading = this._shouldShowImageDuringLoading();
|
||||
const showLoadingIcon = !this._mediaLoadedInfoSinkController.has();
|
||||
const mediaLoaded = this._mediaLoadedInfoSinkController.has();
|
||||
|
||||
// Loaded media or a snapshot gives the frame a size; mark the host `sized`
|
||||
// when one is present. In its absence CSS reserves an aspect ratio so the
|
||||
// frame (whose loading/error fill is absolutely positioned) doesn't
|
||||
// collapse.
|
||||
this.toggleAttribute('sized', mediaLoaded || showImageDuringLoading);
|
||||
|
||||
const classes = {
|
||||
hidden: showImageDuringLoading,
|
||||
@@ -302,6 +325,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
.hass=${this.hass}
|
||||
.camera=${this.camera}
|
||||
.targetID=${this.targetID}
|
||||
.cameraTitle=${this.cameraTitle}
|
||||
class=${classMap({
|
||||
...classes,
|
||||
// The image provider is providing the temporary loading image,
|
||||
@@ -340,42 +364,75 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
.hass=${this.hass}
|
||||
.camera=${this.camera}
|
||||
.targetID=${this.targetID}
|
||||
.cameraTitle=${this.cameraTitle}
|
||||
.microphoneStream=${this.microphoneStream}
|
||||
.microphoneConfig=${this.liveConfig.microphone}
|
||||
?controls=${this._getEffectiveBuiltinControls()}
|
||||
>
|
||||
</advanced-camera-card-live-go2rtc>`
|
||||
: provider === 'webrtc-card'
|
||||
? html`<advanced-camera-card-live-webrtc-card
|
||||
: provider === 'go2rtc-experimental'
|
||||
? html`<advanced-camera-card-live-go2rtc-experimental
|
||||
${ref(this._refProvider)}
|
||||
class=${classMap(classes)}
|
||||
.hass=${this.hass}
|
||||
.camera=${this.camera}
|
||||
.targetID=${this.targetID}
|
||||
.cameraTitle=${this.cameraTitle}
|
||||
.microphoneStream=${this.microphoneStream}
|
||||
.microphoneConfig=${this.liveConfig.microphone}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
?controls=${this._getEffectiveBuiltinControls()}
|
||||
>
|
||||
</advanced-camera-card-live-webrtc-card>`
|
||||
: provider === 'jsmpeg'
|
||||
? html` <advanced-camera-card-live-jsmpeg
|
||||
</advanced-camera-card-live-go2rtc-experimental>`
|
||||
: provider === 'webrtc-card'
|
||||
? html`<advanced-camera-card-live-webrtc-card
|
||||
${ref(this._refProvider)}
|
||||
class=${classMap(classes)}
|
||||
.hass=${this.hass}
|
||||
.camera=${this.camera}
|
||||
.targetID=${this.targetID}
|
||||
.cameraTitle=${this.cameraTitle}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
?controls=${this._getEffectiveBuiltinControls()}
|
||||
>
|
||||
</advanced-camera-card-live-jsmpeg>`
|
||||
: html``}
|
||||
</advanced-camera-card-live-webrtc-card>`
|
||||
: provider === 'jsmpeg'
|
||||
? html` <advanced-camera-card-live-jsmpeg
|
||||
${ref(this._refProvider)}
|
||||
class=${classMap(classes)}
|
||||
.hass=${this.hass}
|
||||
.camera=${this.camera}
|
||||
.targetID=${this.targetID}
|
||||
.cameraTitle=${this.cameraTitle}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
>
|
||||
</advanced-camera-card-live-jsmpeg>`
|
||||
: html``}
|
||||
`)}
|
||||
${showLoadingIcon
|
||||
? html`<advanced-camera-card-icon
|
||||
title=${localize('error.awaiting_live')}
|
||||
.icon=${{ icon: 'mdi:progress-helper' }}
|
||||
@click=${() =>
|
||||
fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_unavailable')}
|
||||
></advanced-camera-card-icon>`
|
||||
: ''}`;
|
||||
${failure || mediaLoaded ? '' : this._renderLoadingOverlay(showImageDuringLoading)}`;
|
||||
}
|
||||
|
||||
// The loading status drawn on top of the mounted provider while its media has
|
||||
// not loaded: a subtle corner spinner over a snapshot that is already filling
|
||||
// the frame, or a full "waiting for live" state. The cases that render nothing
|
||||
// (a failure, or media already loaded) are handled at the call site.
|
||||
private _renderLoadingOverlay(showImageDuringLoading: boolean): TemplateResult {
|
||||
if (showImageDuringLoading) {
|
||||
return html`<advanced-camera-card-icon
|
||||
title=${localize('error.awaiting_live')}
|
||||
.icon=${{ icon: 'mdi:progress-helper' }}
|
||||
@click=${() =>
|
||||
fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_unavailable')}
|
||||
></advanced-camera-card-icon>`;
|
||||
}
|
||||
|
||||
return html`<div class="fill">
|
||||
${renderMediaNotification({
|
||||
icon: 'mdi:progress-helper',
|
||||
title: localize('error.awaiting_live'),
|
||||
targetTitle: this.cameraTitle,
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
html,
|
||||
LitElement,
|
||||
unsafeCSS,
|
||||
type CSSResultGroup,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
||||
|
||||
import type { Camera } from '../../../../camera-manager/camera.js';
|
||||
import {
|
||||
MEDIA_UNAVAILABLE_REASONS,
|
||||
type MediaUnavailableIssueReason,
|
||||
} from '../../../../card-controller/issues/issues/media-unavailable.js';
|
||||
import { ImageSurfaceController } from '../../../../components-lib/live/providers/go2rtc-experimental/image-surface-controller.js';
|
||||
import {
|
||||
Go2RTCSessionController,
|
||||
type SessionSurfaces,
|
||||
type VideoSurface,
|
||||
} from '../../../../components-lib/live/providers/go2rtc-experimental/session-controller.js';
|
||||
import type { SurfaceKind } from '../../../../components-lib/live/providers/go2rtc-experimental/types.js';
|
||||
import { mapFailureReasonToIssueReason } from '../../../../components-lib/live/providers/go2rtc-experimental/utils/failure-reason.js';
|
||||
import { dispatchLiveErrorEvent } from '../../../../components-lib/live/utils/dispatch-live-error.js';
|
||||
import { MediaLoadedInfoSourceController } from '../../../../components-lib/media-loaded-info-source-controller.js';
|
||||
import { VideoMediaPlayerController } from '../../../../components-lib/media-player/video.js';
|
||||
import { SignedURLController } from '../../../../components-lib/signed-url-controller.js';
|
||||
import type { MicrophoneConfig } from '../../../../config/schema/live.js';
|
||||
import type { CardWideConfig } from '../../../../config/schema/types.js';
|
||||
import type { HomeAssistant } from '../../../../ha/types.js';
|
||||
import { localize } from '../../../../localize/localize.js';
|
||||
import liveGo2RTCExperimentalStyle from '../../../../scss/live-go2rtc-experimental.scss';
|
||||
import type { MediaPlayer, MediaPlayerController } from '../../../../types.js';
|
||||
import {
|
||||
dispatchMediaPauseEvent,
|
||||
dispatchMediaPlayEvent,
|
||||
dispatchMediaVolumeChangeEvent,
|
||||
} from '../../../../utils/media-info.js';
|
||||
import { renderMediaNotification } from '../../../notification/media.js';
|
||||
|
||||
@customElement('advanced-camera-card-live-go2rtc-experimental')
|
||||
export class AdvancedCameraCardGo2RTCExperimental
|
||||
extends LitElement
|
||||
implements MediaPlayer
|
||||
{
|
||||
// Not a reactive property to avoid resetting the video.
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public camera?: Camera;
|
||||
|
||||
// The BASE camera ID (camera property may be a substream)
|
||||
@property({ attribute: false })
|
||||
public targetID?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public microphoneStream?: MediaStream | null;
|
||||
|
||||
@property({ attribute: false })
|
||||
public microphoneConfig?: MicrophoneConfig;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
// The camera's title, shown in error messages to identify the camera.
|
||||
@property({ attribute: false })
|
||||
public cameraTitle?: string;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public controls = false;
|
||||
|
||||
private _hasLiveError = false;
|
||||
|
||||
// ===========================================================================
|
||||
// Surface: Video
|
||||
// ===========================================================================
|
||||
private _refVideo: Ref<HTMLVideoElement> = createRef();
|
||||
|
||||
private _videoMediaPlayerController = new VideoMediaPlayerController(
|
||||
this,
|
||||
() => this._refVideo.value ?? null,
|
||||
() => this.controls,
|
||||
);
|
||||
|
||||
private _videoSurface: VideoSurface = {
|
||||
getElement: () => this._refVideo.value ?? null,
|
||||
getMediaPlayer: () => this._videoMediaPlayerController,
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Surface: Image
|
||||
// ===========================================================================
|
||||
|
||||
private _refImage: Ref<HTMLImageElement> = createRef();
|
||||
|
||||
// A controller rather than a plain object (unlike the video surface): the
|
||||
// image surface owns state, the object-URL lifecycle -- each frame's
|
||||
// createObjectURL and revoking the previous one.
|
||||
private _imageSurface = new ImageSurfaceController(
|
||||
this,
|
||||
() => this._refImage.value ?? null,
|
||||
{
|
||||
livenessOptions: {
|
||||
isFrameExpected: () => true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// Surface Management
|
||||
// ===========================================================================
|
||||
|
||||
// The surface currently showing committed media, or null before anything has
|
||||
// committed (both surfaces hidden). Driven by the session's
|
||||
// surfaceCommittedCallback.
|
||||
@state()
|
||||
private _activeSurface: SurfaceKind | null = null;
|
||||
|
||||
@state()
|
||||
private _streamError: MediaUnavailableIssueReason | null = null;
|
||||
|
||||
// Built once and kept stable: the session compares this object by identity,
|
||||
// so handing it a new one will trigger a reconnect.
|
||||
private _surfaces: SessionSurfaces = {
|
||||
video: this._videoSurface,
|
||||
image: this._imageSurface,
|
||||
};
|
||||
|
||||
private _signedURLController = new SignedURLController(this, () => {
|
||||
const endpoint = this.camera?.getEndpoints()?.go2rtc;
|
||||
if (!this.hass || !endpoint) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
hass: this.hass,
|
||||
endpoint,
|
||||
proxyConfig: this.camera?.getLiveProxyConfig(),
|
||||
proxyEndpointOptions: { websocket: true },
|
||||
};
|
||||
});
|
||||
|
||||
private _mediaLoadedInfoSourceController = new MediaLoadedInfoSourceController(this, {
|
||||
getTargetID: () => this.targetID ?? null,
|
||||
});
|
||||
|
||||
private _session = new Go2RTCSessionController({
|
||||
getControls: () => this.controls,
|
||||
getCardWideConfig: () => this.cardWideConfig ?? null,
|
||||
mediaLoadedCallback: (info) => this._mediaLoadedInfoSourceController.set(info),
|
||||
|
||||
surfaceCommittedCallback: (surface) => {
|
||||
this._activeSurface = surface;
|
||||
|
||||
// A commit means the stream recovered: drop any prior error.
|
||||
this._streamError = null;
|
||||
},
|
||||
|
||||
// The session could not recover the stream on its own; surface it (with the
|
||||
// failure's user-facing cause) so the card's media-load retry (reconnecting
|
||||
// indicator, backoff, give-up) runs and can name why. The provider renders
|
||||
// the error itself (below); the event drives the liveness verdict + retry.
|
||||
errorCallback: (reason) => {
|
||||
this._streamError = mapFailureReasonToIssueReason(reason);
|
||||
dispatchLiveErrorEvent(this, this._streamError);
|
||||
},
|
||||
});
|
||||
|
||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||
return this._activeSurface === 'image'
|
||||
? this._imageSurface.getMediaPlayer()
|
||||
: this._videoMediaPlayerController;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
// Re-render (and thus re-establish the session) when reconnected to the
|
||||
// DOM. https://github.com/dermotduffy/advanced-camera-card/issues/996
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
// Tear down synchronously so streams (e.g. 2-way audio backchannels)
|
||||
// release immediately.
|
||||
this._session.reset();
|
||||
this._activeSurface = null;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('camera')) {
|
||||
// The session is re-established by `updated()` once the new camera's
|
||||
// signed URL resolves; the next commit picks the live surface. Blank the
|
||||
// view meanwhile so the previous camera's last frame is not shown.
|
||||
this._session.reset();
|
||||
this._activeSurface = null;
|
||||
this._streamError = null;
|
||||
}
|
||||
|
||||
// Only treat a missing go2rtc endpoint as an error after the camera's
|
||||
// endpoints have been explicitly set (not undefined / still loading).
|
||||
const endpoints = this.camera?.getEndpoints();
|
||||
const hasLiveError =
|
||||
!!this._signedURLController.getError() || (!!endpoints && !endpoints.go2rtc);
|
||||
|
||||
if (hasLiveError && !this._hasLiveError) {
|
||||
dispatchLiveErrorEvent(this);
|
||||
}
|
||||
this._hasLiveError = hasLiveError;
|
||||
|
||||
if (changedProps.has('controls')) {
|
||||
// Only the video surface has native controls; the image surface has none.
|
||||
this._videoMediaPlayerController.setControls(this.controls).catch(() => {});
|
||||
}
|
||||
|
||||
if (changedProps.has('microphoneStream')) {
|
||||
// The WebRTC lane swaps the outbound track in place; no visible reload.
|
||||
this._session.setMicrophoneStream(this.microphoneStream ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(): void {
|
||||
const url = this._signedURLController.getValue();
|
||||
if (url) {
|
||||
this._session.connect(
|
||||
url,
|
||||
this._surfaces,
|
||||
this.camera?.getConfig()?.go2rtc?.modes,
|
||||
);
|
||||
} else {
|
||||
// No usable URL: a signing/proxy error, or no go2rtc endpoint (which
|
||||
// includes endpoints still loading). The render omits the surfaces, so
|
||||
// drop the session -- otherwise a later URL, even an identical unsigned
|
||||
// endpoint, would be skipped by connect()'s identity check and leave the
|
||||
// session bound to the removed elements.
|
||||
this._session.reset();
|
||||
this._activeSurface = null;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const error = this._signedURLController.getError();
|
||||
if (error) {
|
||||
return renderMediaNotification({
|
||||
title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
if (!this.camera?.getEndpoints()?.go2rtc) {
|
||||
return renderMediaNotification({
|
||||
title: localize('error.configuration_error'),
|
||||
detail: localize('error.live_camera_no_endpoint'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
if (this._streamError) {
|
||||
// A stream-level failure the session gave up on: the provider must render
|
||||
// its own error (marked in-progress, since the card keeps retrying).
|
||||
return renderMediaNotification({
|
||||
icon: MEDIA_UNAVAILABLE_REASONS[this._streamError].icon,
|
||||
title: localize(MEDIA_UNAVAILABLE_REASONS[this._streamError].localizationKey),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
|
||||
// Both image and video surfaces are always rendered; only the committed one
|
||||
// is shown (the other, and both before anything commits, are hidden). MSE
|
||||
// and WebRTC play on the <video>; MP4 and MJPEG show frames on the <img>.
|
||||
//
|
||||
// Muted is bound as a property: Chrome ignores the `muted` content
|
||||
// attribute on videos instantiated from cloned templates (as Lit does), so
|
||||
// an attribute would not actually start the video muted. Media may be
|
||||
// unmuted later in accordance with user configuration.
|
||||
return html`
|
||||
<video
|
||||
${ref(this._refVideo)}
|
||||
.muted=${true}
|
||||
?hidden=${this._activeSurface !== 'video'}
|
||||
playsinline
|
||||
preload="auto"
|
||||
@play=${() => dispatchMediaPlayEvent(this)}
|
||||
@pause=${() => dispatchMediaPauseEvent(this)}
|
||||
@volumechange=${() => dispatchMediaVolumeChangeEvent(this)}
|
||||
></video>
|
||||
<img ${ref(this._refImage)} ?hidden=${this._activeSurface !== 'image'} alt="" />
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return unsafeCSS(liveGo2RTCExperimentalStyle);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'advanced-camera-card-live-go2rtc-experimental': AdvancedCameraCardGo2RTCExperimental;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import type { HomeAssistant } from '../../../../ha/types.js';
|
||||
import { localize } from '../../../../localize/localize.js';
|
||||
import liveGo2RTCStyle from '../../../../scss/live-go2rtc.scss';
|
||||
import type { MediaPlayer, MediaPlayerController } from '../../../../types.js';
|
||||
import { renderNotificationBlockFromText } from '../../../notification/block.js';
|
||||
import { renderMediaNotification } from '../../../notification/media.js';
|
||||
import { VideoRTC } from './video-rtc.js';
|
||||
|
||||
customElements.define('advanced-camera-card-live-go2rtc-player', VideoRTC);
|
||||
@@ -40,6 +40,10 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
||||
@property({ attribute: false })
|
||||
public microphoneConfig?: MicrophoneConfig;
|
||||
|
||||
// The camera's title, shown in error messages to identify the camera.
|
||||
@property({ attribute: false })
|
||||
public cameraTitle?: string;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public controls = false;
|
||||
|
||||
@@ -148,14 +152,16 @@ export class AdvancedCameraCardGo2RTC extends LitElement implements MediaPlayer
|
||||
protected render(): TemplateResult | void {
|
||||
const error = this._signedURLController.getError();
|
||||
if (error) {
|
||||
return renderNotificationBlockFromText(
|
||||
localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
||||
{ context: this.camera?.getConfig() },
|
||||
);
|
||||
return renderMediaNotification({
|
||||
title: localize(error === 'proxy' ? 'error.failed_proxy' : 'error.failed_sign'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
if (!this.camera?.getEndpoints()?.go2rtc) {
|
||||
return renderNotificationBlockFromText(localize('error.live_camera_no_endpoint'), {
|
||||
context: this.camera?.getConfig(),
|
||||
return renderMediaNotification({
|
||||
title: localize('error.configuration_error'),
|
||||
detail: localize('error.live_camera_no_endpoint'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
return html`${this._player}`;
|
||||
|
||||
@@ -9,6 +9,8 @@ import { customElement, property } from 'lit/decorators.js';
|
||||
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
||||
|
||||
import type { Camera } from '../../../camera-manager/camera.js';
|
||||
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable.js';
|
||||
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
|
||||
import type { HomeAssistant } from '../../../ha/types';
|
||||
import basicBlockStyle from '../../../scss/basic-block.scss';
|
||||
import type {
|
||||
@@ -31,6 +33,10 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
|
||||
@property({ attribute: false })
|
||||
public targetID?: string;
|
||||
|
||||
// The camera's title, shown in error messages to identify the camera.
|
||||
@property({ attribute: false })
|
||||
public cameraTitle?: string;
|
||||
|
||||
private _refImage: Ref<MediaPlayerElement> = createRef();
|
||||
|
||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||
@@ -51,7 +57,11 @@ export class AdvancedCameraCardLiveImage extends LitElement implements MediaPlay
|
||||
.imageConfig=${cameraConfig.image}
|
||||
.cameraConfig=${cameraConfig}
|
||||
.targetID=${this.targetID}
|
||||
.cameraTitle=${this.cameraTitle}
|
||||
.proxyConfig=${this.camera?.getLiveProxyConfig()}
|
||||
@advanced-camera-card:image-updating-player:error=${(
|
||||
ev: CustomEvent<MediaUnavailableIssueReason>,
|
||||
) => dispatchLiveErrorEvent(this, ev.detail)}
|
||||
>
|
||||
</advanced-camera-card-image-updating-player>
|
||||
`;
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { Camera } from '../../../camera-manager/camera.js';
|
||||
import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispatch-live-error.js';
|
||||
import { MediaLoadedInfoSourceController } from '../../../components-lib/media-loaded-info-source-controller.js';
|
||||
import { JSMPEGMediaPlayerController } from '../../../components-lib/media-player/jsmpeg.js';
|
||||
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
|
||||
import { createMediaNotification } from '../../../components-lib/notification/media.js';
|
||||
import type { Notification } from '../../../config/schema/actions/types.js';
|
||||
import type { CardWideConfig } from '../../../config/schema/types.js';
|
||||
import { homeAssistantGetSignedURLIfNecessary } from '../../../ha/sign-path.js';
|
||||
@@ -22,13 +22,14 @@ import type { HomeAssistant } from '../../../ha/types.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import liveJSMPEGStyle from '../../../scss/live-jsmpeg.scss';
|
||||
import type { MediaPlayer, MediaPlayerController } from '../../../types.js';
|
||||
import { convertHTTPAdressToWebsocket, errorToConsole } from '../../../utils/basic.js';
|
||||
import { errorToConsole } from '../../../utils/basic.js';
|
||||
import {
|
||||
createMediaLoadedInfo,
|
||||
dispatchMediaPauseEvent,
|
||||
dispatchMediaPlayEvent,
|
||||
} from '../../../utils/media-info.js';
|
||||
import { Timer } from '../../../utils/timer.js';
|
||||
import { convertToWebSocketURL } from '../../../utils/websocket-url.js';
|
||||
|
||||
import '../../notification/block.js';
|
||||
|
||||
@@ -58,6 +59,10 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
// The camera's title, shown in error messages to identify the camera.
|
||||
@property({ attribute: false })
|
||||
public cameraTitle?: string;
|
||||
|
||||
@state()
|
||||
private _notification: Notification | null = null;
|
||||
|
||||
@@ -193,10 +198,11 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
||||
|
||||
const endpoint = this.camera?.getEndpoints()?.jsmpeg;
|
||||
if (!endpoint) {
|
||||
this._notification = createNotificationFromText(
|
||||
localize('error.live_camera_no_endpoint'),
|
||||
{ context: this.camera?.getConfig() },
|
||||
);
|
||||
this._notification = createMediaNotification({
|
||||
title: localize('error.configuration_error'),
|
||||
detail: localize('error.live_camera_no_endpoint'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
dispatchLiveErrorEvent(this);
|
||||
return;
|
||||
}
|
||||
@@ -211,11 +217,12 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
||||
} catch (e) {
|
||||
errorToConsole(e);
|
||||
}
|
||||
const address = response ? convertHTTPAdressToWebsocket(response) : null;
|
||||
const address = response ? convertToWebSocketURL(response) : null;
|
||||
|
||||
if (!address) {
|
||||
this._notification = createNotificationFromText(localize('error.failed_sign'), {
|
||||
context: this.camera?.getConfig(),
|
||||
this._notification = createMediaNotification({
|
||||
title: localize('error.failed_sign'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
dispatchLiveErrorEvent(this);
|
||||
return;
|
||||
@@ -238,10 +245,10 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
||||
|
||||
if (!this._jsmpegVideoPlayer || !this._jsmpegCanvasElement) {
|
||||
if (!this._notification) {
|
||||
this._notification = createNotificationFromText(
|
||||
localize('error.jsmpeg_no_player'),
|
||||
{ context: this.camera?.getConfig() },
|
||||
);
|
||||
this._notification = createMediaNotification({
|
||||
title: localize('error.jsmpeg_no_player'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
dispatchLiveErrorEvent(this);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -14,24 +14,19 @@ import { dispatchLiveErrorEvent } from '../../../components-lib/live/utils/dispa
|
||||
import { getTechnologyForVideoRTC } from '../../../components-lib/live/utils/get-technology-for-video-rtc.js';
|
||||
import { MediaLoadedInfoSourceController } from '../../../components-lib/media-loaded-info-source-controller.js';
|
||||
import { VideoMediaPlayerController } from '../../../components-lib/media-player/video.js';
|
||||
import { createNotificationFromText } from '../../../components-lib/notification/factory.js';
|
||||
import { createMediaNotification } from '../../../components-lib/notification/media.js';
|
||||
import type { Notification } from '../../../config/schema/actions/types.js';
|
||||
import type { CardWideConfig } from '../../../config/schema/types.js';
|
||||
import type { HomeAssistant } from '../../../ha/types.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import liveWebRTCCardStyle from '../../../scss/live-webrtc-card.scss';
|
||||
import {
|
||||
AdvancedCameraCardError,
|
||||
type MediaPlayer,
|
||||
type MediaPlayerController,
|
||||
} from '../../../types.js';
|
||||
import type { MediaPlayer, MediaPlayerController } from '../../../types.js';
|
||||
import { mayHaveAudio } from '../../../utils/audio.js';
|
||||
import {
|
||||
hideMediaControlsTemporarily,
|
||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||
setControlsOnVideo,
|
||||
} from '../../../utils/controls.js';
|
||||
import { getContextFromError } from '../../../utils/error-context.js';
|
||||
import {
|
||||
createMediaLoadedInfo,
|
||||
dispatchMediaPauseEvent,
|
||||
@@ -63,6 +58,10 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
// The camera's title, shown in error messages to identify the camera.
|
||||
@property({ attribute: false })
|
||||
public cameraTitle?: string;
|
||||
|
||||
@property({ attribute: true, type: Boolean })
|
||||
public controls = false;
|
||||
|
||||
@@ -169,15 +168,11 @@ export class AdvancedCameraCardLiveWebRTCCard extends LitElement implements Medi
|
||||
try {
|
||||
webrtcElement = this._createWebRTC();
|
||||
} catch (e) {
|
||||
const context = getContextFromError(e);
|
||||
this._notification = createNotificationFromText(
|
||||
e instanceof AdvancedCameraCardError
|
||||
? e.message
|
||||
: localize('error.webrtc_card_reported_error') + ': ' + (e as Error).message,
|
||||
{
|
||||
...(context && { context }),
|
||||
},
|
||||
);
|
||||
this._notification = createMediaNotification({
|
||||
title: localize('error.webrtc_card_reported_error'),
|
||||
detail: e instanceof Error ? e.message : String(e),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
dispatchLiveErrorEvent(this);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { CameraManager } from '../../camera-manager/manager.js';
|
||||
import {
|
||||
createMediaNotification,
|
||||
type MediaNotificationOptions,
|
||||
} from '../../components-lib/notification/media.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import { renderNotificationBlock } from './block.js';
|
||||
|
||||
// Render the standard media notification block: a short titled heading (with
|
||||
// the camera name when there is one), an optional longer detail, a
|
||||
// troubleshooting link, and a retry spinner. See `createMediaNotification`.
|
||||
export function renderMediaNotification(
|
||||
options: MediaNotificationOptions,
|
||||
): TemplateResult {
|
||||
return renderNotificationBlock(createMediaNotification(options));
|
||||
}
|
||||
|
||||
interface NoMediaOptions {
|
||||
cameraID: string | null;
|
||||
inProgress?: boolean;
|
||||
}
|
||||
|
||||
// The viewer/gallery no-media (or awaiting-media) state.
|
||||
export function renderNoMediaNotification(
|
||||
options: NoMediaOptions,
|
||||
cameraManager?: CameraManager,
|
||||
): TemplateResult {
|
||||
const cameraID =
|
||||
options.cameraID ?? cameraManager?.getStore().getDefaultCameraID() ?? null;
|
||||
const targetTitle = cameraID
|
||||
? cameraManager?.getCameraMetadata(cameraID)?.title ?? cameraID
|
||||
: undefined;
|
||||
|
||||
return renderMediaNotification({
|
||||
title: localize(options.inProgress ? 'error.awaiting_media' : 'common.no_media'),
|
||||
icon: 'mdi:multimedia',
|
||||
targetTitle,
|
||||
inProgress: !!options.inProgress,
|
||||
troubleshooting: false,
|
||||
});
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import type { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import { renderNotificationBlock } from './block.js';
|
||||
|
||||
interface NoMediaOptions {
|
||||
cameraID: string | null;
|
||||
cameraManager: CameraManager | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function renderNoMedia(options: NoMediaOptions): TemplateResult {
|
||||
const cameraID =
|
||||
options.cameraID ?? options.cameraManager?.getStore().getDefaultCameraID() ?? null;
|
||||
const cameraTitle = cameraID
|
||||
? options.cameraManager?.getCameraMetadata(cameraID)?.title ?? cameraID
|
||||
: null;
|
||||
|
||||
return renderNotificationBlock({
|
||||
heading: {
|
||||
text: options.loading
|
||||
? localize('error.awaiting_media')
|
||||
: localize('common.no_media'),
|
||||
icon: 'mdi:multimedia',
|
||||
},
|
||||
in_progress: options.loading,
|
||||
...(cameraTitle && {
|
||||
metadata: [{ text: cameraTitle, icon: 'mdi:cctv' }],
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import type { ViewMedia } from '../../view/item.js';
|
||||
import '../carousel';
|
||||
import '../next-prev-control.js';
|
||||
|
||||
import { renderNoMedia } from '../notification/no-media.js';
|
||||
import { renderNoMediaNotification } from '../notification/media.js';
|
||||
|
||||
import '../ptz.js';
|
||||
import './provider.js';
|
||||
@@ -321,13 +321,15 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
protected render(): TemplateResult | void {
|
||||
const mediaCount = this._media?.length ?? 0;
|
||||
if (!this._media || !mediaCount) {
|
||||
return renderNoMedia({
|
||||
cameraID:
|
||||
this.viewFilterCameraID ??
|
||||
this.viewManagerEpoch?.manager.getView()?.camera ??
|
||||
null,
|
||||
cameraManager: this.cameraManager ?? null,
|
||||
});
|
||||
return renderNoMediaNotification(
|
||||
{
|
||||
cameraID:
|
||||
this.viewFilterCameraID ??
|
||||
this.viewManagerEpoch?.manager.getView()?.camera ??
|
||||
null,
|
||||
},
|
||||
this.cameraManager,
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.hass || !this.cameraManager || this._selected === null) {
|
||||
@@ -443,17 +445,17 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
|
||||
const seekTimeInMedia = selectedMedia.includesTime(seek);
|
||||
this.toggleAttribute('unseekable', !seekTimeInMedia);
|
||||
if (!seekTimeInMedia && !mediaPlayerController.isPaused()) {
|
||||
void mediaPlayerController.pause();
|
||||
} else if (seekTimeInMedia && mediaPlayerController.isPaused()) {
|
||||
void mediaPlayerController.play();
|
||||
if (!seekTimeInMedia && !mediaPlayerController.playback?.isPaused()) {
|
||||
void mediaPlayerController.playback?.pause();
|
||||
} else if (seekTimeInMedia && mediaPlayerController.playback?.isPaused()) {
|
||||
void mediaPlayerController.playback?.play();
|
||||
}
|
||||
|
||||
const seekTime =
|
||||
(await this.cameraManager?.getMediaSeekTime(selectedMedia, seek)) ?? null;
|
||||
|
||||
if (seekTime !== null) {
|
||||
void mediaPlayerController.seek(seekTime);
|
||||
void mediaPlayerController.seek?.(seekTime);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import '../../patches/ha-hls-player.js';
|
||||
|
||||
import viewerStyle from '../../scss/viewer.scss';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||
import { renderNoMedia } from '../notification/no-media.js';
|
||||
import { renderNoMediaNotification } from '../notification/media.js';
|
||||
|
||||
import './grid';
|
||||
|
||||
@@ -84,11 +84,13 @@ export class AdvancedCameraCardViewer extends LitElement {
|
||||
// Directly render an error message (instead of dispatching it upwards)
|
||||
// to preserve the mini-timeline if the user pans into an area with no
|
||||
// media.
|
||||
return renderNoMedia({
|
||||
cameraID: this.viewManagerEpoch.manager.getView()?.camera ?? null,
|
||||
cameraManager: this.cameraManager ?? null,
|
||||
loading: !!this.viewManagerEpoch.manager.getView()?.context?.loading?.query,
|
||||
});
|
||||
return renderNoMediaNotification(
|
||||
{
|
||||
cameraID: this.viewManagerEpoch.manager.getView()?.camera ?? null,
|
||||
inProgress: !!this.viewManagerEpoch.manager.getView()?.context?.loading?.query,
|
||||
},
|
||||
this.cameraManager,
|
||||
);
|
||||
}
|
||||
|
||||
return html` <advanced-camera-card-viewer-grid
|
||||
|
||||
Reference in New Issue
Block a user