fix: Report and retry media failures for every camera in a grid (#2658)
- Closes: #2637 - Related: #2099
This commit is contained in:
@@ -32,7 +32,7 @@ export const createIssueManager = (
|
||||
manager.addIssue(new InitializationIssue(api));
|
||||
manager.addIssue(new LegacyResourceIssue(changeCallback));
|
||||
manager.addIssue(new MediaQueryIssue(api));
|
||||
manager.addIssue(new MediaUnavailableIssue(api, changeCallback));
|
||||
manager.addIssue(new MediaUnavailableIssue(api));
|
||||
|
||||
return manager;
|
||||
};
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type { IssueResolveContext, IssueTriggerContext } from 'issue';
|
||||
|
||||
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
|
||||
import type {
|
||||
Notification,
|
||||
NotificationDetail,
|
||||
} from '../../../config/schema/actions/types.js';
|
||||
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
|
||||
import { localize } from '../../../localize/localize.js';
|
||||
import type { UnsubscribeCallback } from '../../../types.js';
|
||||
import { Timer } from '../../../utils/timer.js';
|
||||
import { getDisplayedTargetIDs } from '../../../view/layout.js';
|
||||
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js';
|
||||
import { isAnyMediaViewName } from '../../../view/view.js';
|
||||
import type { CardIssueManagerAPI } from '../../types.js';
|
||||
import { createRetryControl } from '../retry-control.js';
|
||||
import type { Issue, IssueDescription } from '../types.js';
|
||||
@@ -40,6 +37,9 @@ declare module 'issue' {
|
||||
interface IssueResolveContext {
|
||||
media_unavailable: {
|
||||
targetID: string;
|
||||
|
||||
// Optionally limits the clearing to one kind of failure.
|
||||
reason?: MediaUnavailableIssueReason;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,6 @@ interface TargetError {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
|
||||
|
||||
// The per-cause presentation (localization key + icon), shared by the
|
||||
// notification metadata and the reconnecting placeholder so each cause is
|
||||
// described in exactly one place.
|
||||
@@ -85,33 +83,20 @@ export const MEDIA_UNAVAILABLE_REASONS: Record<
|
||||
},
|
||||
};
|
||||
|
||||
// Reports media failures for the targets the user can currently see. Failures
|
||||
// are raised and cleared by the components observing the media (e.g. providers)
|
||||
// -- player errors and stalls via the liveness detectors, and a load that never
|
||||
// arrives via each media host's load watchdog. This issue scopes them to the
|
||||
// displayed targets and drives the throttled reload that retries them.
|
||||
export class MediaUnavailableIssue implements Issue {
|
||||
public readonly key = 'media_unavailable' as const;
|
||||
|
||||
private _issueActive = false;
|
||||
private _erroredTargets = new Map<string, TargetError>();
|
||||
|
||||
// Timer fires when a target has been loading too long without success.
|
||||
private _timer = new Timer();
|
||||
private _timerTargetID: string | null = null;
|
||||
|
||||
private _api: CardIssueManagerAPI;
|
||||
private _onChange: (() => void) | null;
|
||||
private _unsubscribeCallback: UnsubscribeCallback;
|
||||
|
||||
constructor(api: CardIssueManagerAPI, onChange?: () => void) {
|
||||
constructor(api: CardIssueManagerAPI) {
|
||||
this._api = api;
|
||||
this._onChange = onChange ?? null;
|
||||
|
||||
// React to a target's media loading; unload / select changes are
|
||||
// irrelevant here.
|
||||
this._unsubscribeCallback = this._api
|
||||
.getMediaLoadedInfoManager()
|
||||
.subscribe((change) => {
|
||||
if (change.type === 'load') {
|
||||
this._onMediaLoad(change.targetID);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -126,67 +111,29 @@ export class MediaUnavailableIssue implements Issue {
|
||||
});
|
||||
}
|
||||
|
||||
// A target is proven to be delivering media again. Stronger evidence than a
|
||||
// media load, which only says a player attached, so it clears any recorded
|
||||
// error.
|
||||
public resolve(context: IssueResolveContext['media_unavailable']): void {
|
||||
const error = this._erroredTargets.get(context.targetID);
|
||||
if (!error || (context.reason && context.reason !== error.reason)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._erroredTargets.delete(context.targetID);
|
||||
this._cancelPendingTimer(context.targetID);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Detection -- called by the manager on every state change.
|
||||
// =========================================================================
|
||||
|
||||
public detectDynamic(state: ConditionState): void {
|
||||
if (!isAnyMediaViewName(state.view)) {
|
||||
this._deactivate();
|
||||
return;
|
||||
}
|
||||
|
||||
// A known error for the current target activates immediately, even if its
|
||||
// (frozen) media still reads as loaded (it might be loaded but then
|
||||
// reported a playback error that stops playback but leaves the player
|
||||
// attached). Errors are cleared out-of-band, by `resolve` or by
|
||||
// `_onMediaLoad`.
|
||||
if (this._hasError(state)) {
|
||||
this._activate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.mediaLoadedInfo) {
|
||||
// Loaded with no known error: healthy.
|
||||
this._deactivate();
|
||||
return;
|
||||
}
|
||||
|
||||
this._handlePendingLoad(state);
|
||||
}
|
||||
|
||||
// A load proves media attached for the target. That ends any wait on it, and
|
||||
// refutes a `not_loading` error. It is no evidence of recovery for any other
|
||||
// reason, so those clear only via `resolve`.
|
||||
private _onMediaLoad(targetID: string): void {
|
||||
let changed = this._cancelPendingTimer(targetID);
|
||||
if (this._erroredTargets.get(targetID)?.reason === 'not_loading') {
|
||||
this._erroredTargets.delete(targetID);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
this._onChange?.();
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// State queries -- called by the manager to read current state.
|
||||
// =========================================================================
|
||||
|
||||
// Reported exactly when a target on screen has a known failure, even if its
|
||||
// (frozen) media still reads as loaded (it might be loaded but then reported
|
||||
// a playback error that stops playback but leaves the player attached).
|
||||
// Failures are cleared out-of-band, by `resolve`.
|
||||
public hasIssue(): boolean {
|
||||
return this._issueActive;
|
||||
return !!this._getDisplayedErrors().size;
|
||||
}
|
||||
|
||||
public getIssue(): IssueDescription | null {
|
||||
if (!this._issueActive) {
|
||||
if (!this.hasIssue()) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@@ -197,20 +144,7 @@ export class MediaUnavailableIssue implements Issue {
|
||||
}
|
||||
|
||||
public getNotification(): Notification {
|
||||
const targets = new Map(this._erroredTargets);
|
||||
|
||||
// The pending-load timer's target is a slow initial load that has not yet
|
||||
// errored. Gate on the timer still running: once it is stopped (a hard error
|
||||
// on another target took over, or the view moved on), _timerTargetID lingers
|
||||
// and would otherwise paint a stale "not loading" line for a target that has
|
||||
// since loaded.
|
||||
if (
|
||||
this._timerTargetID &&
|
||||
this._timer.isRunning() &&
|
||||
!targets.has(this._timerTargetID)
|
||||
) {
|
||||
targets.set(this._timerTargetID, { reason: 'not_loading' });
|
||||
}
|
||||
const targets = this._getDisplayedErrors();
|
||||
|
||||
// The free-text causes go in the context block rather than on the metadata
|
||||
// lines, which stay short enough to scan when several cameras fail at once.
|
||||
@@ -269,20 +203,11 @@ export class MediaUnavailableIssue implements Issue {
|
||||
// =========================================================================
|
||||
|
||||
public needsRetry(): boolean {
|
||||
return this._issueActive;
|
||||
return this.hasIssue();
|
||||
}
|
||||
|
||||
public retry(): boolean {
|
||||
// Build the set of targets to retry: all errored targets plus the
|
||||
// target the pending timer is tracking (so a user-initiated retry
|
||||
// works even before the timeout fires). A stopped timer leaves
|
||||
// _timerTargetID behind, so gate on it still running: that target may
|
||||
// since have loaded.
|
||||
const retryTargets = new Set(this._erroredTargets.keys());
|
||||
if (this._timerTargetID && this._timer.isRunning()) {
|
||||
retryTargets.add(this._timerTargetID);
|
||||
}
|
||||
|
||||
const retryTargets = this._getDisplayedErrors();
|
||||
if (!retryTargets.size) {
|
||||
return false;
|
||||
}
|
||||
@@ -291,16 +216,15 @@ export class MediaUnavailableIssue implements Issue {
|
||||
// only way to rebuild a stream from scratch.
|
||||
const view = this._api.getViewManager().getView();
|
||||
const mediaEpoch = { ...(view?.context?.mediaEpoch ?? {}) };
|
||||
for (const id of retryTargets) {
|
||||
for (const id of retryTargets.keys()) {
|
||||
mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Intentionally keep _issueActive, _erroredTargets, and the pending
|
||||
// timer in place. The issue stays visible while the provider re-attempts
|
||||
// loading underneath. If the retry succeeds, the fresh load clears a
|
||||
// not-loading error and the rebuilt provider's liveness observation
|
||||
// resolves a stream error. If it fails silently (e.g. bogus stream name),
|
||||
// the error stays visible immediately -- no new 10s grace period.
|
||||
// Intentionally keep _erroredTargets in place. The issue stays visible
|
||||
// while the provider re-attempts loading underneath. If the retry succeeds,
|
||||
// the fresh load clears a not-loading error and the rebuilt provider's
|
||||
// liveness observation resolves a stream error. If it fails silently (e.g.
|
||||
// bogus stream name), the error stays visible immediately.
|
||||
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
|
||||
return false;
|
||||
}
|
||||
@@ -310,83 +234,27 @@ export class MediaUnavailableIssue implements Issue {
|
||||
// =========================================================================
|
||||
|
||||
public reset(): void {
|
||||
this._deactivate();
|
||||
this._erroredTargets.clear();
|
||||
}
|
||||
|
||||
// Stop reacting to media loads at end of life.
|
||||
public destroy(): void {
|
||||
this._unsubscribeCallback();
|
||||
}
|
||||
|
||||
// Stop the pending-load timer so offscreen time doesn't count toward the
|
||||
// 10s threshold. Preserve _issueActive, _erroredTargets, and
|
||||
// _timerTargetID: already-visible errors remain visible on reattach, and
|
||||
// retaining _timerTargetID lets the existing active/target-mismatch guard
|
||||
// in _handlePendingLoad avoid spuriously deactivating the preserved
|
||||
// issue when the same target is still loading on resume. The timer is
|
||||
// re-armed with a fresh window by the next detectDynamic pass.
|
||||
public suspend(): void {
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private helpers.
|
||||
// =========================================================================
|
||||
|
||||
// Stop waiting on a target's load, if it is the one being waited on. Returns
|
||||
// whether it was.
|
||||
private _cancelPendingTimer(targetID: string): boolean {
|
||||
if (this._timerTargetID !== targetID) {
|
||||
return false;
|
||||
}
|
||||
this._timer.stop();
|
||||
this._timerTargetID = null;
|
||||
return true;
|
||||
// The errored targets the user can currently see. An error recorded for a
|
||||
// target that has since left the screen names something they cannot look at,
|
||||
// and reloading it would achieve nothing. Read fresh rather than remembered:
|
||||
// a change in conditions can re-evaluate an override that replaces the
|
||||
// configured cameras, leaving the view exactly as it was.
|
||||
private _getDisplayedErrors(): Map<string, TargetError> {
|
||||
const view = this._api.getViewManager().getView();
|
||||
if (!view) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
// Media not yet loaded and no known error: start (or keep) a timeout to catch
|
||||
// a slow or failed initial load. No targetID means no provider is rendering
|
||||
// media (e.g. the viewer shows "No media to display"), so there's nothing to
|
||||
// wait for.
|
||||
private _handlePendingLoad(state: ConditionState): void {
|
||||
if (!state.targetID) {
|
||||
this._deactivate();
|
||||
return;
|
||||
}
|
||||
|
||||
const targetID = state.targetID;
|
||||
|
||||
// When the target changes, clear the active state so the new target gets
|
||||
// its own timeout window instead of inheriting the previous target's.
|
||||
if (this._issueActive && this._timerTargetID !== targetID) {
|
||||
this._deactivate();
|
||||
}
|
||||
|
||||
// Start (or restart) the timer for this target.
|
||||
if (!this._timer.isRunning() || this._timerTargetID !== targetID) {
|
||||
this._timerTargetID = targetID;
|
||||
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
|
||||
// Record the error on timeout so retry() knows which epoch to bump.
|
||||
this.trigger({ targetID, reason: 'not_loading' });
|
||||
this._activate();
|
||||
this._onChange?.();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _hasError(state: ConditionState): boolean {
|
||||
return !!state.targetID && this._erroredTargets.has(state.targetID);
|
||||
}
|
||||
|
||||
private _activate(): void {
|
||||
this._timer.stop();
|
||||
this._issueActive = true;
|
||||
}
|
||||
|
||||
private _deactivate(): void {
|
||||
this._timer.stop();
|
||||
this._timerTargetID = null;
|
||||
this._issueActive = false;
|
||||
const displayedTargetIDs = getDisplayedTargetIDs(view, this._api.getCameraManager());
|
||||
return new Map(
|
||||
[...this._erroredTargets].filter(([targetID]) => displayedTargetIDs.has(targetID)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||
|
||||
import type {
|
||||
IssueResolveEventData,
|
||||
IssueTriggerEventData,
|
||||
} from '../card-controller/issues/types';
|
||||
import type { MediaLoadedInfoEventDetail } from '../types';
|
||||
import { onAbort } from '../utils/abort-signal';
|
||||
import { Generation } from '../utils/concurrency/generation';
|
||||
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
|
||||
import { Timer } from '../utils/timer';
|
||||
|
||||
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
|
||||
|
||||
export const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
|
||||
|
||||
interface MediaLoadWatchdogConfig {
|
||||
getTargetID: () => string | null;
|
||||
|
||||
// Whether the host is currently trying to load media. False when loading is
|
||||
// held back (e.g. lazy loading), or when the host is showing a failure of its
|
||||
// own rather than waiting for media.
|
||||
isLoadExpected: () => boolean;
|
||||
|
||||
// Changes when the media underneath is rebuilt without the host itself being
|
||||
// rebuilt (e.g. a retry re-keying a player below it), so the wait starts
|
||||
// again. Omitted by a host that a retry replaces outright.
|
||||
getAttemptID?: () => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches a media host's load: when the host expects media but none arrives
|
||||
* within the window, raises a `media_unavailable` issue with reason
|
||||
* `not_loading`, and clears it again once media does arrive.
|
||||
*
|
||||
* Loads are observed from the host's own bubble path, so a `media:loaded`
|
||||
* dispatched by any descendant player ends the wait, and that media going away
|
||||
* (its event's abort signal) restarts it if a load is still expected.
|
||||
*
|
||||
* A host may be reused for a succession of targets (a viewer slide swiped from
|
||||
* one media item to the next), so everything learned is scoped to the target it
|
||||
* was learned about. A target change starts a fresh wait and resolves any
|
||||
* failure reported for the target left behind.
|
||||
*/
|
||||
export class MediaLoadWatchdogController implements ReactiveController {
|
||||
private _host: ReactiveControllerHost & HTMLElement;
|
||||
private _config: MediaLoadWatchdogConfig;
|
||||
|
||||
private _timer = new Timer();
|
||||
|
||||
// What the state below describes: the target, and which attempt at loading it
|
||||
// (see `getAttemptID`). A change in either means everything known is about
|
||||
// media the host has moved on from.
|
||||
private _targetID: string | null = null;
|
||||
private _attemptID: unknown = undefined;
|
||||
|
||||
private _mediaLoaded = false;
|
||||
|
||||
// Identifies which load `_mediaLoaded` describes. One player can replace
|
||||
// another for the same target before the first has gone away, and the first
|
||||
// going away must not be read as the current media being lost.
|
||||
private _loadGeneration = new Generation();
|
||||
|
||||
// Whether the timeout has fired for the current attempt, so a hung load is
|
||||
// reported only once. A retry is a new attempt and is waited on afresh.
|
||||
private _fired = false;
|
||||
|
||||
// The target this watchdog has an outstanding failure reported for, so that
|
||||
// failure can be resolved if the host moves on to another target. It
|
||||
// outlives the attempt that reported it, which keeps the failure on screen
|
||||
// while a retry runs underneath.
|
||||
private _reportedTargetID: string | null = null;
|
||||
|
||||
// Media loads, and the media going away, are observed through listeners and
|
||||
// abort signals that can outlive a disconnect, and a detached host must not be
|
||||
// waited on.
|
||||
private _connected = false;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost & HTMLElement,
|
||||
config: MediaLoadWatchdogConfig,
|
||||
) {
|
||||
this._host = host;
|
||||
this._config = config;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._connected = true;
|
||||
this._host.addEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
|
||||
this._evaluate();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._connected = false;
|
||||
this._host.removeEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
public hostUpdated(): void {
|
||||
this._evaluate();
|
||||
}
|
||||
|
||||
private _forgetLoad(): void {
|
||||
this._mediaLoaded = false;
|
||||
this._fired = false;
|
||||
this._loadGeneration.invalidate();
|
||||
this._timer.stop();
|
||||
}
|
||||
|
||||
private _onMediaLoaded = (ev: CustomEvent<MediaLoadedInfoEventDetail>): void => {
|
||||
const targetID = ev.detail.info.targetID;
|
||||
|
||||
// The host's own target is read fresh so a load arriving as the host
|
||||
// switches is attributed to whichever target it actually describes.
|
||||
if (!targetID || targetID !== this._config.getTargetID()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This load replaces whatever was being followed, which may be a different
|
||||
// target the host has just moved off.
|
||||
this._syncAttempt();
|
||||
this._forgetLoad();
|
||||
|
||||
this._mediaLoaded = true;
|
||||
const generation = this._loadGeneration.next();
|
||||
|
||||
// Media arriving disproves a not-loading failure whoever reported it.
|
||||
this._resolveFailure(targetID);
|
||||
|
||||
// Media going away is not itself a failure, but a load that is still
|
||||
// expected afterwards needs a fresh wait. This is recorded whether or not
|
||||
// the host is attached, since a detached host has no media either.
|
||||
onAbort(ev.detail.signal, () => {
|
||||
if (targetID !== this._targetID || !this._loadGeneration.isCurrent(generation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._mediaLoaded = false;
|
||||
this._evaluate();
|
||||
});
|
||||
};
|
||||
|
||||
// Clear a target's not-loading failure, whichever component reported it.
|
||||
// Naming the reason leaves a failure reported for any other reason alone.
|
||||
private _resolveFailure(targetID: string): void {
|
||||
if (this._reportedTargetID === targetID) {
|
||||
this._reportedTargetID = null;
|
||||
}
|
||||
|
||||
fireAdvancedCameraCardEvent<IssueResolveEventData>(this._host, 'issue:resolve', {
|
||||
key: 'media_unavailable',
|
||||
targetID,
|
||||
reason: 'not_loading',
|
||||
});
|
||||
}
|
||||
|
||||
// Discard everything learned about a previous target, or a previous attempt
|
||||
// at the same one.
|
||||
private _syncAttempt(): void {
|
||||
const targetID = this._config.getTargetID();
|
||||
const attemptID = this._config.getAttemptID?.();
|
||||
if (targetID === this._targetID && attemptID === this._attemptID) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If an abandoned target was previously reported, resolve it so it's not
|
||||
// stuck forever
|
||||
if (this._reportedTargetID && this._reportedTargetID !== targetID) {
|
||||
this._resolveFailure(this._reportedTargetID);
|
||||
}
|
||||
|
||||
this._targetID = targetID;
|
||||
this._attemptID = attemptID;
|
||||
this._forgetLoad();
|
||||
}
|
||||
|
||||
private _evaluate(): void {
|
||||
if (!this._connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._syncAttempt();
|
||||
|
||||
if (!this._targetID || this._mediaLoaded || !this._config.isLoadExpected()) {
|
||||
this._fired = false;
|
||||
this._timer.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._fired && !this._timer.isRunning()) {
|
||||
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => this._onTimeout());
|
||||
}
|
||||
}
|
||||
|
||||
private _onTimeout(): void {
|
||||
const targetID = this._targetID;
|
||||
|
||||
// Conditions may have changed while the timer matured.
|
||||
if (
|
||||
!this._connected ||
|
||||
!targetID ||
|
||||
targetID !== this._config.getTargetID() ||
|
||||
this._attemptID !== this._config.getAttemptID?.() ||
|
||||
this._mediaLoaded ||
|
||||
!this._config.isLoadExpected()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._fired = true;
|
||||
this._reportedTargetID = targetID;
|
||||
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this._host, 'issue:trigger', {
|
||||
key: 'media_unavailable',
|
||||
targetID,
|
||||
reason: 'not_loading',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,13 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
|
||||
private _refImage: Ref<HTMLImageElement> = createRef();
|
||||
|
||||
// Whether the <img> currently holds the stock image swapped in by
|
||||
// `_forceSafeImage` rather than the intended media. The safe image load must
|
||||
// not be announced as real media arriving, which may otherwise report a
|
||||
// broken camera as healthy. Cleared on the next render, which restores the
|
||||
// real source.
|
||||
private _showingSafeImage = false;
|
||||
|
||||
private _cachedValueController = new CachedValueController(
|
||||
this,
|
||||
() => this._getEffectiveRefreshSeconds(),
|
||||
@@ -234,6 +241,10 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
* @param _changedProps The changed properties
|
||||
*/
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
// The render (below) restores the real source (via `live()`), so its load
|
||||
// is the real media again.
|
||||
this._showingSafeImage = false;
|
||||
|
||||
const relevantEntity = this._getRelevantEntityForMode(
|
||||
resolveImageMode({
|
||||
imageConfig: this.imageConfig,
|
||||
@@ -454,6 +465,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
*/
|
||||
private _forceSafeImage(stockOnly?: boolean): void {
|
||||
if (this._refImage.value) {
|
||||
this._showingSafeImage = true;
|
||||
// Avoid restoring the raw configured URL when proxying is enabled, since
|
||||
// that would bypass the proxied/signed URL path on visibility changes.
|
||||
const configuredURL =
|
||||
@@ -495,6 +507,10 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
||||
${ref(this._refImage)}
|
||||
src=${live(src)}
|
||||
@load=${(ev: Event) => {
|
||||
if (this._showingSafeImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaLoadedInfo = createMediaLoadedInfo(ev, {
|
||||
mediaPlayerController: this._mediaPlayerController,
|
||||
capabilities: {
|
||||
|
||||
+39
-10
@@ -12,6 +12,7 @@ import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
||||
|
||||
import type { CameraManager } from '../camera-manager/manager';
|
||||
import type { ViewManagerEpoch } from '../card-controller/view/types';
|
||||
import { MediaLoadWatchdogController } from '../components-lib/media-load-watchdog-controller';
|
||||
import type { ZoomSettingsObserved } from '../components-lib/zoom/types';
|
||||
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context';
|
||||
import type { CameraConfig } from '../config/schema/cameras';
|
||||
@@ -59,6 +60,40 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
|
||||
|
||||
private _refImage: Ref<MediaPlayerElement> = createRef();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// No lazy loading: Reports the image as a media_unavailable issue if it
|
||||
// never arrives.
|
||||
new MediaLoadWatchdogController(this, {
|
||||
getTargetID: () => IMAGE_VIEW_TARGET_ID_SENTINEL,
|
||||
isLoadExpected: () =>
|
||||
// A misconfigured image already shows why nothing can be drawn, and
|
||||
// reloading it cannot change the configuration.
|
||||
!!this.hass && !this._getConfigurationError(),
|
||||
|
||||
// Player is keyed on epoch.
|
||||
getAttemptID: () => this._getMediaEpoch(),
|
||||
});
|
||||
}
|
||||
|
||||
private _getMediaEpoch(): number {
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
return view?.context?.mediaEpoch?.[IMAGE_VIEW_TARGET_ID_SENTINEL] ?? 0;
|
||||
}
|
||||
|
||||
// Returns the reason no image can be shown at all, or null when one can.
|
||||
// `camera` mode has nothing to draw from without a camera.
|
||||
private _getConfigurationError(): string | null {
|
||||
const mode = resolveImageMode({
|
||||
imageConfig: this.imageConfig,
|
||||
cameraConfig: this.cameraConfig,
|
||||
});
|
||||
return mode === 'camera' && !this.cameraConfig
|
||||
? localize('error.no_camera_for_image')
|
||||
: null;
|
||||
}
|
||||
|
||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||
await this.updateComplete;
|
||||
return (await this._refImage.value?.getMediaPlayerController()) ?? null;
|
||||
@@ -118,24 +153,18 @@ export class AdvancedCameraCardImage extends LitElement implements MediaPlayer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine if this image mode requires a camera
|
||||
const mode = resolveImageMode({
|
||||
imageConfig: this.imageConfig,
|
||||
cameraConfig: this.cameraConfig,
|
||||
});
|
||||
|
||||
if (mode === 'camera' && !this.cameraConfig) {
|
||||
return renderNotificationBlockFromText(localize('error.no_camera_for_image'), {
|
||||
const configurationError = this._getConfigurationError();
|
||||
if (configurationError) {
|
||||
return renderNotificationBlockFromText(configurationError, {
|
||||
icon: 'mdi:camera-off',
|
||||
});
|
||||
}
|
||||
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
const mediaEpoch = view?.context?.mediaEpoch?.[IMAGE_VIEW_TARGET_ID_SENTINEL] ?? 0;
|
||||
|
||||
return this._renderContainer(html`
|
||||
${keyed(
|
||||
mediaEpoch,
|
||||
this._getMediaEpoch(),
|
||||
html`
|
||||
<advanced-camera-card-image-updating-player
|
||||
${ref(this._refImage)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { CardWideConfig } from '../../config/schema/types.js';
|
||||
import type { HomeAssistant } from '../../ha/types.js';
|
||||
import liveGridStyle from '../../scss/live-grid.scss?inline';
|
||||
import { contentsChanged } from '../../utils/basic.js';
|
||||
import { getLiveGridCameraIDs } from '../../view/layout.js';
|
||||
|
||||
import './carousel.js';
|
||||
|
||||
@@ -97,26 +98,22 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _needsGrid(): boolean {
|
||||
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
||||
private _getGridCameraIDs(): Set<string> | null {
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
return (
|
||||
!!view?.isGrid() &&
|
||||
!!view?.supportsMultipleDisplayModes() &&
|
||||
!!cameraIDs &&
|
||||
cameraIDs.size > 1
|
||||
);
|
||||
return view && this.cameraManager
|
||||
? getLiveGridCameraIDs(view, this.cameraManager)
|
||||
: null;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
|
||||
if (changedProps.has('viewManagerEpoch') && this._getGridCameraIDs()) {
|
||||
void import('../media-grid.js');
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
const cameraIDs = this.cameraManager?.getStore().getCameraIDsWithCapability('live');
|
||||
if (!cameraIDs?.size || !this._needsGrid()) {
|
||||
const cameraIDs = this._getGridCameraIDs();
|
||||
if (!cameraIDs) {
|
||||
return this._renderCarousel();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MEDIA_UNAVAILABLE_REASONS } from '../../card-controller/issues/issues/m
|
||||
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||
import { isAudioIntendedOnLoad } from '../../components-lib/live/audio-intent.js';
|
||||
import { StreamLivenessController } from '../../components-lib/live/liveness/stream-liveness-controller.js';
|
||||
import { MediaLoadWatchdogController } from '../../components-lib/media-load-watchdog-controller.js';
|
||||
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
|
||||
import type { PartialZoomSettings } from '../../components-lib/zoom/types.js';
|
||||
import type { LiveConfig } from '../../config/schema/live.js';
|
||||
@@ -110,6 +111,24 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
|
||||
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Watch for media that fails to load. The constructor registers it as a
|
||||
// controller on this host.
|
||||
new MediaLoadWatchdogController(this, {
|
||||
getTargetID: () => this.targetID ?? null,
|
||||
isLoadExpected: () =>
|
||||
this._shouldLoad() &&
|
||||
// Don't report media unavailable for configuration errors as retries
|
||||
// cannot possible help, and a message is already rendered.
|
||||
!this._getConfigurationError() &&
|
||||
// Specific > generic: Don't replace existing failures flagged with
|
||||
// liveness detectors.
|
||||
!this._streamLivenessController.getFailure(),
|
||||
});
|
||||
}
|
||||
|
||||
// A note on dynamic imports:
|
||||
//
|
||||
// We gather the dynamic live provider import promises and do not consider the
|
||||
@@ -181,6 +200,33 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
}
|
||||
}
|
||||
|
||||
// Whether this provider should be loading media at all.
|
||||
private _shouldLoad(): boolean {
|
||||
return this._lazyLoadController.isLoaded();
|
||||
}
|
||||
|
||||
// Returns the reason this camera cannot stream at all, or null when it can.
|
||||
private _getConfigurationError(): string | null {
|
||||
const cameraConfig = this.camera?.getConfig();
|
||||
const provider = getResolvedLiveProvider(cameraConfig);
|
||||
|
||||
if (
|
||||
provider !== 'ha' &&
|
||||
provider !== 'image' &&
|
||||
!(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!cameraConfig?.camera_entity) {
|
||||
return localize('error.no_live_camera');
|
||||
}
|
||||
if (!this.hass?.states[cameraConfig.camera_entity]) {
|
||||
return localize('error.live_camera_not_found');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
override async getUpdateComplete(): Promise<boolean> {
|
||||
// See 'A note on dynamic imports' above for explanation of why this is
|
||||
// necessary.
|
||||
@@ -234,7 +280,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
protected render(): TemplateResult | void {
|
||||
const cameraConfig = this.camera?.getConfig();
|
||||
if (
|
||||
!this._lazyLoadController?.isLoaded() ||
|
||||
!this._shouldLoad() ||
|
||||
!this.hass ||
|
||||
!this.liveConfig ||
|
||||
!this.camera ||
|
||||
@@ -262,32 +308,15 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
||||
|
||||
const provider = getResolvedLiveProvider(this.camera?.getConfig());
|
||||
|
||||
// `ha`/`image` cannot stream without a camera entity, so validate that
|
||||
// here. Entity *availability* (including the always_error immediate path)
|
||||
// is owned by the liveness controller's EntityAvailabilityDetector and
|
||||
// surfaces via getFailure() below, for all providers.
|
||||
if (
|
||||
provider === 'ha' ||
|
||||
provider === 'image' ||
|
||||
(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
|
||||
) {
|
||||
if (!cameraConfig?.camera_entity) {
|
||||
const configurationError = this._getConfigurationError();
|
||||
if (configurationError) {
|
||||
return renderMediaNotification({
|
||||
icon: 'mdi:camera',
|
||||
title: localize('error.configuration_error'),
|
||||
detail: localize('error.no_live_camera'),
|
||||
detail: configurationError,
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
if (!this.hass.states[cameraConfig.camera_entity]) {
|
||||
return renderMediaNotification({
|
||||
icon: 'mdi:camera',
|
||||
title: localize('error.configuration_error'),
|
||||
detail: localize('error.live_camera_not_found'),
|
||||
targetTitle: this.cameraTitle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const failure = this._streamLivenessController.getFailure();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { CardWideConfig } from '../../config/schema/types.js';
|
||||
import type { ViewerConfig } from '../../config/schema/viewer.js';
|
||||
import type { ResolvedMediaCache } from '../../ha/resolved-media.js';
|
||||
import type { HomeAssistant } from '../../ha/types.js';
|
||||
import { getViewerGridCameraIDs } from '../../view/layout.js';
|
||||
|
||||
import '../../patches/ha-hls-player.js';
|
||||
|
||||
@@ -75,19 +76,14 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('viewManagerEpoch') && this._needsGrid()) {
|
||||
if (changedProps.has('viewManagerEpoch') && this._getGridCameraIDs()) {
|
||||
void import('../media-grid.js');
|
||||
}
|
||||
}
|
||||
|
||||
private _needsGrid(): boolean {
|
||||
private _getGridCameraIDs(): Set<string> | null {
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
const cameraIDs = view?.queryResults?.getCameraIDs();
|
||||
return (
|
||||
!!view?.isGrid() &&
|
||||
!!view?.supportsMultipleDisplayModes() &&
|
||||
(cameraIDs?.size ?? 0) > 1
|
||||
);
|
||||
return view ? getViewerGridCameraIDs(view) : null;
|
||||
}
|
||||
|
||||
private _gridSelectCamera(cameraID: string): void {
|
||||
@@ -103,15 +99,14 @@ export class AdvancedCameraCardViewerGrid extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
const cameraIDs = view?.queryResults?.getCameraIDs();
|
||||
if (!cameraIDs || !this._needsGrid()) {
|
||||
const cameraIDs = this._getGridCameraIDs();
|
||||
if (!cameraIDs) {
|
||||
return this._renderCarousel();
|
||||
}
|
||||
|
||||
return html`
|
||||
<advanced-camera-card-media-grid
|
||||
.selected=${view?.camera}
|
||||
.selected=${this.viewManagerEpoch?.manager.getView()?.camera}
|
||||
.displayConfig=${this.viewerConfig?.display}
|
||||
@advanced-camera-card:media-grid:selected=${(
|
||||
ev: CustomEvent<MediaGridSelected>,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { CameraManager } from '../../camera-manager/manager.js';
|
||||
import { QueryType } from '../../camera-manager/types.js';
|
||||
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||
import { MediaLoadWatchdogController } from '../../components-lib/media-load-watchdog-controller.js';
|
||||
import {
|
||||
getSignedURLErrorText,
|
||||
SignedURLController,
|
||||
@@ -36,6 +37,7 @@ import type {
|
||||
MediaPlayerController,
|
||||
MediaPlayerElement,
|
||||
} from '../../types.js';
|
||||
import { Generation } from '../../utils/concurrency/generation.js';
|
||||
import { classifyMimeType } from '../../utils/mime-type.js';
|
||||
import { ViewItemClassifier } from '../../view/item-classifier.js';
|
||||
import type { ViewMedia } from '../../view/item.js';
|
||||
@@ -77,6 +79,9 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
||||
|
||||
private _resolvedMedia: ResolvedMedia | null = null;
|
||||
|
||||
// Drops a slow resolution for media the provider has since moved off.
|
||||
private _resolveGeneration = new Generation();
|
||||
|
||||
private _signedURLController = new SignedURLController(this, () => {
|
||||
if (!this.hass || !this._resolvedMedia) {
|
||||
return {};
|
||||
@@ -99,6 +104,12 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
||||
constructor() {
|
||||
super();
|
||||
this._lazyLoadController.addListener((loaded) => loaded && this._resolveURL());
|
||||
|
||||
// Watch for media load failure (including resolving media ID and signing).
|
||||
new MediaLoadWatchdogController(this, {
|
||||
getTargetID: () => this.media?.getID() ?? null,
|
||||
isLoadExpected: () => this._shouldLoad(),
|
||||
});
|
||||
}
|
||||
|
||||
public async getMediaPlayerController(): Promise<MediaPlayerController | null> {
|
||||
@@ -140,19 +151,28 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
||||
|
||||
private async _resolveURL(): Promise<void> {
|
||||
const contentID = this.media?.getContentID();
|
||||
if (!contentID || !this.hass || !this._lazyLoadController?.isLoaded()) {
|
||||
if (!contentID || !this.hass || !this._shouldLoad()) {
|
||||
this._resolveGeneration.invalidate();
|
||||
this._resolvedMedia = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = this._resolveGeneration.next();
|
||||
|
||||
// Clear immediately so the SignedURLController doesn't see a stale URL
|
||||
// from the previous media item during the async gap.
|
||||
this._resolvedMedia = null;
|
||||
|
||||
this._resolvedMedia =
|
||||
const resolved =
|
||||
this.resolvedMediaCache?.get(contentID) ??
|
||||
(await resolveMedia(this.hass, contentID, this.resolvedMediaCache)) ??
|
||||
null;
|
||||
|
||||
if (!this._resolveGeneration.isCurrent(generation)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._resolvedMedia = resolved;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -177,6 +197,10 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
||||
}
|
||||
}
|
||||
|
||||
private _shouldLoad(): boolean {
|
||||
return this._lazyLoadController.isLoaded();
|
||||
}
|
||||
|
||||
private _getRelevantCameraConfig(): CameraConfig | null {
|
||||
const cameraID = this.media?.getCameraID();
|
||||
return cameraID
|
||||
@@ -233,12 +257,7 @@ export class AdvancedCameraCardViewerProvider extends LitElement implements Medi
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (
|
||||
!this._lazyLoadController?.isLoaded() ||
|
||||
!this.media ||
|
||||
!this.hass ||
|
||||
!this.viewerConfig
|
||||
) {
|
||||
if (!this._shouldLoad() || !this.media || !this.hass || !this.viewerConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ConditionState {
|
||||
|
||||
// Generic media target identifier. See @view/target-id for details.
|
||||
targetID?: string;
|
||||
triggered?: Set<string>;
|
||||
triggered?: ReadonlySet<string>;
|
||||
userAgent?: string;
|
||||
view?: AdvancedCameraCardView;
|
||||
}
|
||||
|
||||
@@ -37,12 +37,18 @@
|
||||
// not change in size (even border-box sizing appears to allow size to change
|
||||
// when the element has a non-fixed height).
|
||||
border: var(--advanced-camera-card-grid-border-size) solid transparent;
|
||||
|
||||
// Unselected cells cannot be usefully scrolled, so ensure notifications do
|
||||
// not show scrollbars.
|
||||
--advanced-camera-card-notification-block-overflow-y: hidden;
|
||||
}
|
||||
|
||||
::slotted([selected]) {
|
||||
border: var(--advanced-camera-card-grid-border-size) solid
|
||||
var(--advanced-camera-card-grid-selected-border-color);
|
||||
|
||||
--advanced-camera-card-notification-block-overflow-y: auto;
|
||||
|
||||
// Selected width = columns × selection factor, capped at 100%.
|
||||
width: min(
|
||||
100%,
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
padding: 24px;
|
||||
max-width: min(92%, 500px);
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
|
||||
// A container that cannot usefully be scrolled can set this to 'hidden'.
|
||||
overflow-y: var(--advanced-camera-card-notification-block-overflow-y, auto);
|
||||
scrollbar-width: thin;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { CameraManager } from '../camera-manager/manager';
|
||||
import { getViewTargetID } from './target-id';
|
||||
import type { View } from './view';
|
||||
|
||||
const isGridLayout = (view: View): boolean =>
|
||||
// This reflects whether the view 'asks' for a grid. This is inherited as
|
||||
// the user changes view.
|
||||
view.isGrid() &&
|
||||
// This reflects whether the current view actually *supports* a grid.
|
||||
view.supportsMultipleDisplayModes();
|
||||
|
||||
// The cameras a live grid lays out, one cell each, or null when live is laid
|
||||
// out as a single carousel instead (incl. when there's <= 1 camera).
|
||||
export const getLiveGridCameraIDs = (
|
||||
view: View,
|
||||
cameraManager: CameraManager,
|
||||
): Set<string> | null => {
|
||||
if (!isGridLayout(view)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cameraIDs = cameraManager.getStore().getCameraIDsWithCapability('live');
|
||||
return cameraIDs.size > 1 ? cameraIDs : null;
|
||||
};
|
||||
|
||||
// The cameras a viewer grid lays out, one cell each, or null when the viewer is
|
||||
// laid out as a single carousel instead (incl. when there's <= 1 camera). The
|
||||
// cameras come from the query results, so only a camera with media to show gets
|
||||
// a cell.
|
||||
export const getViewerGridCameraIDs = (view: View): Set<string> | null => {
|
||||
if (!isGridLayout(view)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cameraIDs = view.queryResults?.getCameraIDs();
|
||||
return cameraIDs && cameraIDs.size > 1 ? cameraIDs : null;
|
||||
};
|
||||
|
||||
// The target shown in each cell of a grid, or null when the view is not laid
|
||||
// out as a grid.
|
||||
const getGridTargetIDs = (
|
||||
view: View,
|
||||
cameraManager: CameraManager,
|
||||
): Set<string> | null => {
|
||||
if (view.is('live')) {
|
||||
// A live cell shows its camera, so the camera is the target.
|
||||
return getLiveGridCameraIDs(view, cameraManager);
|
||||
}
|
||||
|
||||
if (view.isViewerView()) {
|
||||
const cameraIDs = getViewerGridCameraIDs(view);
|
||||
if (!cameraIDs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A viewer cell shows one media item belonging to its camera.
|
||||
const targetIDs = new Set<string>();
|
||||
for (const cameraID of cameraIDs) {
|
||||
const targetID = view.queryResults?.getSelectedResult(cameraID)?.getID();
|
||||
if (targetID) {
|
||||
targetIDs.add(targetID);
|
||||
}
|
||||
}
|
||||
return targetIDs;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Every target the current view lays out, one per cell. A grid shows several
|
||||
// targets at once, so all of them count even when some are scrolled out of the
|
||||
// viewport: the user chose to put them on screen. Every other layout shows a
|
||||
// single target at a time, which is the one the view identifies.
|
||||
export const getDisplayedTargetIDs = (
|
||||
view: View,
|
||||
cameraManager: CameraManager,
|
||||
): Set<string> => {
|
||||
const gridTargetIDs = getGridTargetIDs(view, cameraManager);
|
||||
if (gridTargetIDs) {
|
||||
return gridTargetIDs;
|
||||
}
|
||||
|
||||
const targetID = getViewTargetID(view);
|
||||
return targetID ? new Set([targetID]) : new Set();
|
||||
};
|
||||
+1
-1
@@ -70,7 +70,7 @@ const isGalleryViewName = (view?: AdvancedCameraCardView): boolean =>
|
||||
const isAnyFolderViewName = (view?: AdvancedCameraCardView): boolean =>
|
||||
!!view && FOLDER_VIEW_NAMES.includes(view);
|
||||
|
||||
export const isAnyMediaViewName = (view?: AdvancedCameraCardView): boolean =>
|
||||
const isAnyMediaViewName = (view?: AdvancedCameraCardView): boolean =>
|
||||
isViewerViewName(view) || view === 'live' || view === 'image';
|
||||
|
||||
export class View {
|
||||
|
||||
@@ -607,10 +607,31 @@ export class MountedCard {
|
||||
control.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the control that steps a carousel one item along.
|
||||
*
|
||||
* These carry the name of whatever they move to rather than a name of their
|
||||
* own, which several other controls also carry, so they are reached by the
|
||||
* side they sit on instead. Which side moves forward depends on the reading
|
||||
* direction of the page, exactly as it does for the user.
|
||||
*/
|
||||
public async clickNextPreviousControl(side: 'left' | 'right'): Promise<void> {
|
||||
const control = await this.waitForRender(
|
||||
() => deepQuery<HTMLElement>(this.card, `ha-icon-button.controls.${side}`),
|
||||
`the ${side} carousel control`,
|
||||
);
|
||||
|
||||
await clickElement(control);
|
||||
}
|
||||
|
||||
private async _findControl(name: string): Promise<HTMLElement> {
|
||||
return await this.waitForRender(() => {
|
||||
const found = deepQueryAll(this.card, '*').find(
|
||||
(element) => getControlName(element) === name,
|
||||
(element) =>
|
||||
getControlName(element) === name &&
|
||||
// A control the user can press occupies space. An element that only
|
||||
// wraps one can carry the same name while having no box of its own.
|
||||
!!element.getBoundingClientRect().width,
|
||||
);
|
||||
return found instanceof HTMLElement ? found : null;
|
||||
}, `a control named ${name}`);
|
||||
|
||||
@@ -90,7 +90,7 @@ export const createUnansweredMediaURL = (): string => createMediaURL([]);
|
||||
*/
|
||||
export const createStallingMediaURL = (): string => createMediaURL([HTTP_OK]);
|
||||
|
||||
export interface StillCameraHASSOptions {
|
||||
export interface CameraHASSOptions {
|
||||
// Camera entities beyond the default one.
|
||||
cameras?: string[];
|
||||
|
||||
@@ -105,7 +105,7 @@ export interface StillCameraHASSOptions {
|
||||
* A Home Assistant holding the cameras a card is about to be given, which is
|
||||
* the minimum any browser test needs before it can mount anything.
|
||||
*/
|
||||
export const createStillCameraHASS = (options?: StillCameraHASSOptions): FakeHASS => {
|
||||
export const createCameraHASS = (options?: CameraHASSOptions): FakeHASS => {
|
||||
const cameras = [STILL_CAMERA_ENTITY, ...(options?.cameras ?? [])];
|
||||
|
||||
return new FakeHASS({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
|
||||
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
|
||||
import {
|
||||
createStillCameraHASS,
|
||||
createCameraHASS,
|
||||
createStillImageCameraConfig,
|
||||
createStillImageCardConfig,
|
||||
getBlockNotificationText,
|
||||
@@ -43,7 +43,7 @@ describe('CameraManager', () => {
|
||||
],
|
||||
view: { issues: { retry_seconds: 0 } },
|
||||
}),
|
||||
createStillCameraHASS({
|
||||
createCameraHASS({
|
||||
cameras: [TRIGGERING_CAMERA_ENTITY, OTHER_TRIGGERING_CAMERA_ENTITY],
|
||||
}),
|
||||
);
|
||||
@@ -64,7 +64,7 @@ describe('CameraManager', () => {
|
||||
createStillImageCardConfig({
|
||||
cameras: [createSubscribingCameraConfig(TRIGGERING_CAMERA_ENTITY)],
|
||||
}),
|
||||
createStillCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY] }),
|
||||
createCameraHASS({ cameras: [TRIGGERING_CAMERA_ENTITY] }),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(card.getOpenEventSubscriptionCount()).toBe(1));
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
|
||||
import {
|
||||
createStillCameraHASS,
|
||||
createStillImageCardConfig,
|
||||
} from '../browser/test-utils';
|
||||
import { createCameraHASS, createStillImageCardConfig } from '../browser/test-utils';
|
||||
|
||||
const TRIGGER_ENTITY = 'input_boolean.zoom';
|
||||
|
||||
const mount = async (): Promise<MountedCard> => {
|
||||
const hass = createStillCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
|
||||
const hass = createCameraHASS({ entities: { [TRIGGER_ENTITY]: 'off' } });
|
||||
return await MountedCardFactory.createFromSource(
|
||||
createStillImageCardConfig({
|
||||
automations: [
|
||||
|
||||
@@ -4,8 +4,8 @@ import { createLogAction } from '../../src/utils/action';
|
||||
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
|
||||
import {
|
||||
CARD_INITIALIZED_MESSAGE,
|
||||
createCameraHASS,
|
||||
createInitializedAutomation,
|
||||
createStillCameraHASS,
|
||||
createStillImageCardConfig,
|
||||
deepQueryAll,
|
||||
getFocusedElement,
|
||||
@@ -28,7 +28,7 @@ const mountCard = async (): Promise<MountedCard> => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
createStillCameraHASS(),
|
||||
createCameraHASS(),
|
||||
);
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
|
||||
import { MountedCardFactory, type MountedCard } from '../../browser/mounted-card';
|
||||
import {
|
||||
CARD_INITIALIZED_MESSAGE,
|
||||
createCameraHASS,
|
||||
createInitializedAutomation,
|
||||
createStillCameraHASS,
|
||||
createStillImageCameraConfig,
|
||||
createStillImageCardConfig,
|
||||
isMediaLoadedInfoEventDetail,
|
||||
@@ -26,7 +26,7 @@ const mount = async (
|
||||
): Promise<MountedCard> =>
|
||||
await MountedCardFactory.createFromSource(
|
||||
createConfig(overrides),
|
||||
createStillCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }),
|
||||
createCameraHASS({ cameras: [OTHER_CAMERA_ENTITY] }),
|
||||
);
|
||||
|
||||
// The cameras the card has actually loaded media for, in order. Media that
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createIssueManager } from '../../../src/card-controller/issues/factory'
|
||||
import { IssueManager } from '../../../src/card-controller/issues/issue-manager';
|
||||
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
|
||||
import { createCardAPI } from '../../test-utils';
|
||||
import { createView } from '../../view/test-utils';
|
||||
import { createSubscriptionHealth } from '../test-utils';
|
||||
|
||||
describe('createIssueManager', () => {
|
||||
@@ -77,23 +78,49 @@ describe('createIssueManager', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('should wire changeCallback so timer-based issues activate via evaluate', () => {
|
||||
it('should wire changeCallback so an issue can ask for a re-evaluation', () => {
|
||||
const api = createCardAPI();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(new ConditionStateManager());
|
||||
const health = createSubscriptionHealth();
|
||||
|
||||
const manager = createIssueManager(api, health);
|
||||
vi.mocked(api.getCardElementManager().update).mockClear();
|
||||
|
||||
// A subscription starts failing. Nothing has asked the IssueManager to
|
||||
// re-evaluate, so it does not know yet.
|
||||
health.getFailures.mockReturnValue([{ key: 'camera-1', error: 'failed' }]);
|
||||
expect(api.getCardElementManager().update).not.toHaveBeenCalled();
|
||||
|
||||
// The callback EventSubscriptionIssue registered is what asks it to
|
||||
// re-evaluate.
|
||||
const changeCallback = health.addListener.mock.calls[0][0];
|
||||
changeCallback();
|
||||
|
||||
expect(manager.getStateManager().getIssuePresence().has('event_subscription')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(api.getCardElementManager().update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should report a media failure raised by a component', () => {
|
||||
const api = createCardAPI();
|
||||
const stateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({ view: 'live', camera: 'camera-1' }),
|
||||
);
|
||||
|
||||
const manager = createIssueManager(api, createSubscriptionHealth());
|
||||
|
||||
// Setting view starts the media_unavailable timer (via the condition state
|
||||
// listener → evaluate → detectDynamic).
|
||||
stateManager.setState({ targetID: 'camera-1', view: 'live' });
|
||||
expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
// After the timeout, the changeCallback fires evaluate which
|
||||
// updates the card element.
|
||||
vi.advanceTimersByTime(10000);
|
||||
manager.trigger('media_unavailable', {
|
||||
targetID: 'camera-1',
|
||||
reason: 'not_loading',
|
||||
});
|
||||
|
||||
expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe(
|
||||
true,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
createMediaLoadedInfo,
|
||||
flushPromises,
|
||||
} from '../../test-utils';
|
||||
import { createView } from '../../view/test-utils';
|
||||
|
||||
const DEFAULT_RETRY_SECONDS = 1;
|
||||
|
||||
@@ -120,6 +121,9 @@ describe('IssueManager', () => {
|
||||
const api = createCardAPI();
|
||||
const conditionStateManager = new ConditionStateManager();
|
||||
vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager);
|
||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||
createView({ view: 'live', camera: 'camera.office' }),
|
||||
);
|
||||
|
||||
const manager = new IssueManager(api);
|
||||
const issue = new MediaUnavailableIssue(api);
|
||||
|
||||
@@ -3,8 +3,8 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { MountedCardFactory, type MountedCard } from '../../../browser/mounted-card';
|
||||
import {
|
||||
CARD_INITIALIZED_MESSAGE,
|
||||
createCameraHASS,
|
||||
createInitializedAutomation,
|
||||
createStillCameraHASS,
|
||||
createStillImageCameraConfig,
|
||||
createStillImageCardConfig,
|
||||
getBlockNotificationText,
|
||||
@@ -40,7 +40,7 @@ const mountBrokenCard = async (): Promise<MountedCard> =>
|
||||
view: { issues: { retry_seconds: 0 } },
|
||||
automations: [createInitializedAutomation()],
|
||||
}),
|
||||
createStillCameraHASS(),
|
||||
createCameraHASS(),
|
||||
);
|
||||
|
||||
describe('InitializationIssue', () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RETRY_EXPONENTIAL_BASE_SECONDS } from '../../../../src/card-controller/issues/issue-manager';
|
||||
import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../../src/card-controller/issues/issues/media-unavailable';
|
||||
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability';
|
||||
import { MEDIA_LOADING_TIMEOUT_SECONDS } from '../../../../src/components-lib/media-load-watchdog-controller';
|
||||
import { FRAME_STALL_SECONDS } from '../../../../src/components-lib/media-player/frame-stall-watchdog';
|
||||
import type { RawAdvancedCameraCardConfig } from '../../../../src/config/types';
|
||||
import {
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
} from '../../../browser/mounted-card';
|
||||
import { useTestMedia } from '../../../browser/test-media';
|
||||
import {
|
||||
createCameraHASS,
|
||||
createFailingMediaURL,
|
||||
createStallingMediaURL,
|
||||
createStillCameraHASS,
|
||||
createStillImageCameraConfig,
|
||||
createStillImageCardConfig,
|
||||
createTemporarilyFailingMediaURL,
|
||||
@@ -24,10 +24,13 @@ import {
|
||||
getBlockNotificationText,
|
||||
isLiveMediaShowing,
|
||||
STILL_CAMERA_ENTITY,
|
||||
type CameraHASSOptions,
|
||||
} from '../../../browser/test-utils';
|
||||
|
||||
const SECOND_CAMERA_ENTITY = 'camera.hallway';
|
||||
|
||||
const OVERRIDE_ENTITY = 'input_boolean.override';
|
||||
|
||||
const MEDIA_ISSUE_TITLE = 'Media unavailable';
|
||||
|
||||
// Holding this reaches the diagnostics view, the only view showing no media
|
||||
@@ -41,6 +44,17 @@ const findIssue = (card: MountedCard): Element | null =>
|
||||
|
||||
const isIssueReported = (card: MountedCard): boolean => !!findIssue(card);
|
||||
|
||||
// Resolve once `image` holds a picture that actually loaded. A failed load also
|
||||
// marks the element complete, so the decoded size is what separates the two.
|
||||
const waitForImageLoaded = async (image: HTMLImageElement): Promise<void> =>
|
||||
await new Promise<void>((resolve) => {
|
||||
if (image.complete && image.naturalWidth > 0) {
|
||||
resolve();
|
||||
} else {
|
||||
image.addEventListener('load', () => resolve(), { once: true });
|
||||
}
|
||||
});
|
||||
|
||||
const waitForIssueReported = async (card: MountedCard): Promise<void> => {
|
||||
await card.waitForRender(
|
||||
() => findIssue(card),
|
||||
@@ -48,9 +62,14 @@ const waitForIssueReported = async (card: MountedCard): Promise<void> => {
|
||||
);
|
||||
};
|
||||
|
||||
interface MountCardOptions extends MountOptions {
|
||||
cameras?: string[];
|
||||
}
|
||||
const waitForIssueCleared = async (card: MountedCard): Promise<void> => {
|
||||
await card.waitForRender(
|
||||
() => !findIssue(card) || null,
|
||||
`the ${MEDIA_ISSUE_TITLE} issue being cleared`,
|
||||
);
|
||||
};
|
||||
|
||||
interface MountCardOptions extends MountOptions, CameraHASSOptions {}
|
||||
|
||||
/**
|
||||
* Every test here needs the status bar rendered, since that is where an issue
|
||||
@@ -60,11 +79,11 @@ const mountCard = async (
|
||||
config?: Partial<RawAdvancedCameraCardConfig>,
|
||||
options?: MountCardOptions,
|
||||
): Promise<MountedCard> => {
|
||||
const { cameras, ...mountOptions } = options ?? {};
|
||||
const { cameras, entities, language, ...mountOptions } = options ?? {};
|
||||
|
||||
return await MountedCardFactory.createFromSource(
|
||||
createStillImageCardConfig({ status_bar: { style: 'outside' }, ...config }),
|
||||
createStillCameraHASS({ cameras }),
|
||||
createCameraHASS({ cameras, entities, language }),
|
||||
mountOptions,
|
||||
);
|
||||
};
|
||||
@@ -77,17 +96,22 @@ const mountCardSingleCamera = async (): Promise<MountedCard> => {
|
||||
return card;
|
||||
};
|
||||
|
||||
const mountCardDualCameras = async (): Promise<MountedCard> => {
|
||||
const card = await mountCard(
|
||||
{
|
||||
live: { display: { mode: 'grid' } },
|
||||
cameras: [
|
||||
createStillImageCameraConfig(),
|
||||
createStillImageCameraConfig(SECOND_CAMERA_ENTITY),
|
||||
],
|
||||
/**
|
||||
* A grid of the two standard cameras, which differ only in how they are
|
||||
* configured to behave.
|
||||
*/
|
||||
const mountCardGrid = async (
|
||||
cameras: RawAdvancedCameraCardConfig[],
|
||||
options?: {
|
||||
config?: Partial<RawAdvancedCameraCardConfig>;
|
||||
entities?: CameraHASSOptions['entities'];
|
||||
},
|
||||
): Promise<MountedCard> =>
|
||||
await mountCard(
|
||||
{ live: { display: { mode: 'grid' } }, cameras, ...options?.config },
|
||||
{
|
||||
cameras: [SECOND_CAMERA_ENTITY],
|
||||
...(options?.entities && { entities: options.entities }),
|
||||
|
||||
// The grid observes both its own size and its cells', so a cell resize
|
||||
// can resize the host and vice versa. Chromium reports each round it has
|
||||
@@ -100,6 +124,12 @@ const mountCardDualCameras = async (): Promise<MountedCard> => {
|
||||
},
|
||||
);
|
||||
|
||||
const mountCardDualCameras = async (): Promise<MountedCard> => {
|
||||
const card = await mountCardGrid([
|
||||
createStillImageCameraConfig(),
|
||||
createStillImageCameraConfig(SECOND_CAMERA_ENTITY),
|
||||
]);
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
return card;
|
||||
@@ -182,6 +212,149 @@ describe('MediaUnavailableIssue', () => {
|
||||
expect(isLiveMediaShowing(card.card)).toBe(true);
|
||||
});
|
||||
|
||||
it('should offer no scrollbar on a failed grid camera that is not selected', async () => {
|
||||
const card = await mountCardDualCameras();
|
||||
|
||||
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
|
||||
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS);
|
||||
const block = await card.waitForSelector('advanced-camera-card-notification-block');
|
||||
|
||||
// Unselected cells cannot usefully be scrolled, so a scrollbar is not offered.
|
||||
const content = block.shadowRoot?.querySelector('.content');
|
||||
assert(content);
|
||||
|
||||
expect(getComputedStyle(content).overflowY).toBe('hidden');
|
||||
});
|
||||
|
||||
it('should trigger an issue for an unselected grid camera and recover it', async () => {
|
||||
// Every camera in a grid is on screen, so any of them failing triggers an
|
||||
// issue, not only the camera that happens to be selected.
|
||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2637
|
||||
const card = await mountCardGrid([
|
||||
createStillImageCameraConfig(),
|
||||
createStillImageCameraConfig(
|
||||
SECOND_CAMERA_ENTITY,
|
||||
createTemporarilyFailingMediaURL(1),
|
||||
),
|
||||
]);
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
|
||||
await waitForIssueReported(card);
|
||||
|
||||
expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY);
|
||||
|
||||
// Nothing here asks the card to try again: the retry runs on its own.
|
||||
await card.advanceSeconds(RETRY_EXPONENTIAL_BASE_SECONDS);
|
||||
await waitForIssueCleared(card);
|
||||
|
||||
expect(isLiveMediaShowing(card.card)).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger an issue for a camera added to a grid by an override', async () => {
|
||||
// Overrides don't recreate views. If an override changes the cameras,
|
||||
// issues should be created for newly added cameras.
|
||||
const card = await mountCardGrid([createStillImageCameraConfig()], {
|
||||
entities: { [OVERRIDE_ENTITY]: 'off' },
|
||||
config: {
|
||||
overrides: [
|
||||
{
|
||||
conditions: [{ condition: 'state', entity: OVERRIDE_ENTITY, state: 'on' }],
|
||||
set: {
|
||||
cameras: [
|
||||
createStillImageCameraConfig(),
|
||||
createStillImageCameraConfig(
|
||||
SECOND_CAMERA_ENTITY,
|
||||
createFailingMediaURL(),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
|
||||
card.setEntityState(OVERRIDE_ENTITY, 'on');
|
||||
|
||||
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
|
||||
await waitForIssueReported(card);
|
||||
|
||||
expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY);
|
||||
});
|
||||
|
||||
it('should stay silent about a carousel camera that is not on screen', async () => {
|
||||
// A carousel camera that has loaded and failed triggers no issue while it
|
||||
// is off screen. Lazy loading is off so that it does load and fail, rather
|
||||
// than the test passing because nothing was watching it.
|
||||
const card = await mountCard(
|
||||
{
|
||||
live: { lazy_load: false },
|
||||
cameras: [
|
||||
createStillImageCameraConfig(),
|
||||
createStillImageCameraConfig(SECOND_CAMERA_ENTITY),
|
||||
],
|
||||
},
|
||||
{ cameras: [SECOND_CAMERA_ENTITY] },
|
||||
);
|
||||
await card.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
|
||||
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 4);
|
||||
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
|
||||
// Step the carousel on to that same camera, which reports the failure it
|
||||
// already had. The silence above was the scoping, not an absence of
|
||||
// anything watching.
|
||||
await card.clickNextPreviousControl('right');
|
||||
await waitForIssueReported(card);
|
||||
|
||||
expect(getBlockNotificationText(card.card)).toContain(SECOND_CAMERA_ENTITY);
|
||||
});
|
||||
|
||||
it('should keep an issue across a detach and re-attach', async () => {
|
||||
const card = await mountCardDualCameras();
|
||||
|
||||
card.setEntityState(SECOND_CAMERA_ENTITY, 'unavailable');
|
||||
await card.advanceSeconds(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS);
|
||||
await waitForIssueReported(card);
|
||||
|
||||
card.detach();
|
||||
card.attach();
|
||||
await card.updateComplete;
|
||||
|
||||
// The clock has not moved, so a failure found all over again could not have
|
||||
// been reported yet. This is the issue from before the detach.
|
||||
expect(isIssueReported(card)).toBe(true);
|
||||
});
|
||||
|
||||
it('should trigger an issue for a grid camera that goes quiet without failing', async () => {
|
||||
// No active errors, just an unanswered load on an unselected camera.
|
||||
const card = await mountCardGrid([
|
||||
createStillImageCameraConfig(),
|
||||
createStillImageCameraConfig(SECOND_CAMERA_ENTITY, createUnansweredMediaURL()),
|
||||
]);
|
||||
|
||||
// Nothing is waited on until the cell has a player asking for media.
|
||||
await card.waitForSelector('advanced-camera-card-live-image');
|
||||
|
||||
await card.advanceSeconds(MEDIA_LOADING_TIMEOUT_SECONDS - 1);
|
||||
expect(isIssueReported(card)).toBe(false);
|
||||
|
||||
await card.advanceSeconds(1);
|
||||
await waitForIssueReported(card);
|
||||
|
||||
// The cell itself is still showing that it is waiting, so which camera has
|
||||
// given up is only knowable from the issue.
|
||||
await card.clickControl(MEDIA_ISSUE_TITLE);
|
||||
const notification = await card.waitForSelector('advanced-camera-card-notification');
|
||||
|
||||
expect(notification.shadowRoot?.textContent).toContain(SECOND_CAMERA_ENTITY);
|
||||
expect(notification.shadowRoot?.textContent).toContain('Media not loading');
|
||||
});
|
||||
|
||||
it('should report a camera whose media fails to load', async () => {
|
||||
const card = await mountCard({
|
||||
cameras: [
|
||||
@@ -392,6 +565,44 @@ 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 () => {
|
||||
// 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
|
||||
// would be reported as recovered.
|
||||
const card = await MountedCardFactory.createFromSource(
|
||||
createStillImageCardConfig({
|
||||
status_bar: { style: 'outside' },
|
||||
cameras: [
|
||||
{
|
||||
camera_entity: STILL_CAMERA_ENTITY,
|
||||
live_provider: 'image',
|
||||
image: { mode: 'camera' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
createCameraHASS({
|
||||
entities: {
|
||||
[STILL_CAMERA_ENTITY]: {
|
||||
state: 'idle',
|
||||
attributes: { entity_picture: createFailingMediaURL() },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const image = await card.waitForSelector<HTMLImageElement>('img');
|
||||
await card.events.waitForFirst('advanced-camera-card:issue:trigger');
|
||||
|
||||
// The stock picture is being swapped into the same element. Wait for that
|
||||
// load specifically: it is the one that could be mistaken for the camera's
|
||||
// media arriving.
|
||||
await waitForImageLoaded(image);
|
||||
|
||||
expect(isIssueReported(card)).toBe(true);
|
||||
expect(card.events.getEntries('advanced-camera-card:media:loaded')).toHaveLength(0);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,8 @@ import {
|
||||
import {
|
||||
CARD_INITIALIZED_MESSAGE,
|
||||
clickElement,
|
||||
createCameraHASS,
|
||||
createInitializedAutomation,
|
||||
createStillCameraHASS,
|
||||
createStillImageCardConfig,
|
||||
dispatchPointerDown,
|
||||
getFocusedElement,
|
||||
@@ -106,7 +106,7 @@ const mountCard = async (options?: MountCardOptions): Promise<MountedCard> => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
createStillCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }),
|
||||
createCameraHASS({ entities: { [ZOOM_ENTITY]: 'off' } }),
|
||||
mountOptions,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MountedCardFactory } from '../browser/mounted-card';
|
||||
import {
|
||||
createStillCameraHASS,
|
||||
createStillImageCardConfig,
|
||||
} from '../browser/test-utils';
|
||||
import { createCameraHASS, createStillImageCardConfig } from '../browser/test-utils';
|
||||
|
||||
// A colour the dark theme sets and the card carries no other way. Reading it
|
||||
// back proves the stylesheet reached the card rather than merely compiling.
|
||||
@@ -14,7 +11,7 @@ const DARK_PRIMARY_BACKGROUND = '#111111';
|
||||
const mountThemed = async (themes: string[]) =>
|
||||
await MountedCardFactory.createFromSource(
|
||||
createStillImageCardConfig({ view: { theme: { themes } } }),
|
||||
createStillCameraHASS(),
|
||||
createCameraHASS(),
|
||||
);
|
||||
|
||||
describe('themes', () => {
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
IssueResolveEventData,
|
||||
IssueTriggerEventData,
|
||||
} from '../../src/card-controller/issues/types';
|
||||
import {
|
||||
MEDIA_LOADING_TIMEOUT_SECONDS,
|
||||
MediaLoadWatchdogController,
|
||||
} from '../../src/components-lib/media-load-watchdog-controller';
|
||||
import { createLitElement, createMediaLoadedInfo } from '../test-utils';
|
||||
|
||||
const TIMEOUT_MS = MEDIA_LOADING_TIMEOUT_SECONDS * 1000;
|
||||
|
||||
const createHarness = (options?: {
|
||||
targetID?: string | null;
|
||||
loadExpected?: boolean;
|
||||
}) => {
|
||||
const host = createLitElement();
|
||||
let targetID = options?.targetID === undefined ? 'camera-1' : options.targetID;
|
||||
let loadExpected = options?.loadExpected ?? true;
|
||||
let attemptID = 0;
|
||||
|
||||
const triggerRequests: IssueTriggerEventData[] = [];
|
||||
host.addEventListener('advanced-camera-card:issue:trigger', (ev: Event) => {
|
||||
triggerRequests.push((ev as CustomEvent<IssueTriggerEventData>).detail);
|
||||
});
|
||||
|
||||
const resolveRequests: IssueResolveEventData[] = [];
|
||||
host.addEventListener('advanced-camera-card:issue:resolve', (ev: Event) => {
|
||||
resolveRequests.push((ev as CustomEvent<IssueResolveEventData>).detail);
|
||||
});
|
||||
|
||||
const controller = new MediaLoadWatchdogController(host, {
|
||||
getTargetID: () => targetID,
|
||||
isLoadExpected: () => loadExpected,
|
||||
getAttemptID: () => attemptID,
|
||||
});
|
||||
|
||||
return {
|
||||
host,
|
||||
controller,
|
||||
triggerRequests,
|
||||
resolveRequests,
|
||||
setTargetID: (value: string | null): void => {
|
||||
targetID = value;
|
||||
},
|
||||
setLoadExpected: (value: boolean): void => {
|
||||
loadExpected = value;
|
||||
},
|
||||
retryMedia: (): void => {
|
||||
attemptID++;
|
||||
},
|
||||
connect: (): void => controller.hostConnected(),
|
||||
disconnect: (): void => controller.hostDisconnected(),
|
||||
update: (): void => controller.hostUpdated(),
|
||||
|
||||
// Deliver a `media:loaded` as a descendant player would, returning the
|
||||
// controller that makes that media go away.
|
||||
mediaLoaded: (loadedTargetID: string): AbortController => {
|
||||
const abort = new AbortController();
|
||||
host.dispatchEvent(
|
||||
new CustomEvent('advanced-camera-card:media:loaded', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
info: createMediaLoadedInfo({ targetID: loadedTargetID }),
|
||||
signal: abort.signal,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return abort;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('MediaLoadWatchdogController', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should register itself with the host', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
expect(harness.host.addController).toHaveBeenCalledWith(harness.controller);
|
||||
});
|
||||
|
||||
it('should report a load that never arrives', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not report a load that arrives in time', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS - 1);
|
||||
harness.mediaLoaded('camera-1');
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it('should report only once for a target that stays hung', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
harness.update();
|
||||
vi.advanceTimersByTime(TIMEOUT_MS * 5);
|
||||
|
||||
expect(harness.triggerRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not wait when no load is expected', () => {
|
||||
const harness = createHarness({ loadExpected: false });
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not wait without a target', () => {
|
||||
const harness = createHarness({ targetID: null });
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it('should give a fresh window to a load that becomes expected again', () => {
|
||||
const harness = createHarness({ loadExpected: false });
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
harness.setLoadExpected(true);
|
||||
harness.update();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS - 1);
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(harness.triggerRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not report once the host stops expecting a load', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS - 1);
|
||||
harness.setLoadExpected(false);
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
});
|
||||
|
||||
describe('clearing a reported failure', () => {
|
||||
it('should clear the failure when media arrives', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
harness.mediaLoaded('camera-1');
|
||||
|
||||
expect(harness.resolveRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should clear a failure it never reported itself', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
// Another component can report the same kind of failure for this target.
|
||||
harness.mediaLoaded('camera-1');
|
||||
|
||||
expect(harness.resolveRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not clear anything for a load belonging to another target', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
harness.mediaLoaded('camera-2');
|
||||
|
||||
expect(harness.resolveRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('media going away', () => {
|
||||
it('should wait again when loaded media goes away', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
const abort = harness.mediaLoaded('camera-1');
|
||||
vi.advanceTimersByTime(TIMEOUT_MS * 2);
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
|
||||
abort.abort();
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should ignore an older load going away after a newer one', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
const stale = harness.mediaLoaded('camera-1');
|
||||
harness.mediaLoaded('camera-1');
|
||||
|
||||
stale.abort();
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it('should wait again when media went away while the host was disconnected', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
const abort = harness.mediaLoaded('camera-1');
|
||||
harness.disconnect();
|
||||
abort.abort();
|
||||
|
||||
// A detached host is not waited on, so nothing is reported yet.
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
|
||||
// The media that went away is gone on return, so the wait resumes rather
|
||||
// than the host being taken to still have it.
|
||||
harness.connect();
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a host reused for another target', () => {
|
||||
it('should not treat a load for another target as its own', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
harness.mediaLoaded('camera-2');
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not carry a previous load over to the next target', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
harness.mediaLoaded('camera-1');
|
||||
|
||||
harness.setTargetID('camera-2');
|
||||
harness.update();
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should give the next target a full window rather than what remained', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS - 1);
|
||||
harness.setTargetID('camera-2');
|
||||
harness.update();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS - 1);
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(harness.triggerRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should report the next target after the previous one was reported', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
harness.setTargetID('camera-2');
|
||||
harness.update();
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
|
||||
{ key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should resolve a failure it reported for the previous target', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
harness.setTargetID('camera-2');
|
||||
harness.update();
|
||||
|
||||
// Nothing watches camera-1 now, so nothing could ever say it recovered.
|
||||
expect(harness.resolveRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should resolve a failure when the next target loads immediately', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
harness.setTargetID('camera-2');
|
||||
harness.mediaLoaded('camera-2');
|
||||
|
||||
expect(harness.resolveRequests).toEqual([
|
||||
{ key: 'media_unavailable', targetID: 'camera-1', reason: 'not_loading' },
|
||||
{ key: 'media_unavailable', targetID: 'camera-2', reason: 'not_loading' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should resolve nothing when it reported no failure', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
harness.setTargetID('camera-2');
|
||||
harness.update();
|
||||
|
||||
expect(harness.resolveRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connection', () => {
|
||||
it('should not wait while disconnected', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS - 1);
|
||||
harness.disconnect();
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it('should give a fresh window on reconnect', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
harness.disconnect();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS - 1);
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(harness.triggerRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should ignore a host update while disconnected', () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.update();
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a rebuilt attempt at the same target', () => {
|
||||
it('should wait again after the media underneath is rebuilt', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
harness.mediaLoaded('camera-1');
|
||||
|
||||
harness.retryMedia();
|
||||
harness.update();
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should report again after a retry of a target already reported', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
harness.retryMedia();
|
||||
harness.update();
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
|
||||
expect(harness.triggerRequests).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should not report an attempt the retry has already replaced', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
// The retry lands while the window for the previous attempt is still
|
||||
// maturing, and the host has not been updated yet.
|
||||
vi.advanceTimersByTime(TIMEOUT_MS - 1);
|
||||
harness.retryMedia();
|
||||
vi.advanceTimersByTime(1);
|
||||
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it('should keep a reported failure visible while the retry runs', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS);
|
||||
harness.retryMedia();
|
||||
harness.update();
|
||||
|
||||
// The target is unchanged, so the failure still describes it until the
|
||||
// retried media either arrives or hangs in its turn.
|
||||
expect(harness.resolveRequests).toEqual([]);
|
||||
});
|
||||
|
||||
it('should give the rebuilt attempt a full window', () => {
|
||||
const harness = createHarness();
|
||||
harness.connect();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS - 1);
|
||||
harness.retryMedia();
|
||||
harness.update();
|
||||
|
||||
vi.advanceTimersByTime(TIMEOUT_MS - 1);
|
||||
expect(harness.triggerRequests).toEqual([]);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(harness.triggerRequests).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { MediaLoadedInfoEventDetail } from '../../src/types';
|
||||
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
|
||||
import {
|
||||
createStillCameraHASS,
|
||||
createCameraHASS,
|
||||
createStillImageCardConfig,
|
||||
isMediaLoadedInfoEventDetail,
|
||||
STILL_CAMERA_ENTITY,
|
||||
@@ -22,7 +22,7 @@ interface RenderedElement extends Element {
|
||||
}
|
||||
|
||||
const mount = async (): Promise<MountedCard> => {
|
||||
const hass = createStillCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
|
||||
const hass = createCameraHASS({ entities: { [UNRELATED_ENTITY]: 'off' } });
|
||||
return await MountedCardFactory.createFromSource(createStillImageCardConfig(), hass);
|
||||
};
|
||||
|
||||
|
||||
Vendored
+3
-3
@@ -11,7 +11,7 @@ import {
|
||||
type MountOptions,
|
||||
} from '../browser/mounted-card';
|
||||
import {
|
||||
createStillCameraHASS,
|
||||
createCameraHASS,
|
||||
createStillImageCardConfig,
|
||||
isLiveMediaShowing,
|
||||
} from '../browser/test-utils';
|
||||
@@ -99,7 +99,7 @@ class BuildMountedCardFactory extends MountedCardFactory {
|
||||
* rather than from `src/`.
|
||||
*/
|
||||
const mountBuiltCard = async (
|
||||
hass: FakeHASS = createStillCameraHASS(),
|
||||
hass: FakeHASS = createCameraHASS(),
|
||||
): Promise<MountedCard> =>
|
||||
await BuildMountedCardFactory.createFromBuild(
|
||||
`/${PUBLIC_ENTRY}?hacstag=${HACSTAG}`,
|
||||
@@ -243,7 +243,7 @@ describe('the built card', () => {
|
||||
// Clear resource timings to only measure the impact of mounting the card.
|
||||
performance.clearResourceTimings();
|
||||
|
||||
const mounted = await mountBuiltCard(createStillCameraHASS({ language: 'de' }));
|
||||
const mounted = await mountBuiltCard(createCameraHASS({ language: 'de' }));
|
||||
await mounted.events.waitForFirst('advanced-camera-card:media:loaded');
|
||||
|
||||
expect(getLanguageChunks()).toEqual([expect.stringMatching(/^lang-de-/)]);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CameraManager } from '../../src/camera-manager/manager';
|
||||
import type { CameraManagerReadOnlyConfigStore } from '../../src/camera-manager/store';
|
||||
import {
|
||||
getDisplayedTargetIDs,
|
||||
getLiveGridCameraIDs,
|
||||
getViewerGridCameraIDs,
|
||||
} from '../../src/view/layout';
|
||||
import { QueryResults } from '../../src/view/query-results';
|
||||
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../src/view/target-id';
|
||||
import { createView, generateViewMediaArray } from './test-utils';
|
||||
|
||||
// A camera manager whose store reports the given live-capable cameras.
|
||||
const createCameraManager = (cameraIDs: string[]): CameraManager => {
|
||||
const cameraManager = mock<CameraManager>();
|
||||
const store = mock<CameraManagerReadOnlyConfigStore>();
|
||||
store.getCameraIDsWithCapability.mockReturnValue(new Set(cameraIDs));
|
||||
cameraManager.getStore.mockReturnValue(store);
|
||||
return cameraManager;
|
||||
};
|
||||
|
||||
// Query results spanning `cameraIDs`, each camera with its own media.
|
||||
const createQueryResults = (cameraIDs: string[]): QueryResults =>
|
||||
new QueryResults({
|
||||
results: generateViewMediaArray({ cameraIDs, count: 1 }),
|
||||
selectedIndex: 0,
|
||||
});
|
||||
|
||||
describe('getLiveGridCameraIDs', () => {
|
||||
it('should return every live camera when laid out as a grid', () => {
|
||||
const view = createView({ view: 'live', displayMode: 'grid' });
|
||||
|
||||
expect(
|
||||
getLiveGridCameraIDs(view, createCameraManager(['kitchen', 'office'])),
|
||||
).toEqual(new Set(['kitchen', 'office']));
|
||||
});
|
||||
|
||||
it('should return null when not laid out as a grid', () => {
|
||||
const view = createView({ view: 'live', displayMode: 'single' });
|
||||
|
||||
expect(
|
||||
getLiveGridCameraIDs(view, createCameraManager(['kitchen', 'office'])),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for a view that inherited a grid it cannot use', () => {
|
||||
const view = createView({ view: 'image', displayMode: 'grid' });
|
||||
|
||||
expect(
|
||||
getLiveGridCameraIDs(view, createCameraManager(['kitchen', 'office'])),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getViewerGridCameraIDs', () => {
|
||||
it('should return every camera with media when laid out as a grid', () => {
|
||||
const view = createView({
|
||||
view: 'media',
|
||||
displayMode: 'grid',
|
||||
queryResults: createQueryResults(['kitchen', 'office']),
|
||||
});
|
||||
|
||||
expect(getViewerGridCameraIDs(view)).toEqual(new Set(['kitchen', 'office']));
|
||||
});
|
||||
|
||||
it('should return null when not laid out as a grid', () => {
|
||||
const view = createView({
|
||||
view: 'media',
|
||||
displayMode: 'single',
|
||||
queryResults: createQueryResults(['kitchen', 'office']),
|
||||
});
|
||||
|
||||
expect(getViewerGridCameraIDs(view)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for a view that inherited a grid it cannot use', () => {
|
||||
const view = createView({
|
||||
view: 'image',
|
||||
displayMode: 'grid',
|
||||
queryResults: createQueryResults(['kitchen', 'office']),
|
||||
});
|
||||
|
||||
expect(getViewerGridCameraIDs(view)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDisplayedTargetIDs', () => {
|
||||
it('should return every camera of a live grid', () => {
|
||||
const view = createView({ view: 'live', displayMode: 'grid' });
|
||||
|
||||
expect(
|
||||
getDisplayedTargetIDs(view, createCameraManager(['kitchen', 'office'])),
|
||||
).toEqual(new Set(['kitchen', 'office']));
|
||||
});
|
||||
|
||||
it('should return the selected camera of a live carousel', () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'kitchen',
|
||||
displayMode: 'single',
|
||||
});
|
||||
|
||||
expect(
|
||||
getDisplayedTargetIDs(view, createCameraManager(['kitchen', 'office'])),
|
||||
).toEqual(new Set(['kitchen']));
|
||||
});
|
||||
|
||||
it('should return the selected media of every camera of a viewer grid', () => {
|
||||
const view = createView({
|
||||
view: 'media',
|
||||
displayMode: 'grid',
|
||||
queryResults: createQueryResults(['kitchen', 'office']),
|
||||
});
|
||||
|
||||
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(
|
||||
new Set(['id-kitchen-0', 'id-office-0']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the selected media of a viewer carousel', () => {
|
||||
const view = createView({
|
||||
view: 'media',
|
||||
displayMode: 'single',
|
||||
queryResults: createQueryResults(['kitchen', 'office']),
|
||||
});
|
||||
|
||||
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(
|
||||
new Set(['id-kitchen-0']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip a viewer grid camera without a selected media', () => {
|
||||
const queryResults = createQueryResults(['kitchen', 'office']);
|
||||
queryResults.resetSelectedResult('office');
|
||||
const view = createView({
|
||||
view: 'media',
|
||||
displayMode: 'grid',
|
||||
queryResults,
|
||||
});
|
||||
|
||||
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(
|
||||
new Set(['id-kitchen-0']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the lone camera of a live grid that needs no layout', () => {
|
||||
const view = createView({
|
||||
view: 'live',
|
||||
camera: 'kitchen',
|
||||
displayMode: 'grid',
|
||||
});
|
||||
|
||||
expect(getDisplayedTargetIDs(view, createCameraManager(['kitchen']))).toEqual(
|
||||
new Set(['kitchen']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the lone media of a viewer grid that needs no layout', () => {
|
||||
const view = createView({
|
||||
view: 'media',
|
||||
displayMode: 'grid',
|
||||
queryResults: createQueryResults(['kitchen']),
|
||||
});
|
||||
|
||||
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(
|
||||
new Set(['id-kitchen-0']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return nothing for a viewer grid without query results', () => {
|
||||
const view = createView({ view: 'media', displayMode: 'grid' });
|
||||
|
||||
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(new Set());
|
||||
});
|
||||
|
||||
it('should return the sentinel for the image view', () => {
|
||||
const view = createView({ view: 'image' });
|
||||
|
||||
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(
|
||||
new Set([IMAGE_VIEW_TARGET_ID_SENTINEL]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return nothing for a view that displays no media', () => {
|
||||
const view = createView({ view: 'timeline' });
|
||||
|
||||
expect(getDisplayedTargetIDs(view, createCameraManager([]))).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user