fix: Report and retry media failures for every camera in a grid (#2658)

- Closes: #2637
 - Related: #2099
This commit is contained in:
Dermot Duffy
2026-08-05 21:35:47 -07:00
committed by GitHub
parent 9f88aacefe
commit c137f4c00c
31 changed files with 1719 additions and 1000 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ export const createIssueManager = (
manager.addIssue(new InitializationIssue(api));
manager.addIssue(new LegacyResourceIssue(changeCallback));
manager.addIssue(new MediaQueryIssue(api));
manager.addIssue(new MediaUnavailableIssue(api, changeCallback));
manager.addIssue(new MediaUnavailableIssue(api));
return manager;
};
@@ -1,16 +1,13 @@
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
import type {
Notification,
NotificationDetail,
} from '../../../config/schema/actions/types.js';
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
import { localize } from '../../../localize/localize.js';
import type { UnsubscribeCallback } from '../../../types.js';
import { Timer } from '../../../utils/timer.js';
import { getDisplayedTargetIDs } from '../../../view/layout.js';
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js';
import { isAnyMediaViewName } from '../../../view/view.js';
import type { CardIssueManagerAPI } from '../../types.js';
import { createRetryControl } from '../retry-control.js';
import type { Issue, IssueDescription } from '../types.js';
@@ -40,6 +37,9 @@ declare module 'issue' {
interface IssueResolveContext {
media_unavailable: {
targetID: string;
// Optionally limits the clearing to one kind of failure.
reason?: MediaUnavailableIssueReason;
};
}
}
@@ -50,8 +50,6 @@ interface TargetError {
description?: string;
}
export const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
// The per-cause presentation (localization key + icon), shared by the
// notification metadata and the reconnecting placeholder so each cause is
// described in exactly one place.
@@ -85,33 +83,20 @@ export const MEDIA_UNAVAILABLE_REASONS: Record<
},
};
// Reports media failures for the targets the user can currently see. Failures
// are raised and cleared by the components observing the media (e.g. providers)
// -- player errors and stalls via the liveness detectors, and a load that never
// arrives via each media host's load watchdog. This issue scopes them to the
// displayed targets and drives the throttled reload that retries them.
export class MediaUnavailableIssue implements Issue {
public readonly key = 'media_unavailable' as const;
private _issueActive = false;
private _erroredTargets = new Map<string, TargetError>();
// Timer fires when a target has been loading too long without success.
private _timer = new Timer();
private _timerTargetID: string | null = null;
private _api: CardIssueManagerAPI;
private _onChange: (() => void) | null;
private _unsubscribeCallback: UnsubscribeCallback;
constructor(api: CardIssueManagerAPI, onChange?: () => void) {
constructor(api: CardIssueManagerAPI) {
this._api = api;
this._onChange = onChange ?? null;
// React to a target's media loading; unload / select changes are
// irrelevant here.
this._unsubscribeCallback = this._api
.getMediaLoadedInfoManager()
.subscribe((change) => {
if (change.type === 'load') {
this._onMediaLoad(change.targetID);
}
});
}
// =========================================================================
@@ -126,67 +111,29 @@ export class MediaUnavailableIssue implements Issue {
});
}
// A target is proven to be delivering media again. Stronger evidence than a
// media load, which only says a player attached, so it clears any recorded
// error.
public resolve(context: IssueResolveContext['media_unavailable']): void {
const error = this._erroredTargets.get(context.targetID);
if (!error || (context.reason && context.reason !== error.reason)) {
return;
}
this._erroredTargets.delete(context.targetID);
this._cancelPendingTimer(context.targetID);
}
// =========================================================================
// Detection -- called by the manager on every state change.
// =========================================================================
public detectDynamic(state: ConditionState): void {
if (!isAnyMediaViewName(state.view)) {
this._deactivate();
return;
}
// A known error for the current target activates immediately, even if its
// (frozen) media still reads as loaded (it might be loaded but then
// reported a playback error that stops playback but leaves the player
// attached). Errors are cleared out-of-band, by `resolve` or by
// `_onMediaLoad`.
if (this._hasError(state)) {
this._activate();
return;
}
if (state.mediaLoadedInfo) {
// Loaded with no known error: healthy.
this._deactivate();
return;
}
this._handlePendingLoad(state);
}
// A load proves media attached for the target. That ends any wait on it, and
// refutes a `not_loading` error. It is no evidence of recovery for any other
// reason, so those clear only via `resolve`.
private _onMediaLoad(targetID: string): void {
let changed = this._cancelPendingTimer(targetID);
if (this._erroredTargets.get(targetID)?.reason === 'not_loading') {
this._erroredTargets.delete(targetID);
changed = true;
}
if (changed) {
this._onChange?.();
}
}
// =========================================================================
// State queries -- called by the manager to read current state.
// =========================================================================
// Reported exactly when a target on screen has a known failure, even if its
// (frozen) media still reads as loaded (it might be loaded but then reported
// a playback error that stops playback but leaves the player attached).
// Failures are cleared out-of-band, by `resolve`.
public hasIssue(): boolean {
return this._issueActive;
return !!this._getDisplayedErrors().size;
}
public getIssue(): IssueDescription | null {
if (!this._issueActive) {
if (!this.hasIssue()) {
return null;
}
return {
@@ -197,20 +144,7 @@ export class MediaUnavailableIssue implements Issue {
}
public getNotification(): Notification {
const targets = new Map(this._erroredTargets);
// The pending-load timer's target is a slow initial load that has not yet
// errored. Gate on the timer still running: once it is stopped (a hard error
// on another target took over, or the view moved on), _timerTargetID lingers
// and would otherwise paint a stale "not loading" line for a target that has
// since loaded.
if (
this._timerTargetID &&
this._timer.isRunning() &&
!targets.has(this._timerTargetID)
) {
targets.set(this._timerTargetID, { reason: 'not_loading' });
}
const targets = this._getDisplayedErrors();
// The free-text causes go in the context block rather than on the metadata
// lines, which stay short enough to scan when several cameras fail at once.
@@ -269,20 +203,11 @@ export class MediaUnavailableIssue implements Issue {
// =========================================================================
public needsRetry(): boolean {
return this._issueActive;
return this.hasIssue();
}
public retry(): boolean {
// Build the set of targets to retry: all errored targets plus the
// target the pending timer is tracking (so a user-initiated retry
// works even before the timeout fires). A stopped timer leaves
// _timerTargetID behind, so gate on it still running: that target may
// since have loaded.
const retryTargets = new Set(this._erroredTargets.keys());
if (this._timerTargetID && this._timer.isRunning()) {
retryTargets.add(this._timerTargetID);
}
const retryTargets = this._getDisplayedErrors();
if (!retryTargets.size) {
return false;
}
@@ -291,16 +216,15 @@ export class MediaUnavailableIssue implements Issue {
// only way to rebuild a stream from scratch.
const view = this._api.getViewManager().getView();
const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) };
for (const id of retryTargets) {
for (const id of retryTargets.keys()) {
mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1;
}
// Intentionally keep _issueActive, _erroredTargets, and the pending
// timer in place. The issue stays visible while the provider re-attempts
// loading underneath. If the retry succeeds, the fresh load clears a
// not-loading error and the rebuilt provider's liveness observation
// resolves a stream error. If it fails silently (e.g. bogus stream name),
// the error stays visible immediately -- no new 10s grace period.
// Intentionally keep _erroredTargets in place. The issue stays visible
// while the provider re-attempts loading underneath. If the retry succeeds,
// the fresh load clears a not-loading error and the rebuilt provider's
// liveness observation resolves a stream error. If it fails silently (e.g.
// bogus stream name), the error stays visible immediately.
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
return false;
}
@@ -310,83 +234,27 @@ export class MediaUnavailableIssue implements Issue {
// =========================================================================
public reset(): void {
this._deactivate();
this._erroredTargets.clear();
}
// Stop reacting to media loads at end of life.
public destroy(): void {
this._unsubscribeCallback();
}
// Stop the pending-load timer so offscreen time doesn't count toward the
// 10s threshold. Preserve _issueActive, _erroredTargets, and
// _timerTargetID: already-visible errors remain visible on reattach, and
// retaining _timerTargetID lets the existing active/target-mismatch guard
// in _handlePendingLoad avoid spuriously deactivating the preserved
// issue when the same target is still loading on resume. The timer is
// re-armed with a fresh window by the next detectDynamic pass.
public suspend(): void {
this._timer.stop();
}
// =========================================================================
// Private helpers.
// =========================================================================
// Stop waiting on a target's load, if it is the one being waited on. Returns
// whether it was.
private _cancelPendingTimer(targetID: string): boolean {
if (this._timerTargetID !== targetID) {
return false;
}
this._timer.stop();
this._timerTargetID = null;
return true;
}
// Media not yet loaded and no known error: start (or keep) a timeout to catch
// a slow or failed initial load. No targetID means no provider is rendering
// media (e.g. the viewer shows "No media to display"), so there's nothing to
// wait for.
private _handlePendingLoad(state: ConditionState): void {
if (!state.targetID) {
this._deactivate();
return;
// The errored targets the user can currently see. An error recorded for a
// target that has since left the screen names something they cannot look at,
// and reloading it would achieve nothing. Read fresh rather than remembered:
// a change in conditions can re-evaluate an override that replaces the
// configured cameras, leaving the view exactly as it was.
private _getDisplayedErrors(): Map<string, TargetError> {
const view = this._api.getViewManager().getView();
if (!view) {
return new Map();
}
const targetID = state.targetID;
// When the target changes, clear the active state so the new target gets
// its own timeout window instead of inheriting the previous target's.
if (this._issueActive && this._timerTargetID !== targetID) {
this._deactivate();
}
// Start (or restart) the timer for this target.
if (!this._timer.isRunning() || this._timerTargetID !== targetID) {
this._timerTargetID = targetID;
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
// Record the error on timeout so retry() knows which epoch to bump.
this.trigger({ targetID, reason: 'not_loading' });
this._activate();
this._onChange?.();
});
}
}
private _hasError(state: ConditionState): boolean {
return !!state.targetID && this._erroredTargets.has(state.targetID);
}
private _activate(): void {
this._timer.stop();
this._issueActive = true;
}
private _deactivate(): void {
this._timer.stop();
this._timerTargetID = null;
this._issueActive = false;
const displayedTargetIDs = getDisplayedTargetIDs(view, this._api.getCameraManager());
return new Map(
[...this._erroredTargets].filter(([targetID]) => displayedTargetIDs.has(targetID)),
);
}
}
@@ -0,0 +1,219 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import type {
IssueResolveEventData,
IssueTriggerEventData,
} from '../card-controller/issues/types';
import type { MediaLoadedInfoEventDetail } from '../types';
import { onAbort } from '../utils/abort-signal';
import { Generation } from '../utils/concurrency/generation';
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
import { Timer } from '../utils/timer';
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
export const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
interface MediaLoadWatchdogConfig {
getTargetID: () => string | null;
// Whether the host is currently trying to load media. False when loading is
// held back (e.g. lazy loading), or when the host is showing a failure of its
// own rather than waiting for media.
isLoadExpected: () => boolean;
// Changes when the media underneath is rebuilt without the host itself being
// rebuilt (e.g. a retry re-keying a player below it), so the wait starts
// again. Omitted by a host that a retry replaces outright.
getAttemptID?: () => unknown;
}
/**
* Watches a media host's load: when the host expects media but none arrives
* within the window, raises a `media_unavailable` issue with reason
* `not_loading`, and clears it again once media does arrive.
*
* Loads are observed from the host's own bubble path, so a `media:loaded`
* dispatched by any descendant player ends the wait, and that media going away
* (its event's abort signal) restarts it if a load is still expected.
*
* A host may be reused for a succession of targets (a viewer slide swiped from
* one media item to the next), so everything learned is scoped to the target it
* was learned about. A target change starts a fresh wait and resolves any
* failure reported for the target left behind.
*/
export class MediaLoadWatchdogController implements ReactiveController {
private _host: ReactiveControllerHost & HTMLElement;
private _config: MediaLoadWatchdogConfig;
private _timer = new Timer();
// What the state below describes: the target, and which attempt at loading it
// (see `getAttemptID`). A change in either means everything known is about
// media the host has moved on from.
private _targetID: string | null = null;
private _attemptID: unknown = undefined;
private _mediaLoaded = false;
// Identifies which load `_mediaLoaded` describes. One player can replace
// another for the same target before the first has gone away, and the first
// going away must not be read as the current media being lost.
private _loadGeneration = new Generation();
// Whether the timeout has fired for the current attempt, so a hung load is
// reported only once. A retry is a new attempt and is waited on afresh.
private _fired = false;
// The target this watchdog has an outstanding failure reported for, so that
// failure can be resolved if the host moves on to another target. It
// outlives the attempt that reported it, which keeps the failure on screen
// while a retry runs underneath.
private _reportedTargetID: string | null = null;
// Media loads, and the media going away, are observed through listeners and
// abort signals that can outlive a disconnect, and a detached host must not be
// waited on.
private _connected = false;
constructor(
host: ReactiveControllerHost & HTMLElement,
config: MediaLoadWatchdogConfig,
) {
this._host = host;
this._config = config;
host.addController(this);
}
public hostConnected(): void {
this._connected = true;
this._host.addEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
this._evaluate();
}
public hostDisconnected(): void {
this._connected = false;
this._host.removeEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
this._timer.stop();
}
public hostUpdated(): void {
this._evaluate();
}
private _forgetLoad(): void {
this._mediaLoaded = false;
this._fired = false;
this._loadGeneration.invalidate();
this._timer.stop();
}
private _onMediaLoaded = (ev: CustomEvent<MediaLoadedInfoEventDetail>): void => {
const targetID = ev.detail.info.targetID;
// The host's own target is read fresh so a load arriving as the host
// switches is attributed to whichever target it actually describes.
if (!targetID || targetID !== this._config.getTargetID()) {
return;
}
// This load replaces whatever was being followed, which may be a different
// target the host has just moved off.
this._syncAttempt();
this._forgetLoad();
this._mediaLoaded = true;
const generation = this._loadGeneration.next();
// Media arriving disproves a not-loading failure whoever reported it.
this._resolveFailure(targetID);
// Media going away is not itself a failure, but a load that is still
// expected afterwards needs a fresh wait. This is recorded whether or not
// the host is attached, since a detached host has no media either.
onAbort(ev.detail.signal, () => {
if (targetID !== this._targetID || !this._loadGeneration.isCurrent(generation)) {
return;
}
this._mediaLoaded = false;
this._evaluate();
});
};
// Clear a target's not-loading failure, whichever component reported it.
// Naming the reason leaves a failure reported for any other reason alone.
private _resolveFailure(targetID: string): void {
if (this._reportedTargetID === targetID) {
this._reportedTargetID = null;
}
fireAdvancedCameraCardEvent<IssueResolveEventData>(this._host, 'issue:resolve', {
key: 'media_unavailable',
targetID,
reason: 'not_loading',
});
}
// Discard everything learned about a previous target, or a previous attempt
// at the same one.
private _syncAttempt(): void {
const targetID = this._config.getTargetID();
const attemptID = this._config.getAttemptID?.();
if (targetID === this._targetID && attemptID === this._attemptID) {
return;
}
// If an abandoned target was previously reported, resolve it so it's not
// stuck forever
if (this._reportedTargetID && this._reportedTargetID !== targetID) {
this._resolveFailure(this._reportedTargetID);
}
this._targetID = targetID;
this._attemptID = attemptID;
this._forgetLoad();
}
private _evaluate(): void {
if (!this._connected) {
return;
}
this._syncAttempt();
if (!this._targetID || this._mediaLoaded || !this._config.isLoadExpected()) {
this._fired = false;
this._timer.stop();
return;
}
if (!this._fired && !this._timer.isRunning()) {
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => this._onTimeout());
}
}
private _onTimeout(): void {
const targetID = this._targetID;
// Conditions may have changed while the timer matured.
if (
!this._connected ||
!targetID ||
targetID !== this._config.getTargetID() ||
this._attemptID !== this._config.getAttemptID?.() ||
this._mediaLoaded ||
!this._config.isLoadExpected()
) {
return;
}
this._fired = true;
this._reportedTargetID = targetID;
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this._host, 'issue:trigger', {
key: 'media_unavailable',
targetID,
reason: 'not_loading',
});
}
}
+16
View File
@@ -126,6 +126,13 @@ export class AdvancedCameraCardImageUpdatingPlayer
private _refImage: Ref<HTMLImageElement> = createRef();
// Whether the <img> currently holds the stock image swapped in by
// `_forceSafeImage` rather than the intended media. The safe image load must
// not be announced as real media arriving, which may otherwise report a
// broken camera as healthy. Cleared on the next render, which restores the
// real source.
private _showingSafeImage = false;
private _cachedValueController = new CachedValueController(
this,
() => this._getEffectiveRefreshSeconds(),
@@ -234,6 +241,10 @@ export class AdvancedCameraCardImageUpdatingPlayer
* @param _changedProps The changed properties
*/
protected willUpdate(changedProps: PropertyValues): void {
// The render (below) restores the real source (via `live()`), so its load
// is the real media again.
this._showingSafeImage = false;
const relevantEntity = this._getRelevantEntityForMode(
resolveImageMode({
imageConfig: this.imageConfig,
@@ -454,6 +465,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
*/
private _forceSafeImage(stockOnly?: boolean): void {
if (this._refImage.value) {
this._showingSafeImage = true;
// Avoid restoring the raw configured URL when proxying is enabled, since
// that would bypass the proxied/signed URL path on visibility changes.
const configuredURL =
@@ -495,6 +507,10 @@ export class AdvancedCameraCardImageUpdatingPlayer
${ref(this._refImage)}
src=${live(src)}
@load=${(ev: Event) => {
if (this._showingSafeImage) {
return;
}
const mediaLoadedInfo = createMediaLoadedInfo(ev, {
mediaPlayerController: this._mediaPlayerController,
capabilities: {
+39 -10
View File
@@ -12,6 +12,7 @@ import { createRef, ref, type Ref } from 'lit/directives/ref.js';
import type { CameraManager } from '../camera-manager/manager';
import type { ViewManagerEpoch } from '../card-controller/view/types';
import { MediaLoadWatchdogController } from '../components-lib/media-load-watchdog-controller';
import type { ZoomSettingsObserved } from '../components-lib/zoom/types';
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context';
import type { CameraConfig } from '../config/schema/cameras';
@@ -59,6 +60,40 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
private _refImage: Ref<MediaPlayerElement> = createRef();
constructor() {
super();
// No lazy loading: Reports the image as a media_unavailable issue if it
// never arrives.
new MediaLoadWatchdogController(this, {
getTargetID: () => IMAGE_VIEW_TARGET_ID_SENTINEL,
isLoadExpected: () =>
// A misconfigured image already shows why nothing can be drawn, and
// reloading it cannot change the configuration.
!!this.hass && !this._getConfigurationError(),
// Player is keyed on epoch.
getAttemptID: () => this._getMediaEpoch(),
});
}
private _getMediaEpoch(): number {
const view = this.viewManagerEpoch?.manager.getView();
return view?.context?.mediaEpoch?.[IMAGE_VIEW_TARGET_ID_SENTINEL] ?? 0;
}
// Returns the reason no image can be shown at all, or null when one can.
// `camera` mode has nothing to draw from without a camera.
private _getConfigurationError(): string | null {
const mode = resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
});
return mode === 'camera' && !this.cameraConfig
? localize('error.no_camera_for_image')
: null;
}
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
await this.updateComplete;
return (await this._refImage.value?.getMediaPlayerController()) ?? null;
@@ -118,24 +153,18 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
return;
}
// Determine if this image mode requires a camera
const mode = resolveImageMode({
imageConfig: this.imageConfig,
cameraConfig: this.cameraConfig,
});
if (mode === 'camera' && !this.cameraConfig) {
return renderNotificationBlockFromText(localize('error.no_camera_for_image'), {
const configurationError = this._getConfigurationError();
if (configurationError) {
return renderNotificationBlockFromText(configurationError, {
icon: 'mdi:camera-off',
});
}
const view = this.viewManagerEpoch?.manager.getView();
const mediaEpoch = view?.context?.mediaEpoch?.[IMAGE_VIEW_TARGET_ID_SENTINEL] ?? 0;
return this._renderContainer(html`
${keyed(
mediaEpoch,
this._getMediaEpoch(),
html`
<advanced-camera-card-image-updating-player
${ref(this._refImage)}
+8 -11
View File
@@ -20,6 +20,7 @@ import type { CardWideConfig } from '../../config/schema/types.js';
import type { HomeAssistant } from '../../ha/types.js';
import liveGridStyle from '../../scss/live-grid.scss?inline';
import { contentsChanged } from '../../utils/basic.js';
import { getLiveGridCameraIDs } from '../../view/layout.js';
import './carousel.js';
@@ -97,26 +98,22 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
});
}
private _needsGrid(): boolean {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
private _getGridCameraIDs(): Set<string> | null {
const view = this.viewManagerEpoch?.manager.getView();
return (
!!view?.isGrid() &&
!!view?.supportsMultipleDisplayModes() &&
!!cameraIDs &&
cameraIDs.size > 1
);
return view && this.cameraManager
? getLiveGridCameraIDs(view, this.cameraManager)
: null;
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
if (changedProps.has('viewManagerEpoch') && this._getGridCameraIDs()) {
void import('../media-grid.js');
}
}
protected render(): TemplateResult | void {
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
if (!cameraIDs?.size || !this._needsGrid()) {
const cameraIDs = this._getGridCameraIDs();
if (!cameraIDs) {
return this._renderCarousel();
}
+55 -26
View File
@@ -17,6 +17,7 @@ import { MEDIA_UNAVAILABLE_REASONS } from '../../card-controller/issues/issues/m
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { isAudioIntendedOnLoad } from '../../components-lib/live/audio-intent.js';
import { StreamLivenessController } from '../../components-lib/live/liveness/stream-liveness-controller.js';
import { MediaLoadWatchdogController } from '../../components-lib/media-load-watchdog-controller.js';
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
import type { PartialZoomSettings } from '../../components-lib/zoom/types.js';
import type { LiveConfig } from '../../config/schema/live.js';
@@ -110,6 +111,24 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
constructor() {
super();
// Watch for media that fails to load. The constructor registers it as a
// controller on this host.
new MediaLoadWatchdogController(this, {
getTargetID: () => this.targetID ?? null,
isLoadExpected: () =>
this._shouldLoad() &&
// Don't report media unavailable for configuration errors as retries
// cannot possible help, and a message is already rendered.
!this._getConfigurationError() &&
// Specific > generic: Don't replace existing failures flagged with
// liveness detectors.
!this._streamLivenessController.getFailure(),
});
}
// A note on dynamic imports:
//
// We gather the dynamic live provider import promises and do not consider the
@@ -181,6 +200,33 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
}
}
// Whether this provider should be loading media at all.
private _shouldLoad(): boolean {
return this._lazyLoadController.isLoaded();
}
// Returns the reason this camera cannot stream at all, or null when it can.
private _getConfigurationError(): string | null {
const cameraConfig = this.camera?.getConfig();
const provider = getResolvedLiveProvider(cameraConfig);
if (
provider !== 'ha' &&
provider !== 'image' &&
!(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
) {
return null;
}
if (!cameraConfig?.camera_entity) {
return localize('error.no_live_camera');
}
if (!this.hass?.states[cameraConfig.camera_entity]) {
return localize('error.live_camera_not_found');
}
return null;
}
override async getUpdateComplete(): Promise<boolean> {
// See 'A note on dynamic imports' above for explanation of why this is
// necessary.
@@ -234,7 +280,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
protected render(): TemplateResult | void {
const cameraConfig = this.camera?.getConfig();
if (
!this._lazyLoadController?.isLoaded() ||
!this._shouldLoad() ||
!this.hass ||
!this.liveConfig ||
!this.camera ||
@@ -262,31 +308,14 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
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 getFailure() below, for all providers.
if (
provider === 'ha' ||
provider === 'image' ||
(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
) {
if (!cameraConfig?.camera_entity) {
return renderMediaNotification({
icon: 'mdi:camera',
title: localize('error.configuration_error'),
detail: localize('error.no_live_camera'),
targetTitle: this.cameraTitle,
});
}
if (!this.hass.states[cameraConfig.camera_entity]) {
return renderMediaNotification({
icon: 'mdi:camera',
title: localize('error.configuration_error'),
detail: localize('error.live_camera_not_found'),
targetTitle: this.cameraTitle,
});
}
const configurationError = this._getConfigurationError();
if (configurationError) {
return renderMediaNotification({
icon: 'mdi:camera',
title: localize('error.configuration_error'),
detail: configurationError,
targetTitle: this.cameraTitle,
});
}
const failure = this._streamLivenessController.getFailure();
+7 -12
View File
@@ -17,6 +17,7 @@ import type { CardWideConfig } from '../../config/schema/types.js';
import type { ViewerConfig } from '../../config/schema/viewer.js';
import type { ResolvedMediaCache } from '../../ha/resolved-media.js';
import type { HomeAssistant } from '../../ha/types.js';
import { getViewerGridCameraIDs } from '../../view/layout.js';
import '../../patches/ha-hls-player.js';
@@ -75,19 +76,14 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
}
protected willUpdate(changedProps: PropertyValues): void {
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
if (changedProps.has('viewManagerEpoch') && this._getGridCameraIDs()) {
void import('../media-grid.js');
}
}
private _needsGrid(): boolean {
private _getGridCameraIDs(): Set<string> | null {
const view = this.viewManagerEpoch?.manager.getView();
const cameraIDs = view?.queryResults?.getCameraIDs();
return (
!!view?.isGrid() &&
!!view?.supportsMultipleDisplayModes() &&
(cameraIDs?.size ?? 0) > 1
);
return view ? getViewerGridCameraIDs(view) : null;
}
private _gridSelectCamera(cameraID: string): void {
@@ -103,15 +99,14 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
}
protected render(): TemplateResult {
const view = this.viewManagerEpoch?.manager.getView();
const cameraIDs = view?.queryResults?.getCameraIDs();
if (!cameraIDs || !this._needsGrid()) {
const cameraIDs = this._getGridCameraIDs();
if (!cameraIDs) {
return this._renderCarousel();
}
return html`
<advanced-camera-card-media-grid
.selected=${view?.camera}
.selected=${this.viewManagerEpoch?.manager.getView()?.camera}
.displayConfig=${this.viewerConfig?.display}
@advanced-camera-card:media-grid:selected=${(
ev: CustomEvent<MediaGridSelected>,
+27 -8
View File
@@ -14,6 +14,7 @@ import type { CameraManager } from '../../camera-manager/manager.js';
import { QueryType } from '../../camera-manager/types.js';
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
import { MediaLoadWatchdogController } from '../../components-lib/media-load-watchdog-controller.js';
import {
getSignedURLErrorText,
SignedURLController,
@@ -36,6 +37,7 @@ import type {
MediaPlayerController,
MediaPlayerElement,
} from '../../types.js';
import { Generation } from '../../utils/concurrency/generation.js';
import { classifyMimeType } from '../../utils/mime-type.js';
import { ViewItemClassifier } from '../../view/item-classifier.js';
import type { ViewMedia } from '../../view/item.js';
@@ -77,6 +79,9 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
private _resolvedMedia: ResolvedMedia | null = null;
// Drops a slow resolution for media the provider has since moved off.
private _resolveGeneration = new Generation();
private _signedURLController = new SignedURLController(this, () => {
if (!this.hass || !this._resolvedMedia) {
return {};
@@ -99,6 +104,12 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
constructor() {
super();
this._lazyLoadController.addListener((loaded) => loaded && this._resolveURL());
// Watch for media load failure (including resolving media ID and signing).
new MediaLoadWatchdogController(this, {
getTargetID: () => this.media?.getID() ?? null,
isLoadExpected: () => this._shouldLoad(),
});
}
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
@@ -140,19 +151,28 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
private async _resolveURL(): Promise<void> {
const contentID = this.media?.getContentID();
if (!contentID || !this.hass || !this._lazyLoadController?.isLoaded()) {
if (!contentID || !this.hass || !this._shouldLoad()) {
this._resolveGeneration.invalidate();
this._resolvedMedia = null;
return;
}
const generation = this._resolveGeneration.next();
// Clear immediately so the SignedURLController doesn't see a stale URL
// from the previous media item during the async gap.
this._resolvedMedia = null;
this._resolvedMedia =
const resolved =
this.resolvedMediaCache?.get(contentID) ??
(await resolveMedia(this.hass, contentID, this.resolvedMediaCache)) ??
null;
if (!this._resolveGeneration.isCurrent(generation)) {
return;
}
this._resolvedMedia = resolved;
this.requestUpdate();
}
@@ -177,6 +197,10 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
}
}
private _shouldLoad(): boolean {
return this._lazyLoadController.isLoaded();
}
private _getRelevantCameraConfig(): CameraConfig | null {
const cameraID = this.media?.getCameraID();
return cameraID
@@ -233,12 +257,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
}
protected render(): TemplateResult | void {
if (
!this._lazyLoadController?.isLoaded() ||
!this.media ||
!this.hass ||
!this.viewerConfig
) {
if (!this._shouldLoad() || !this.media || !this.hass || !this.viewerConfig) {
return;
}
+1 -1
View File
@@ -41,7 +41,7 @@ export interface ConditionState {
// Generic media target identifier. See @view/target-id for details.
targetID?: string;
triggered?: Set<string>;
triggered?: ReadonlySet<string>;
userAgent?: string;
view?: AdvancedCameraCardView;
}
+6
View File
@@ -37,12 +37,18 @@
// not change in size (even border-box sizing appears to allow size to change
// when the element has a non-fixed height).
border: var(--advanced-camera-card-grid-border-size) solid transparent;
// Unselected cells cannot be usefully scrolled, so ensure notifications do
// not show scrollbars.
--advanced-camera-card-notification-block-overflow-y: hidden;
}
::slotted([selected]) {
border: var(--advanced-camera-card-grid-border-size) solid
var(--advanced-camera-card-grid-selected-border-color);
--advanced-camera-card-notification-block-overflow-y: auto;
// Selected width = columns × selection factor, capped at 100%.
width: min(
100%,
+3 -1
View File
@@ -29,7 +29,9 @@
padding: 24px;
max-width: min(92%, 500px);
max-height: 100%;
overflow-y: auto;
// A container that cannot usefully be scrolled can set this to 'hidden'.
overflow-y: var(--advanced-camera-card-notification-block-overflow-y, auto);
scrollbar-width: thin;
text-align: center;
}
+85
View File
@@ -0,0 +1,85 @@
import type { CameraManager } from '../camera-manager/manager';
import { getViewTargetID } from './target-id';
import type { View } from './view';
const isGridLayout = (view: View): boolean =>
// This reflects whether the view 'asks' for a grid. This is inherited as
// the user changes view.
view.isGrid() &&
// This reflects whether the current view actually *supports* a grid.
view.supportsMultipleDisplayModes();
// The cameras a live grid lays out, one cell each, or null when live is laid
// out as a single carousel instead (incl. when there's <= 1 camera).
export const getLiveGridCameraIDs = (
view: View,
cameraManager: CameraManager,
): Set<string> | null => {
if (!isGridLayout(view)) {
return null;
}
const cameraIDs = cameraManager.getStore().getCameraIDsWithCapability('live');
return cameraIDs.size > 1 ? cameraIDs : null;
};
// The cameras a viewer grid lays out, one cell each, or null when the viewer is
// laid out as a single carousel instead (incl. when there's <= 1 camera). The
// cameras come from the query results, so only a camera with media to show gets
// a cell.
export const getViewerGridCameraIDs = (view: View): Set<string> | null => {
if (!isGridLayout(view)) {
return null;
}
const cameraIDs = view.queryResults?.getCameraIDs();
return cameraIDs && cameraIDs.size > 1 ? cameraIDs : null;
};
// The target shown in each cell of a grid, or null when the view is not laid
// out as a grid.
const getGridTargetIDs = (
view: View,
cameraManager: CameraManager,
): Set<string> | null => {
if (view.is('live')) {
// A live cell shows its camera, so the camera is the target.
return getLiveGridCameraIDs(view, cameraManager);
}
if (view.isViewerView()) {
const cameraIDs = getViewerGridCameraIDs(view);
if (!cameraIDs) {
return null;
}
// A viewer cell shows one media item belonging to its camera.
const targetIDs = new Set<string>();
for (const cameraID of cameraIDs) {
const targetID = view.queryResults?.getSelectedResult(cameraID)?.getID();
if (targetID) {
targetIDs.add(targetID);
}
}
return targetIDs;
}
return null;
};
// Every target the current view lays out, one per cell. A grid shows several
// targets at once, so all of them count even when some are scrolled out of the
// viewport: the user chose to put them on screen. Every other layout shows a
// single target at a time, which is the one the view identifies.
export const getDisplayedTargetIDs = (
view: View,
cameraManager: CameraManager,
): Set<string> => {
const gridTargetIDs = getGridTargetIDs(view, cameraManager);
if (gridTargetIDs) {
return gridTargetIDs;
}
const targetID = getViewTargetID(view);
return targetID ? new Set([targetID]) : new Set();
};
+1 -1
View File
@@ -70,7 +70,7 @@ const isGalleryViewName = (view?: AdvancedCameraCardView): boolean =>
const isAnyFolderViewName = (view?: AdvancedCameraCardView): boolean =>
!!view && FOLDER_VIEW_NAMES.includes(view);
export const isAnyMediaViewName = (view?: AdvancedCameraCardView): boolean =>
const isAnyMediaViewName = (view?: AdvancedCameraCardView): boolean =>
isViewerViewName(view) || view === 'live' || view === 'image';
export class View {