fix: Report media failures once per failure (#2666)
This commit is contained in:
@@ -35,12 +35,22 @@ declare module 'issue' {
|
||||
}
|
||||
|
||||
interface IssueResolveContext {
|
||||
media_unavailable: {
|
||||
targetID: string;
|
||||
|
||||
// Optionally limits the clearing to one kind of failure.
|
||||
reason?: MediaUnavailableIssueReason;
|
||||
};
|
||||
// Either a resolve scoped to (at most) one named kind of failure, or the
|
||||
// statement that the target's media has loaded, which resolves whichever
|
||||
// failures have `resetOnLoad`. Only real media is ever announced as loaded:
|
||||
// the card's substitute pictures (a loading or stock image) are not, so
|
||||
// they can never make this statement.
|
||||
media_unavailable: { targetID: string } & (
|
||||
| {
|
||||
// Optionally limits the clearing to one kind of failure.
|
||||
reason?: MediaUnavailableIssueReason;
|
||||
cause?: never;
|
||||
}
|
||||
| {
|
||||
reason?: never;
|
||||
cause: 'media-loaded';
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,34 +62,46 @@ interface TargetError {
|
||||
|
||||
// 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.
|
||||
// described in exactly one place. `resetOnLoad` means a media load will reset
|
||||
// this issue reason.
|
||||
export const MEDIA_UNAVAILABLE_REASONS: Record<
|
||||
MediaUnavailableIssueReason,
|
||||
{ localizationKey: string; icon: string }
|
||||
{ localizationKey: string; icon: string; resetOnLoad: boolean }
|
||||
> = {
|
||||
entity_unavailable: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.entity_unavailable',
|
||||
icon: 'mdi:cctv-off',
|
||||
resetOnLoad: false,
|
||||
},
|
||||
not_loading: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.not_loading',
|
||||
icon: 'mdi:progress-helper',
|
||||
resetOnLoad: true,
|
||||
},
|
||||
playback_error: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.playback_error',
|
||||
icon: 'mdi:alert-circle',
|
||||
|
||||
// A player can load media and still fail to play it.
|
||||
resetOnLoad: false,
|
||||
},
|
||||
server_error: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.server_error',
|
||||
icon: 'mdi:server-network-off',
|
||||
resetOnLoad: true,
|
||||
},
|
||||
stalled: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.stalled',
|
||||
icon: 'mdi:motion-pause',
|
||||
resetOnLoad: false,
|
||||
},
|
||||
unsupported: {
|
||||
localizationKey: 'issues.media_unavailable.reasons.unsupported',
|
||||
icon: 'mdi:video-off-outline',
|
||||
|
||||
// Substitute pictures are never announced as loaded media, so a load means
|
||||
// the requested media was delivered in some supported way after all.
|
||||
resetOnLoad: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -113,7 +135,15 @@ export class MediaUnavailableIssue implements Issue {
|
||||
|
||||
public resolve(context: IssueResolveContext['media_unavailable']): void {
|
||||
const error = this._erroredTargets.get(context.targetID);
|
||||
if (!error || (context.reason && context.reason !== error.reason)) {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.cause === 'media-loaded') {
|
||||
if (!MEDIA_UNAVAILABLE_REASONS[error.reason].resetOnLoad) {
|
||||
return;
|
||||
}
|
||||
} else if (context.reason && context.reason !== error.reason) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@ import { isEqual } from 'lodash-es';
|
||||
import type { StateWatcherSubscriptionInterface } from '../../../../card-controller/hass/state-watcher';
|
||||
import type { HassStateDifference, HomeAssistant } from '../../../../ha/types';
|
||||
import { Timer } from '../../../../utils/timer';
|
||||
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
|
||||
import type {
|
||||
LivenessDetector,
|
||||
LivenessInvalidationCause,
|
||||
LivenessVerdict,
|
||||
} from '../stream-liveness-controller';
|
||||
|
||||
// A camera entity must stay `unavailable` this long before the stream is
|
||||
// treated as lost. Shorter blips (e.g. during PTZ, see issue #2124) are
|
||||
@@ -56,13 +60,17 @@ export class EntityAvailabilityDetector implements LivenessDetector {
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
public invalidate(cause: LivenessInvalidationCause): void {
|
||||
if (cause !== 'stream-changed') {
|
||||
// Only changing the entity under detection can invalidate the verdict.
|
||||
return;
|
||||
}
|
||||
|
||||
this._verdict = { state: 'unknown' };
|
||||
this._timer.stop();
|
||||
|
||||
// A reset is not a stop: watching continues, only what was learned is
|
||||
// forgotten. `getCameraEntity` may now name a different entity, so re-point
|
||||
// the subscription and read that entity now.
|
||||
// `getCameraEntity` may now name a different entity, so re-point the
|
||||
// subscription and read that entity now.
|
||||
this._subscribeOrUnsubscribeFromCameraEntity();
|
||||
this._check();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import type {
|
||||
} from '../../../../types';
|
||||
import { onAbort } from '../../../../utils/abort-signal';
|
||||
import { VisibilityObserver } from '../../../visibility-observer';
|
||||
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
|
||||
import type {
|
||||
LivenessDetector,
|
||||
LivenessInvalidationCause,
|
||||
LivenessVerdict,
|
||||
} from '../stream-liveness-controller';
|
||||
|
||||
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
|
||||
|
||||
@@ -61,19 +65,22 @@ export class MediaPlayerLivenessDetector implements LivenessDetector {
|
||||
}
|
||||
|
||||
public unsubscribe(): void {
|
||||
// Retain the verdict so a reconnect resumes where it left off; use reset()
|
||||
// to discard it.
|
||||
// Retain the verdict so a reconnect resumes where it left off; use
|
||||
// invalidate() to discard it.
|
||||
this._host.removeEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
|
||||
this._visibilityObserver?.destroy();
|
||||
this._visibilityObserver = null;
|
||||
this._unwatch();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
// The underlying stream changed (e.g. a substream switch): discard the
|
||||
// verdict and re-evaluate against the new media.
|
||||
this._unwatch();
|
||||
this._setVerdict({ state: 'unknown' });
|
||||
public invalidate(cause: LivenessInvalidationCause): void {
|
||||
// A stall is a failure of media that had already loaded, so an invalidation
|
||||
// with cause 'media-loaded' proves nothing as far as this detector is
|
||||
// concerned. Only a changed stream/camera discards the verdict.
|
||||
if (cause === 'stream-changed') {
|
||||
this._unwatch();
|
||||
this._setVerdict({ state: 'unknown' });
|
||||
}
|
||||
}
|
||||
|
||||
public getVerdict(): LivenessVerdict {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { LiveError } from '../../utils/dispatch-live-error';
|
||||
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
|
||||
import type {
|
||||
LivenessDetector,
|
||||
LivenessInvalidationCause,
|
||||
LivenessVerdict,
|
||||
} from '../stream-liveness-controller';
|
||||
|
||||
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
||||
|
||||
@@ -31,7 +35,10 @@ export class ProviderErrorDetector implements LivenessDetector {
|
||||
this._host.removeEventListener(LIVE_ERROR_EVENT, this._handler);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
public invalidate(_cause: LivenessInvalidationCause): void {
|
||||
// No matter the cause the provider error is disproven: the stream the error
|
||||
// was about has been replaced, or media is demonstrably being delivered.
|
||||
this._verdict = { state: 'unknown' };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,18 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
import type { Camera } from '../../../camera-manager/camera';
|
||||
import type { StateWatcherSubscriptionInterface } from '../../../card-controller/hass/state-watcher';
|
||||
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable';
|
||||
import type {
|
||||
IssueResolveEventData,
|
||||
IssueTriggerEventData,
|
||||
} from '../../../card-controller/issues/types';
|
||||
import type { CameraConfig } from '../../../config/schema/cameras';
|
||||
import type { HomeAssistant } from '../../../ha/types';
|
||||
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
|
||||
import {
|
||||
resolveMediaUnavailableIssue,
|
||||
triggerMediaUnavailableIssue,
|
||||
} from '../../media-unavailable-issue';
|
||||
import { EntityAvailabilityDetector } from './detectors/entity-availability';
|
||||
import { MediaPlayerLivenessDetector } from './detectors/media-player-liveness';
|
||||
import { ProviderErrorDetector } from './detectors/provider-error';
|
||||
|
||||
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
|
||||
|
||||
// How far a verdict's evidence is trusted, so direct observation of the media
|
||||
// outweighs an indirect signal:
|
||||
// - `direct`: observed from the media itself (e.g. frames arriving or
|
||||
@@ -66,17 +67,26 @@ interface StreamFailure {
|
||||
renderPlaceholder: boolean;
|
||||
}
|
||||
|
||||
// A prior detection can be invalidated for one of two reasons:
|
||||
// - `stream-changed`: the media underneath was replaced (e.g. a different camera or
|
||||
// provider, or a retry rebuilding it), so nothing observed about the old one
|
||||
// still applies.
|
||||
// - `media-loaded`: media has loaded, which can disprove a failure to deliver
|
||||
// it. Whether it does is each detector's own call, since the media that
|
||||
// loaded is not always the media it was watching.
|
||||
export type LivenessInvalidationCause = 'stream-changed' | 'media-loaded';
|
||||
|
||||
export interface LivenessDetector {
|
||||
// Start observing the signal.
|
||||
subscribe(): void;
|
||||
|
||||
// Stop observing (e.g. on disconnect). Accumulated state is retained so a
|
||||
// later reconnect resumes where it left off; use reset() to discard it.
|
||||
// later reconnect resumes where it left off; use invalidate() to discard it.
|
||||
unsubscribe(): void;
|
||||
|
||||
// Discard accumulated state because the underlying stream changed (e.g. a
|
||||
// substream switch), so detection restarts from scratch.
|
||||
reset?(): void;
|
||||
// Notify the detector that `cause` occurred; the detector decides whether
|
||||
// its verdict still holds.
|
||||
invalidate(cause: LivenessInvalidationCause): void;
|
||||
|
||||
// Reports the stream's current liveness, calling `onChange` (passed at
|
||||
// construction) whenever that verdict changes.
|
||||
@@ -99,7 +109,7 @@ export class StreamLivenessController implements ReactiveController {
|
||||
private _host: ReactiveControllerHost & HTMLElement;
|
||||
private _config: StreamLivenessControllerConfig;
|
||||
private _detectors: LivenessDetector[];
|
||||
private _resetting = false;
|
||||
private _invalidating = false;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost & HTMLElement,
|
||||
@@ -128,10 +138,12 @@ export class StreamLivenessController implements ReactiveController {
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._host.addEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
|
||||
this._detectors.forEach((detector) => detector.subscribe());
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._host.removeEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
|
||||
this._detectors.forEach((detector) => detector.unsubscribe());
|
||||
}
|
||||
|
||||
@@ -154,17 +166,23 @@ export class StreamLivenessController implements ReactiveController {
|
||||
|
||||
// Discard detector state on a stream change (e.g. a stream switch).
|
||||
public reset(): void {
|
||||
// The detectors are cleared one at a time, and clearing one could cause it
|
||||
// report a change (e.g. an entity might be marked as having an unknown
|
||||
// state). Part-way through, some are cleared and some are not, so what they
|
||||
// add up to is meaningless and this controller needs to not take action
|
||||
// during this time. Ignore anything detectors say until the reset is
|
||||
// complete.
|
||||
this._resetting = true;
|
||||
this._invalidate('stream-changed');
|
||||
}
|
||||
|
||||
private _onMediaLoaded = (): void => this._invalidate('media-loaded');
|
||||
|
||||
private _invalidate(cause: LivenessInvalidationCause): void {
|
||||
// The detectors are invalidated one at a time, and invalidating one can
|
||||
// make it call back with a changed verdict (e.g. an entity dropping back to
|
||||
// an unknown state). Part way through, some have been invalidated and some
|
||||
// have not, so what they add up to is meaningless and this controller needs
|
||||
// to not take action during this time. Ignore anything detectors say until
|
||||
// the invalidation is complete.
|
||||
this._invalidating = true;
|
||||
try {
|
||||
this._detectors.forEach((detector) => detector.reset?.());
|
||||
this._detectors.forEach((detector) => detector.invalidate(cause));
|
||||
} finally {
|
||||
this._resetting = false;
|
||||
this._invalidating = false;
|
||||
}
|
||||
|
||||
this._onDetectorChange();
|
||||
@@ -196,7 +214,7 @@ export class StreamLivenessController implements ReactiveController {
|
||||
}
|
||||
|
||||
private _onDetectorChange(): void {
|
||||
if (this._resetting) {
|
||||
if (this._invalidating) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -219,12 +237,7 @@ export class StreamLivenessController implements ReactiveController {
|
||||
if (!targetID) {
|
||||
return;
|
||||
}
|
||||
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this._host, 'issue:trigger', {
|
||||
key: 'media_unavailable',
|
||||
targetID,
|
||||
reason,
|
||||
description,
|
||||
});
|
||||
triggerMediaUnavailableIssue(this._host, { targetID, reason, description });
|
||||
}
|
||||
|
||||
private _resolveMediaUnavailableIssue(): void {
|
||||
@@ -232,9 +245,6 @@ export class StreamLivenessController implements ReactiveController {
|
||||
if (!targetID) {
|
||||
return;
|
||||
}
|
||||
fireAdvancedCameraCardEvent<IssueResolveEventData>(this._host, 'issue:resolve', {
|
||||
key: 'media_unavailable',
|
||||
targetID,
|
||||
});
|
||||
resolveMediaUnavailableIssue(this._host, { targetID });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
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';
|
||||
import {
|
||||
resolveMediaUnavailableIssue,
|
||||
triggerMediaUnavailableIssue,
|
||||
} from './media-unavailable-issue';
|
||||
|
||||
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
|
||||
|
||||
@@ -125,8 +124,10 @@ export class MediaLoadWatchdogController implements ReactiveController {
|
||||
this._mediaLoaded = true;
|
||||
const generation = this._loadGeneration.next();
|
||||
|
||||
// Media arriving disproves a not-loading failure whoever reported it.
|
||||
this._resolveFailure(targetID);
|
||||
if (this._reportedTargetID === targetID) {
|
||||
this._reportedTargetID = null;
|
||||
}
|
||||
resolveMediaUnavailableIssue(this._host, { targetID, cause: 'media-loaded' });
|
||||
|
||||
// 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
|
||||
@@ -141,20 +142,6 @@ export class MediaLoadWatchdogController implements ReactiveController {
|
||||
});
|
||||
};
|
||||
|
||||
// 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 {
|
||||
@@ -164,10 +151,15 @@ export class MediaLoadWatchdogController implements ReactiveController {
|
||||
return;
|
||||
}
|
||||
|
||||
// If an abandoned target was previously reported, resolve it so it's not
|
||||
// stuck forever
|
||||
// If an issue was triggered for a target since abandoned, resolve it so it
|
||||
// is not stuck forever, scoped to not-loading since that is all this
|
||||
// watchdog triggers.
|
||||
if (this._reportedTargetID && this._reportedTargetID !== targetID) {
|
||||
this._resolveFailure(this._reportedTargetID);
|
||||
resolveMediaUnavailableIssue(this._host, {
|
||||
targetID: this._reportedTargetID,
|
||||
reason: 'not_loading',
|
||||
});
|
||||
this._reportedTargetID = null;
|
||||
}
|
||||
|
||||
this._targetID = targetID;
|
||||
@@ -210,10 +202,6 @@ export class MediaLoadWatchdogController implements ReactiveController {
|
||||
|
||||
this._fired = true;
|
||||
this._reportedTargetID = targetID;
|
||||
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this._host, 'issue:trigger', {
|
||||
key: 'media_unavailable',
|
||||
targetID,
|
||||
reason: 'not_loading',
|
||||
});
|
||||
triggerMediaUnavailableIssue(this._host, { targetID, reason: 'not_loading' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
|
||||
|
||||
import type {
|
||||
IssueResolveEventData,
|
||||
IssueTriggerEventData,
|
||||
} from '../card-controller/issues/types';
|
||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
|
||||
|
||||
// The `media_unavailable` issue holds one failure per target, and several
|
||||
// components trigger and resolve it for the same target. These events are
|
||||
// statements of observed fact, not paired open/close operations:
|
||||
//
|
||||
// - A statement must be true when made: trigger only a failure that was
|
||||
// observed, and resolve only what the observed evidence disproves, naming a
|
||||
// `reason` to narrow the resolve to exactly that.
|
||||
// - Statements are idempotent. Two components observing the same recovery may
|
||||
// both resolve; the second changes nothing.
|
||||
// - A failure is not necessarily resolved by the component that triggered it.
|
||||
// That component may since have been replaced (e.g. a retry rebuilding a
|
||||
// provider), so recovery is stated by whichever component observes it.
|
||||
|
||||
/**
|
||||
* Trigger the `media_unavailable` issue for a target, naming what went wrong.
|
||||
*/
|
||||
export function triggerMediaUnavailableIssue(
|
||||
element: EventTarget,
|
||||
context: IssueTriggerContext['media_unavailable'],
|
||||
): void {
|
||||
fireAdvancedCameraCardEvent<IssueTriggerEventData>(element, 'issue:trigger', {
|
||||
key: 'media_unavailable',
|
||||
...context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a target's `media_unavailable` issue. Naming a reason leaves an issue
|
||||
* triggered for any other reason in place.
|
||||
*/
|
||||
export function resolveMediaUnavailableIssue(
|
||||
element: EventTarget,
|
||||
context: IssueResolveContext['media_unavailable'],
|
||||
): void {
|
||||
fireAdvancedCameraCardEvent<IssueResolveEventData>(element, 'issue:resolve', {
|
||||
key: 'media_unavailable',
|
||||
...context,
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,6 @@ 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 { FRAME_STALL_SECONDS } from '../components-lib/media-player/frame-stall-watchdog.js';
|
||||
@@ -212,7 +211,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
* @returns `true` if the element should be updated.
|
||||
*/
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
if (!this.hass || document.visibilityState !== 'visible') {
|
||||
if (!this.isConnected || !this.hass || document.visibilityState !== 'visible') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -279,6 +278,12 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
}
|
||||
|
||||
private _dispatchError(reason: MediaUnavailableIssueReason): void {
|
||||
// An image request can fail after this player has left the DOM. Ignore
|
||||
// such failures: they describe media that is no longer shown.
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
fireAdvancedCameraCardEvent(this, 'image-updating-player:error', reason, {
|
||||
bubbles: false,
|
||||
composed: false,
|
||||
@@ -530,23 +535,9 @@ 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>(
|
||||
this,
|
||||
'issue:trigger',
|
||||
{
|
||||
key: 'media_unavailable',
|
||||
targetID: this.targetID,
|
||||
reason: 'not_loading',
|
||||
},
|
||||
);
|
||||
}
|
||||
this._dispatchError('not_loading');
|
||||
}}
|
||||
/>
|
||||
`
|
||||
|
||||
@@ -11,8 +11,10 @@ import { keyed } from 'lit/directives/keyed.js';
|
||||
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
||||
|
||||
import type { CameraManager } from '../camera-manager/manager';
|
||||
import type { MediaUnavailableIssueReason } from '../card-controller/issues/issues/media-unavailable';
|
||||
import type { ViewManagerEpoch } from '../card-controller/view/types';
|
||||
import { MediaLoadWatchdogController } from '../components-lib/media-load-watchdog-controller';
|
||||
import { triggerMediaUnavailableIssue } from '../components-lib/media-unavailable-issue';
|
||||
import type { ZoomSettingsObserved } from '../components-lib/zoom/types';
|
||||
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context';
|
||||
import type { CameraConfig } from '../config/schema/cameras';
|
||||
@@ -175,6 +177,17 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
|
||||
.targetID=${IMAGE_VIEW_TARGET_ID_SENTINEL}
|
||||
.proxyConfig=${this._resolveProxyConfig(this.imageConfig?.proxy) ??
|
||||
undefined}
|
||||
@advanced-camera-card:image-updating-player:error=${(
|
||||
ev: CustomEvent<MediaUnavailableIssueReason>,
|
||||
) =>
|
||||
// The image view has no liveness detector, so the view triggers
|
||||
// the issue itself. An image's failures are all failures to load.
|
||||
// The load watchdog above resolves them once media eventually
|
||||
// loads.
|
||||
triggerMediaUnavailableIssue(this, {
|
||||
targetID: IMAGE_VIEW_TARGET_ID_SENTINEL,
|
||||
reason: ev.detail,
|
||||
})}
|
||||
>
|
||||
</advanced-camera-card-image-updating-player>
|
||||
`,
|
||||
|
||||
@@ -209,7 +209,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
for (let i = 0; i < this._media.length; ++i) {
|
||||
const media = this._media[i];
|
||||
if (media) {
|
||||
const slide = this._renderMediaItem(media);
|
||||
const slide = this._renderMediaItem(media, i === this._selected);
|
||||
if (slide) {
|
||||
slides[i] = slide;
|
||||
}
|
||||
@@ -459,7 +459,10 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _renderMediaItem(media: ViewMedia): TemplateResult | null {
|
||||
private _renderMediaItem(
|
||||
media: ViewMedia,
|
||||
isSelected: boolean,
|
||||
): TemplateResult | null {
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
if (!this.hass || !view || !this.viewerConfig) {
|
||||
return null;
|
||||
@@ -479,6 +482,7 @@ export class AdvancedCameraCardViewerCarousel extends LitElement {
|
||||
.resolvedMediaCache=${this.resolvedMediaCache}
|
||||
.cameraManager=${this.cameraManager}
|
||||
.cardWideConfig=${this.cardWideConfig}
|
||||
.forceSelected=${isSelected}
|
||||
></advanced-camera-card-viewer-provider>`,
|
||||
)}
|
||||
</div>`;
|
||||
|
||||
@@ -74,6 +74,15 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
||||
@property({ attribute: false })
|
||||
public cardWideConfig?: CardWideConfig;
|
||||
|
||||
// Whether to force this slide to behave as if it is selected and
|
||||
// intersecting. Set by the carousel on its currently-selected slide. This is
|
||||
// necessary: `render` below draws nothing until the slide has loaded, and a
|
||||
// slide drawing nothing can have no height for IntersectionObserver to see.
|
||||
// Left to the observer only, the slide would wait to be seen before drawing
|
||||
// anything there was to see. See `LazyLoadConfiguration.forceSelected`.
|
||||
@property({ attribute: false })
|
||||
public forceSelected = false;
|
||||
|
||||
private _refProvider: Ref<MediaPlayerElement> = createRef();
|
||||
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
|
||||
|
||||
@@ -177,9 +186,10 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('viewerConfig')) {
|
||||
if (changedProps.has('viewerConfig') || changedProps.has('forceSelected')) {
|
||||
this._lazyLoadController.setConfiguration({
|
||||
lazyLoad: this.viewerConfig?.lazy_load,
|
||||
forceSelected: this.forceSelected,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -804,7 +804,7 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"awaiting_live": "Waiting for live stream to load...",
|
||||
"awaiting_live": "Waiting for live stream to load",
|
||||
"awaiting_media": "Waiting for media to load",
|
||||
"call_invalid_target": "The requested camera or stream is not available to call.",
|
||||
"call_microphone_failed": "Your microphone could not be connected.",
|
||||
|
||||
@@ -75,6 +75,15 @@ export const createUnansweredMediaURL = (): string => createTestMediaURL([]);
|
||||
export const createStallingMediaURL = (filename?: string): string =>
|
||||
createTestMediaURL([HTTP_OK], false, filename);
|
||||
|
||||
/**
|
||||
* How many requests a media URL has been asked for, so a test can count what
|
||||
* the card actually fetched rather than only what it displayed.
|
||||
*/
|
||||
export const getTestMediaRequestCount = (url: string): number => {
|
||||
const token = new URL(url, window.location.href).searchParams.get('token');
|
||||
return (token ? requestCounts.get(token) : null) ?? 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Serves a fixture at `/test-media/<file>`, behaving as the query asks:
|
||||
*
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
createStallingMediaURL,
|
||||
createTemporarilyFailingMediaURL,
|
||||
createUnansweredMediaURL,
|
||||
getTestMediaRequestCount,
|
||||
useTestMedia,
|
||||
} from '../../../browser/test-media';
|
||||
import {
|
||||
@@ -375,6 +376,21 @@ describe('MediaUnavailableIssue', () => {
|
||||
|
||||
expect(getBlockNotificationText(card.card)).toContain('Could not load image');
|
||||
expect(getBlockNotificationText(card.card)).toContain(CAMERA_ENTITY);
|
||||
|
||||
expect(card.events.getEntries('advanced-camera-card:issue:trigger')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should trigger an issue for an image view whose media fails to load', async () => {
|
||||
const card = await mountCard({
|
||||
view: { default: 'image' },
|
||||
image: { mode: 'url', url: createFailingMediaURL() },
|
||||
});
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
|
||||
await waitForIssueReported(card);
|
||||
|
||||
expect(isIssueReported(card)).toBe(true);
|
||||
expect(card.events.getEntries('advanced-camera-card:issue:trigger')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should clear the issue report once the camera delivers media again', async () => {
|
||||
@@ -463,12 +479,11 @@ describe('MediaUnavailableIssue', () => {
|
||||
});
|
||||
|
||||
it('should re-attempt when the retry control is used', async () => {
|
||||
const mediaURL = createTemporarilyFailingMediaURL(1);
|
||||
const card = await mountCard({
|
||||
// Automatic retries switched off.
|
||||
view: { issues: { retry_seconds: 0 } },
|
||||
cameras: [
|
||||
createStillImageCameraConfig(CAMERA_ENTITY, createTemporarilyFailingMediaURL(1)),
|
||||
],
|
||||
cameras: [createStillImageCameraConfig(CAMERA_ENTITY, mediaURL)],
|
||||
});
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
|
||||
@@ -483,6 +498,8 @@ describe('MediaUnavailableIssue', () => {
|
||||
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
expect(isLiveMediaShowing(card.card)).toBe(true);
|
||||
|
||||
expect(getTestMediaRequestCount(mediaURL)).toBe(2);
|
||||
});
|
||||
|
||||
it('should not report while a non-media view is showing', async () => {
|
||||
@@ -551,7 +568,7 @@ describe('MediaUnavailableIssue', () => {
|
||||
expect(getBlockNotificationText(card.card)).toContain('Stream stalled');
|
||||
});
|
||||
|
||||
it('should trigger an issue for a camera picture that falls back to a stock image', async () => {
|
||||
it('should trigger an issue for a camera snapshot that falls back to a stock image', async () => {
|
||||
// A camera-mode image that cannot be fetched swaps in a bundled stock
|
||||
// picture, so something is always on screen. That substitute must not be
|
||||
// announced as the camera's media arriving, or the failure it replaced
|
||||
@@ -589,6 +606,50 @@ describe('MediaUnavailableIssue', () => {
|
||||
expect(card.events.getEntries('advanced-camera-card:media:loaded')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should resolve the issue for a refreshing camera snapshot that recovers by itself', async () => {
|
||||
// Must use the real clock: the picture is refetched on a timer that fake
|
||||
// time would run through instantly, without the fetch in between ever being
|
||||
// answered.
|
||||
vi.useRealTimers();
|
||||
|
||||
const card = await MountedCardFactory.createFromSource(
|
||||
createStillImageCardConfig({
|
||||
status_bar: { style: 'outside' },
|
||||
|
||||
// Automatic retries disabled.
|
||||
view: { issues: { retry_seconds: 0 } },
|
||||
cameras: [
|
||||
{
|
||||
camera_entity: CAMERA_ENTITY,
|
||||
live_provider: 'image',
|
||||
image: { mode: 'camera', refresh_seconds: 1 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
createGenericCameraHASS({
|
||||
entities: {
|
||||
[CAMERA_ENTITY]: {
|
||||
state: 'idle',
|
||||
attributes: { entity_picture: createTemporarilyFailingMediaURL(1) },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
|
||||
await waitForIssueReported(card);
|
||||
|
||||
// A picture that is refetched on a timer can recover on its own, and the
|
||||
// issue has to recover with it.
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
await waitForIssueCleared(card);
|
||||
|
||||
expect(isLiveMediaShowing(card.card)).toBe(true);
|
||||
|
||||
// Cameras that fetch each second must not announce failures each second.
|
||||
expect(card.events.getEntries('advanced-camera-card:issue:trigger')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should report a player that reports a playback error', async () => {
|
||||
const card = await mountCard({
|
||||
// A provider given nothing to play. It reports that it failed without
|
||||
|
||||
@@ -230,6 +230,32 @@ describe('MediaUnavailableIssue', () => {
|
||||
|
||||
expect(issue.hasIssue()).toBe(false);
|
||||
});
|
||||
|
||||
it('should do nothing for a load on a target that never failed', () => {
|
||||
const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1'));
|
||||
|
||||
issue.resolve({ targetID: 'camera-1', cause: 'media-loaded' });
|
||||
|
||||
expect(issue.hasIssue()).toBe(false);
|
||||
});
|
||||
|
||||
describe('when the media loads', () => {
|
||||
it.each([
|
||||
['entity_unavailable' as const, false],
|
||||
['not_loading' as const, true],
|
||||
['playback_error' as const, false],
|
||||
['server_error' as const, true],
|
||||
['stalled' as const, false],
|
||||
['unsupported' as const, true],
|
||||
])('should reset a %s failure: %s', (reason, cleared) => {
|
||||
const issue = new MediaUnavailableIssue(createAPIDisplaying('camera-1'));
|
||||
|
||||
issue.trigger({ targetID: 'camera-1', reason });
|
||||
issue.resolve({ targetID: 'camera-1', cause: 'media-loaded' });
|
||||
|
||||
expect(issue.hasIssue()).toBe(!cleared);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('trigger', () => {
|
||||
|
||||
@@ -214,11 +214,29 @@ describe('EntityAvailabilityDetector', () => {
|
||||
|
||||
// Re-point at the (now available) entity from a fresh state.
|
||||
setEntityState('streaming');
|
||||
detector.reset();
|
||||
detector.invalidate('stream-changed');
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should keep the verdict when media loads', () => {
|
||||
const { detector, fireStateChange } = setup();
|
||||
detector.subscribe();
|
||||
fireStateChange('unavailable');
|
||||
vi.advanceTimersByTime(GRACE_MS);
|
||||
|
||||
// This detector watches the camera entity, so media loading tells it
|
||||
// nothing about the entity it is watching.
|
||||
detector.invalidate('media-loaded');
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'indirect',
|
||||
renderPlaceholder: true,
|
||||
reason: 'entity_unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
it('should re-check the entity when reset', () => {
|
||||
// always_error makes the re-check produce a verdict immediately rather than
|
||||
// waiting out the grace window.
|
||||
@@ -228,7 +246,7 @@ describe('EntityAvailabilityDetector', () => {
|
||||
// An entity that is already unavailable never fires a state change, so
|
||||
// resetting must read it rather than wait to be told.
|
||||
setEntityState('unavailable');
|
||||
detector.reset();
|
||||
detector.invalidate('stream-changed');
|
||||
|
||||
expect(detector.getVerdict()).toEqual(
|
||||
expect.objectContaining({ state: 'not_live', authority: 'hard' }),
|
||||
@@ -238,7 +256,7 @@ describe('EntityAvailabilityDetector', () => {
|
||||
it('should do nothing on reset before subscribe', () => {
|
||||
const { detector, stateWatcher } = setup();
|
||||
|
||||
detector.reset();
|
||||
detector.invalidate('stream-changed');
|
||||
|
||||
expect(stateWatcher.subscribe).not.toHaveBeenCalled();
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
@@ -249,7 +267,7 @@ describe('EntityAvailabilityDetector', () => {
|
||||
detector.subscribe();
|
||||
|
||||
setCameraEntity(null);
|
||||
detector.reset();
|
||||
detector.invalidate('stream-changed');
|
||||
|
||||
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
|
||||
@@ -248,12 +248,34 @@ describe('MediaPlayerLivenessDetector', () => {
|
||||
await callIntersectionHandler(true);
|
||||
fireMediaPlayerLiveness(false);
|
||||
|
||||
detector.reset();
|
||||
detector.invalidate('stream-changed');
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should keep the verdict when media loads', async () => {
|
||||
const { detector, loadMedia } = setup();
|
||||
const { player, unsubscribe, fireMediaPlayerLiveness } = createPlayer();
|
||||
detector.subscribe();
|
||||
loadMedia(player);
|
||||
|
||||
await callIntersectionHandler(true);
|
||||
fireMediaPlayerLiveness(false);
|
||||
|
||||
// The media player reported that media it had already loaded stopped
|
||||
// delivering frames, which another load does not answer.
|
||||
detector.invalidate('media-loaded');
|
||||
|
||||
expect(detector.getVerdict()).toEqual({
|
||||
state: 'not_live',
|
||||
authority: 'direct',
|
||||
renderPlaceholder: true,
|
||||
reason: 'stalled',
|
||||
});
|
||||
expect(unsubscribe).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should tear down the watch and stop listening on unsubscribe', async () => {
|
||||
const { detector, loadMedia } = setup();
|
||||
const first = createPlayer();
|
||||
|
||||
@@ -13,7 +13,7 @@ const createHostInDocument = (): HTMLElement => {
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('ProviderErrorDetector', () => {
|
||||
it('should not report a change when reset', () => {
|
||||
it('should not report a change when the stream changes', () => {
|
||||
const host = createHostInDocument();
|
||||
const onChange = vi.fn();
|
||||
const detector = new ProviderErrorDetector(host, onChange);
|
||||
@@ -21,7 +21,7 @@ describe('ProviderErrorDetector', () => {
|
||||
dispatchLiveErrorEvent(host);
|
||||
onChange.mockClear();
|
||||
|
||||
detector.reset();
|
||||
detector.invalidate('stream-changed');
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -93,14 +93,14 @@ describe('ProviderErrorDetector', () => {
|
||||
document.body.removeEventListener(LIVE_ERROR_EVENT, parentListener);
|
||||
});
|
||||
|
||||
it('should discard the verdict on reset', () => {
|
||||
it('should discard the verdict when the stream changes', () => {
|
||||
const host = createHostInDocument();
|
||||
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||
detector.subscribe();
|
||||
dispatchLiveErrorEvent(host);
|
||||
expect(detector.getVerdict().state).toBe('not_live');
|
||||
|
||||
detector.reset();
|
||||
detector.invalidate('stream-changed');
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
@@ -117,4 +117,31 @@ describe('ProviderErrorDetector', () => {
|
||||
expect(detector.getVerdict().state).toBe('unknown');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear the failure when media arrives', () => {
|
||||
const host = createHostInDocument();
|
||||
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||
detector.subscribe();
|
||||
dispatchLiveErrorEvent(host);
|
||||
|
||||
// The provider said it could not deliver the media, and then delivered it.
|
||||
detector.invalidate('media-loaded');
|
||||
|
||||
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||
});
|
||||
|
||||
it('should report a later error after media cleared the previous one', () => {
|
||||
const host = createHostInDocument();
|
||||
const onChange = vi.fn();
|
||||
const detector = new ProviderErrorDetector(host, onChange);
|
||||
detector.subscribe();
|
||||
dispatchLiveErrorEvent(host);
|
||||
detector.invalidate('media-loaded');
|
||||
onChange.mockClear();
|
||||
|
||||
dispatchLiveErrorEvent(host);
|
||||
|
||||
expect(detector.getVerdict().state).toBe('not_live');
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,6 +372,28 @@ describe('StreamLivenessController', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should stop reporting a provider error once media loads', () => {
|
||||
const { host, controller, issueResolves, failViaProviderError } = setup();
|
||||
controller.hostConnected();
|
||||
failViaProviderError({ reason: 'not_loading' });
|
||||
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent({ info: createMediaLoadedInfo() }));
|
||||
|
||||
// The detector holding the failure goes quiet rather than claiming the
|
||||
// stream is live, so this controller has nothing to state either way.
|
||||
expect(controller.isLive()).toBe(true);
|
||||
expect(issueResolves).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not resolve when media arrives with no failure to disprove', () => {
|
||||
const { host, controller, issueResolves } = setup();
|
||||
controller.hostConnected();
|
||||
|
||||
host.dispatchEvent(createMediaLoadedInfoEvent({ info: createMediaLoadedInfo() }));
|
||||
|
||||
expect(issueResolves).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not resolve while a hard failure exists', async () => {
|
||||
const { issueResolves, failViaProviderError, fireMediaPlayerLiveness } =
|
||||
await setupWithLiveStream();
|
||||
|
||||
@@ -176,19 +176,20 @@ describe('MediaLoadWatchdogController', () => {
|
||||
harness.mediaLoaded('camera-1');
|
||||
|
||||
expect(harness.resolveRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', cause: 'media-loaded' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should clear a failure it never reported itself', () => {
|
||||
it('should resolve on a load even when it reported no failure itself', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
// Another component can report the same kind of failure for this target.
|
||||
// A load resolves whatever failure the target has regardless of the
|
||||
// component that reported it -- not only this watchdog's own timeout.
|
||||
harness.mediaLoaded('camera-1');
|
||||
|
||||
expect(harness.resolveRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', cause: 'media-loaded' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -334,9 +335,11 @@ describe('MediaLoadWatchdogController', () => {
|
||||
harness.setTargetID('camera-2');
|
||||
harness.mediaLoaded('camera-2');
|
||||
|
||||
// The abandoned target gets only its not-loading failure resolved: no
|
||||
// load arrived for it.
|
||||
expect(harness.resolveRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
|
||||
{ key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' },
|
||||
{ key: 'media_unavailable', targetID: 'camera-2', cause: 'media-loaded' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
|
||||
const UNRELATED_ENTITY = 'input_boolean.unrelated';
|
||||
|
||||
const IMAGE_ERROR_EVENT = 'advanced-camera-card:image-updating-player:error';
|
||||
|
||||
// The size of the static image fixture the player loads. Everything downstream
|
||||
// sizes the card from what the player measured, so these are read from the
|
||||
// media rather than declared anywhere in the configuration.
|
||||
@@ -57,6 +59,28 @@ describe('AdvancedCameraCardImageUpdatingPlayer', () => {
|
||||
expect(loads[0].info.targetID).toBe(CAMERA_ENTITY);
|
||||
});
|
||||
|
||||
it('should ignore an image failure that arrives after the card leaves the page', async () => {
|
||||
const card = await mount();
|
||||
const player = await card.waitForSelector(
|
||||
'advanced-camera-card-image-updating-player',
|
||||
);
|
||||
const image = await card.waitForSelector('img');
|
||||
|
||||
const failures: Event[] = [];
|
||||
player.addEventListener(IMAGE_ERROR_EVENT, (ev) => failures.push(ev));
|
||||
|
||||
image.dispatchEvent(new Event('error'));
|
||||
expect(failures).toHaveLength(1);
|
||||
|
||||
// The browser answers a request the player made before the card came off
|
||||
// the page. Nobody is looking at the media it was for, so the player must
|
||||
// say nothing about it.
|
||||
card.detach();
|
||||
image.dispatchEvent(new Event('error'));
|
||||
|
||||
expect(failures).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should announce the size of the media itself', async () => {
|
||||
const card = await mount();
|
||||
|
||||
|
||||
@@ -179,6 +179,40 @@ describe('AdvancedCameraCardViewerCarousel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should build a new player for a failed clip when the retry control is used', async () => {
|
||||
vi.useFakeTimers();
|
||||
onTestFinished(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const { card, frigate } = await mountCardWithFrigate(EVENTS, {
|
||||
// Automatic retries switched off, so the control below is the only one.
|
||||
view: { default: 'clips', issues: { retry_seconds: 0 } },
|
||||
status_bar: { style: 'outside' },
|
||||
});
|
||||
frigate.setMediaURL('newer', 'clips', createFailingMediaURL());
|
||||
|
||||
await waitForThumbnails(card, EVENTS.length);
|
||||
await clickThumbnail(card.card, 0);
|
||||
const failedPlayer = await card.waitForSelector('video');
|
||||
await card.advanceSeconds(MEDIA_LOADING_TIMEOUT_SECONDS);
|
||||
await card.waitForRender(
|
||||
() => getStatusBarItem(card.card, MEDIA_ISSUE_TITLE),
|
||||
`the ${MEDIA_ISSUE_TITLE} issue being reported`,
|
||||
);
|
||||
|
||||
await card.clickControl(MEDIA_ISSUE_TITLE);
|
||||
await card.clickControl('Retry');
|
||||
|
||||
// A second player, not the one that failed: the retry throws the old one
|
||||
// away, and the replacement has to render and ask for the clip again rather
|
||||
// than sit there empty.
|
||||
await card.waitForRender(() => {
|
||||
const player = deepQuery(card.card, 'video');
|
||||
return player && player !== failedPlayer ? player : null;
|
||||
}, 'the clip player being rebuilt');
|
||||
});
|
||||
|
||||
it('should return to the gallery from the viewer', async () => {
|
||||
const card = await mountViewer(EVENTS, {
|
||||
config: {
|
||||
|
||||
Reference in New Issue
Block a user