diff --git a/src/card-controller/issues/issues/media-unavailable.ts b/src/card-controller/issues/issues/media-unavailable.ts index b84a7f8e..21c91285 100644 --- a/src/card-controller/issues/issues/media-unavailable.ts +++ b/src/card-controller/issues/issues/media-unavailable.ts @@ -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; } diff --git a/src/components-lib/live/liveness/detectors/entity-availability.ts b/src/components-lib/live/liveness/detectors/entity-availability.ts index 3a6e5785..c2c72d27 100644 --- a/src/components-lib/live/liveness/detectors/entity-availability.ts +++ b/src/components-lib/live/liveness/detectors/entity-availability.ts @@ -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(); } diff --git a/src/components-lib/live/liveness/detectors/media-player-liveness.ts b/src/components-lib/live/liveness/detectors/media-player-liveness.ts index eb37acba..171b65bf 100644 --- a/src/components-lib/live/liveness/detectors/media-player-liveness.ts +++ b/src/components-lib/live/liveness/detectors/media-player-liveness.ts @@ -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 { diff --git a/src/components-lib/live/liveness/detectors/provider-error.ts b/src/components-lib/live/liveness/detectors/provider-error.ts index ac23080c..de8e628c 100644 --- a/src/components-lib/live/liveness/detectors/provider-error.ts +++ b/src/components-lib/live/liveness/detectors/provider-error.ts @@ -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' }; } diff --git a/src/components-lib/live/liveness/stream-liveness-controller.ts b/src/components-lib/live/liveness/stream-liveness-controller.ts index 954dc816..1b98061e 100644 --- a/src/components-lib/live/liveness/stream-liveness-controller.ts +++ b/src/components-lib/live/liveness/stream-liveness-controller.ts @@ -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(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(this._host, 'issue:resolve', { - key: 'media_unavailable', - targetID, - }); + resolveMediaUnavailableIssue(this._host, { targetID }); } } diff --git a/src/components-lib/media-load-watchdog-controller.ts b/src/components-lib/media-load-watchdog-controller.ts index 51fda764..c419f2ec 100644 --- a/src/components-lib/media-load-watchdog-controller.ts +++ b/src/components-lib/media-load-watchdog-controller.ts @@ -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(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(this._host, 'issue:trigger', { - key: 'media_unavailable', - targetID, - reason: 'not_loading', - }); + triggerMediaUnavailableIssue(this._host, { targetID, reason: 'not_loading' }); } } diff --git a/src/components-lib/media-unavailable-issue.ts b/src/components-lib/media-unavailable-issue.ts new file mode 100644 index 00000000..dfe0a70a --- /dev/null +++ b/src/components-lib/media-unavailable-issue.ts @@ -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(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(element, 'issue:resolve', { + key: 'media_unavailable', + ...context, + }); +} diff --git a/src/components/image-updating-player.ts b/src/components/image-updating-player.ts index b7ec376c..361943bf 100644 --- a/src/components/image-updating-player.ts +++ b/src/components/image-updating-player.ts @@ -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( - this, - 'issue:trigger', - { - key: 'media_unavailable', - targetID: this.targetID, - reason: 'not_loading', - }, - ); - } + this._dispatchError('not_loading'); }} /> ` diff --git a/src/components/image.ts b/src/components/image.ts index 402633b2..68b0e412 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -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, + ) => + // 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, + })} > `, diff --git a/src/components/viewer/carousel.ts b/src/components/viewer/carousel.ts index c32356ae..7981b47a 100644 --- a/src/components/viewer/carousel.ts +++ b/src/components/viewer/carousel.ts @@ -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} >`, )} `; diff --git a/src/components/viewer/provider.ts b/src/components/viewer/provider.ts index 8d77e594..f20cd0f0 100644 --- a/src/components/viewer/provider.ts +++ b/src/components/viewer/provider.ts @@ -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 = 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, }); } diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 1b026cdf..6a3a6c1e 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -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.", diff --git a/tests/browser/test-media.ts b/tests/browser/test-media.ts index eafec7fd..f86d957c 100644 --- a/tests/browser/test-media.ts +++ b/tests/browser/test-media.ts @@ -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/`, behaving as the query asks: * diff --git a/tests/card-controller/issues/issues/media-unavailable.browser.test.ts b/tests/card-controller/issues/issues/media-unavailable.browser.test.ts index ad62c10e..89aeebe3 100644 --- a/tests/card-controller/issues/issues/media-unavailable.browser.test.ts +++ b/tests/card-controller/issues/issues/media-unavailable.browser.test.ts @@ -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 diff --git a/tests/card-controller/issues/issues/media-unavailable.test.ts b/tests/card-controller/issues/issues/media-unavailable.test.ts index 783850bd..988573b4 100644 --- a/tests/card-controller/issues/issues/media-unavailable.test.ts +++ b/tests/card-controller/issues/issues/media-unavailable.test.ts @@ -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', () => { diff --git a/tests/components-lib/live/liveness/detectors/entity-availability.test.ts b/tests/components-lib/live/liveness/detectors/entity-availability.test.ts index 41ebf74d..67ca7520 100644 --- a/tests/components-lib/live/liveness/detectors/entity-availability.test.ts +++ b/tests/components-lib/live/liveness/detectors/entity-availability.test.ts @@ -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' }); diff --git a/tests/components-lib/live/liveness/detectors/media-player-liveness.test.ts b/tests/components-lib/live/liveness/detectors/media-player-liveness.test.ts index ebdb6640..9df81a00 100644 --- a/tests/components-lib/live/liveness/detectors/media-player-liveness.test.ts +++ b/tests/components-lib/live/liveness/detectors/media-player-liveness.test.ts @@ -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(); diff --git a/tests/components-lib/live/liveness/detectors/provider-error.test.ts b/tests/components-lib/live/liveness/detectors/provider-error.test.ts index a90ea69e..19a3f303 100644 --- a/tests/components-lib/live/liveness/detectors/provider-error.test.ts +++ b/tests/components-lib/live/liveness/detectors/provider-error.test.ts @@ -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); + }); }); diff --git a/tests/components-lib/live/liveness/stream-liveness-controller.test.ts b/tests/components-lib/live/liveness/stream-liveness-controller.test.ts index b5c4ba86..ee349abd 100644 --- a/tests/components-lib/live/liveness/stream-liveness-controller.test.ts +++ b/tests/components-lib/live/liveness/stream-liveness-controller.test.ts @@ -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(); diff --git a/tests/components-lib/media-load-watchdog-controller.test.ts b/tests/components-lib/media-load-watchdog-controller.test.ts index ed56ce71..ca313fe4 100644 --- a/tests/components-lib/media-load-watchdog-controller.test.ts +++ b/tests/components-lib/media-load-watchdog-controller.test.ts @@ -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' }, ]); }); diff --git a/tests/components/image-updating-player.browser.test.ts b/tests/components/image-updating-player.browser.test.ts index 9a79d452..77eb35e2 100644 --- a/tests/components/image-updating-player.browser.test.ts +++ b/tests/components/image-updating-player.browser.test.ts @@ -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(); diff --git a/tests/components/viewer/carousel.browser.test.ts b/tests/components/viewer/carousel.browser.test.ts index 18e5df15..ca8dca1a 100644 --- a/tests/components/viewer/carousel.browser.test.ts +++ b/tests/components/viewer/carousel.browser.test.ts @@ -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: {