feat: Automatically recover from frozen live streams (#2569)
- Closes: #2099
This commit is contained in:
@@ -355,6 +355,13 @@ triggers:
|
|||||||
> the condition remains satisfied while any stream of the camera continues to
|
> the condition remains satisfied while any stream of the camera continues to
|
||||||
> render.
|
> render.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> When a live stream drops and the card reconnects automatically (for example
|
||||||
|
> after a camera or backend restart), `media_loaded` briefly becomes `false`
|
||||||
|
> while the stream reloads, then returns to `true` once media resumes.
|
||||||
|
> Automations that react to `media_loaded` should tolerate this transient
|
||||||
|
> transition.
|
||||||
|
|
||||||
## `microphone`
|
## `microphone`
|
||||||
|
|
||||||
Matches the microphone state. As a **condition**, true while the mute state
|
Matches the microphone state. As a **condition**, true while the mute state
|
||||||
|
|||||||
+22
-2
@@ -60,9 +60,9 @@ To resolve it:
|
|||||||
|
|
||||||
The notification clears once the `__UPGRADE_FAILURE__` key is gone.
|
The notification clears once the `__UPGRADE_FAILURE__` key is gone.
|
||||||
|
|
||||||
### Media does not load
|
### Media unavailable
|
||||||
|
|
||||||
Media not loading? Permanent "loading circle"?
|
Media not loading? Permanent "loading circle"? Live view frozen?
|
||||||
|
|
||||||
Media failing to load is a relatively common error, but can be caused by any
|
Media failing to load is a relatively common error, but can be caused by any
|
||||||
number of issues (e.g. installation problems, networking problems, video/codec
|
number of issues (e.g. installation problems, networking problems, video/codec
|
||||||
@@ -74,6 +74,26 @@ cameras with a `camera_entity` configured, will show images refreshing once per
|
|||||||
second until the stream has fully loaded (unless `live.show_image_during_load`
|
second until the stream has fully loaded (unless `live.show_image_during_load`
|
||||||
is set to false).
|
is set to false).
|
||||||
|
|
||||||
|
The card attempts to recover from media playback issues. Some runtime failures
|
||||||
|
are silent: the video input simply freezes on the last frame with no error (e.g.
|
||||||
|
Frigate or camera restart, power loss, temporary network connectivity issue), so
|
||||||
|
the card watches a playing stream for a loss of new frames and treats such a
|
||||||
|
freeze as unavailable.
|
||||||
|
|
||||||
|
While media is unavailable the loading indicator returns and an alert icon
|
||||||
|
appears in the status bar. The card retries automatically with a back-off (and
|
||||||
|
the notification offers a manual retry button). A live stream additionally
|
||||||
|
reconnects on its own once its camera becomes available again.
|
||||||
|
|
||||||
|
Reported reasons why media may be unavailable:
|
||||||
|
|
||||||
|
| Reason | Meaning |
|
||||||
|
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Camera entity unavailable** | The camera's `camera_entity` reported `unavailable` in Home Assistant, e.g. the camera or its integration (such as Frigate) restarted, or the camera lost power or network. A short grace period is allowed before this is reported, and the card recovers automatically once the entity returns. Set [`always_error_if_entity_unavailable`](./configuration/cameras/README.md?id=cameras) to report it immediately instead. |
|
||||||
|
| **Media not loading** | The media did not finish loading within the expected time: a slow or failed initial load. Applies to live streams, the viewer, and image views. |
|
||||||
|
| **Playback error** | The live provider reported an error while trying to play the stream. |
|
||||||
|
| **Stream stalled** | The stream loaded and was playing, but stopped delivering new frames with no error raised (a silent freeze). The card notices the lack of progress and reconnects. |
|
||||||
|
|
||||||
Debugging steps:
|
Debugging steps:
|
||||||
|
|
||||||
1. If you're using the default `auto` live provider, or explicitly setting the
|
1. If you're using the default `auto` live provider, or explicitly setting the
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export class Camera {
|
|||||||
protected _eventCallback?: CameraEventCallback;
|
protected _eventCallback?: CameraEventCallback;
|
||||||
protected _destroyCallbacks: DestroyCallback[] = [];
|
protected _destroyCallbacks: DestroyCallback[] = [];
|
||||||
protected _entity: Entity | null = null;
|
protected _entity: Entity | null = null;
|
||||||
|
protected _initialized = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: CameraConfig,
|
config: CameraConfig,
|
||||||
@@ -79,6 +80,10 @@ export class Camera {
|
|||||||
return this._entity;
|
return this._entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isInitialized(): boolean {
|
||||||
|
return this._initialized;
|
||||||
|
}
|
||||||
|
|
||||||
async initialize(options: CameraInitializationOptions): Promise<Camera> {
|
async initialize(options: CameraInitializationOptions): Promise<Camera> {
|
||||||
// Freeze a single HASS snapshot for the whole (async, multi-step)
|
// Freeze a single HASS snapshot for the whole (async, multi-step)
|
||||||
// initialization so every step observes a consistent entity world; live
|
// initialization so every step observes a consistent entity world; live
|
||||||
@@ -116,6 +121,7 @@ export class Camera {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._initialized = true;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,11 +146,13 @@ export class FrigateCamera extends Camera {
|
|||||||
if (!this._config.frigate.client_id) {
|
if (!this._config.frigate.client_id) {
|
||||||
const stateEntity = cameraEntity ? hass.states[cameraEntity] : undefined;
|
const stateEntity = cameraEntity ? hass.states[cameraEntity] : undefined;
|
||||||
const clientID = stateEntity?.attributes?.client_id;
|
const clientID = stateEntity?.attributes?.client_id;
|
||||||
if (typeof clientID === 'string' && clientID) {
|
|
||||||
this._config.frigate.client_id = clientID;
|
// Prefer the client_id the entity advertises. When it is unreadable (e.g.
|
||||||
} else if (stateEntity?.state !== 'unavailable') {
|
// the entity is unavailable) assume the integration default:
|
||||||
this._config.frigate.client_id = 'frigate';
|
// initialization runs once, so leaving client_id unresolved would leave
|
||||||
}
|
// the camera permanently without endpoints.
|
||||||
|
this._config.frigate.client_id =
|
||||||
|
typeof clientID === 'string' && clientID ? clientID : 'frigate';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,34 +264,38 @@ export class FrigateCamera extends Camera {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override _getGo2RTCMetadataEndpoint(): Endpoint | null {
|
// Build a go2rtc endpoint from an explicitly-configured go2rtc URL, or
|
||||||
const stream = this._config.go2rtc?.stream ?? this._config.frigate.camera_name;
|
// otherwise from the Frigate integration's proxy under the given path (the
|
||||||
|
// integration exposes the go2rtc stream API under 'mse' and the metadata API
|
||||||
|
// under 'go2rtc'). Without either a configured URL or a resolved client_id
|
||||||
|
// there is no usable endpoint.
|
||||||
|
private _buildGo2RTCEndpoint(
|
||||||
|
path: 'go2rtc' | 'mse',
|
||||||
|
builder: (
|
||||||
|
cameraConfig: CameraConfig,
|
||||||
|
options: { url: string; stream?: string },
|
||||||
|
) => Endpoint | null,
|
||||||
|
): Endpoint | null {
|
||||||
const url =
|
const url =
|
||||||
this._config.go2rtc?.url ??
|
this._config.go2rtc?.url ??
|
||||||
(this._config.frigate.client_id
|
(this._config.frigate.client_id
|
||||||
? `/api/frigate/${this._config.frigate.client_id}/go2rtc`
|
? `/api/frigate/${this._config.frigate.client_id}/${path}`
|
||||||
: null);
|
: null);
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return getGo2RTCMetadataEndpoint(this._config, { url, stream });
|
return builder(this._config, {
|
||||||
|
url,
|
||||||
|
stream: this._config.go2rtc?.stream ?? this._config.frigate.camera_name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override _getGo2RTCMetadataEndpoint(): Endpoint | null {
|
||||||
|
return this._buildGo2RTCEndpoint('go2rtc', getGo2RTCMetadataEndpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override _getGo2RTCStreamEndpoint(): Endpoint | null {
|
protected override _getGo2RTCStreamEndpoint(): Endpoint | null {
|
||||||
const stream = this._config.go2rtc?.stream ?? this._config.frigate.camera_name;
|
return this._buildGo2RTCEndpoint('mse', getGo2RTCStreamEndpoint);
|
||||||
const url =
|
|
||||||
this._config.go2rtc?.url ??
|
|
||||||
// go2rtc is exposed by the Frigate integration under the 'mse' path.
|
|
||||||
(this._config.frigate.client_id
|
|
||||||
? `/api/frigate/${this._config.frigate.client_id}/mse`
|
|
||||||
: null);
|
|
||||||
if (!url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return getGo2RTCStreamEndpoint(this._config, {
|
|
||||||
url,
|
|
||||||
stream,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getJSMPEGEndpoint(): Endpoint | null {
|
private _getJSMPEGEndpoint(): Endpoint | null {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isHassReady } from '../../ha/is-hass-ready';
|
import { isHassReady } from '../../ha/is-hass-ready';
|
||||||
import type { HASSListener, HASSUnlistenCallback } from '../../ha/source';
|
import type { HASSListener } from '../../ha/source';
|
||||||
import type { HomeAssistant } from '../../ha/types';
|
import type { HomeAssistant } from '../../ha/types';
|
||||||
|
import type { UnsubscribeCallback } from '../../types';
|
||||||
import { log } from '../../utils/debug';
|
import { log } from '../../utils/debug';
|
||||||
import { InitializationAspect } from '../initialization-manager';
|
import { InitializationAspect } from '../initialization-manager';
|
||||||
import type { CardHASSAPI } from '../types';
|
import type { CardHASSAPI } from '../types';
|
||||||
@@ -39,7 +40,7 @@ export class HASSManager implements HASSManagerReadonlyInterface {
|
|||||||
return this._eventWatcher;
|
return this._eventWatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
public addListener(listener: HASSListener): HASSUnlistenCallback {
|
public addListener(listener: HASSListener): UnsubscribeCallback {
|
||||||
this._hassListeners.add(listener);
|
this._hassListeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
this._hassListeners.delete(listener);
|
this._hassListeners.delete(listener);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getHassDifferences } from '../../ha/get-hass-differences';
|
import { getHassDifferences } from '../../ha/get-hass-differences';
|
||||||
import type { HASSSource, HASSUnlistenCallback } from '../../ha/source';
|
import type { HASSSource } from '../../ha/source';
|
||||||
import type { HassStateDifference, HomeAssistant } from '../../ha/types';
|
import type { HassStateDifference, HomeAssistant } from '../../ha/types';
|
||||||
|
import type { UnsubscribeCallback } from '../../types';
|
||||||
|
|
||||||
type StateWatcherCallback = (difference: HassStateDifference) => void;
|
type StateWatcherCallback = (difference: HassStateDifference) => void;
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ export interface StateWatcherSubscriptionInterface {
|
|||||||
export class StateWatcher implements StateWatcherSubscriptionInterface {
|
export class StateWatcher implements StateWatcherSubscriptionInterface {
|
||||||
private _source: HASSSource;
|
private _source: HASSSource;
|
||||||
private _watcherCallbacks = new Map<StateWatcherCallback, string[]>();
|
private _watcherCallbacks = new Map<StateWatcherCallback, string[]>();
|
||||||
private _unlisten: HASSUnlistenCallback | null = null;
|
private _unlisten: UnsubscribeCallback | null = null;
|
||||||
|
|
||||||
constructor(source: HASSSource) {
|
constructor(source: HASSSource) {
|
||||||
this._source = source;
|
this._source = source;
|
||||||
|
|||||||
@@ -234,9 +234,10 @@ export class InitializationManager {
|
|||||||
this._api.getCardElementManager().update();
|
this._api.getCardElementManager().update();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _tryInitialize(fn: () => Promise<void>): Promise<boolean> {
|
private async _tryInitialize(fn: () => Promise<boolean>): Promise<boolean> {
|
||||||
|
let initialized = false;
|
||||||
try {
|
try {
|
||||||
await fn();
|
initialized = await fn();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
errorToConsole(e);
|
errorToConsole(e);
|
||||||
@@ -249,7 +250,7 @@ export class InitializationManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return initialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setInitializationIssue(error: unknown): void {
|
private _setInitializationIssue(error: unknown): void {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { ConnectionIssue } from './issues/connection';
|
|||||||
import { EventSubscriptionIssue } from './issues/event-subscription';
|
import { EventSubscriptionIssue } from './issues/event-subscription';
|
||||||
import { InitializationIssue } from './issues/initialization';
|
import { InitializationIssue } from './issues/initialization';
|
||||||
import { LegacyResourceIssue } from './issues/legacy-resource';
|
import { LegacyResourceIssue } from './issues/legacy-resource';
|
||||||
import { MediaLoadIssue } from './issues/media-load';
|
|
||||||
import { MediaQueryIssue } from './issues/media-query';
|
import { MediaQueryIssue } from './issues/media-query';
|
||||||
|
import { MediaUnavailableIssue } from './issues/media-unavailable';
|
||||||
import { ViewIncompatibleIssue } from './issues/view-incompatible';
|
import { ViewIncompatibleIssue } from './issues/view-incompatible';
|
||||||
|
|
||||||
export const createIssueManager = (
|
export const createIssueManager = (
|
||||||
@@ -32,7 +32,7 @@ export const createIssueManager = (
|
|||||||
manager.addIssue(new InitializationIssue(api));
|
manager.addIssue(new InitializationIssue(api));
|
||||||
manager.addIssue(new LegacyResourceIssue(changeCallback));
|
manager.addIssue(new LegacyResourceIssue(changeCallback));
|
||||||
manager.addIssue(new MediaQueryIssue(api));
|
manager.addIssue(new MediaQueryIssue(api));
|
||||||
manager.addIssue(new MediaLoadIssue(api, changeCallback));
|
manager.addIssue(new MediaUnavailableIssue(api, changeCallback));
|
||||||
|
|
||||||
return manager;
|
return manager;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import type {
|
|||||||
IssueTriggerContextKey,
|
IssueTriggerContextKey,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// Exponential backoff schedule for 'auto' retry. The base is set above the
|
// Exponential backoff schedule for 'auto' retry. The first attempt fires
|
||||||
// per-media retry threshold (~10s) so the issue-level backoff kicks in *after*
|
// quickly so recovery is prompt, then the delay doubles (up to the max) so a
|
||||||
// lower-level recovery has had a chance to work, not in parallel with it.
|
// persistently-failing issue is not retried indefinitely at a tight interval.
|
||||||
export const RETRY_EXPONENTIAL_BASE_SECONDS = 30;
|
export const RETRY_EXPONENTIAL_BASE_SECONDS = 5;
|
||||||
export const RETRY_EXPONENTIAL_MAX_SECONDS = 600;
|
export const RETRY_EXPONENTIAL_MAX_SECONDS = 600;
|
||||||
|
|
||||||
// Wraps the passive IssueStateManager with reaction logic. A single
|
// Wraps the passive IssueStateManager with reaction logic. A single
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Notification } from '../../../config/schema/actions/types';
|
import type { Notification } from '../../../config/schema/actions/types';
|
||||||
import type { SubscriptionHealthInterface } from '../../../ha/connection/subscription-health-monitor';
|
import type { SubscriptionHealthInterface } from '../../../ha/connection/subscription-health-monitor';
|
||||||
import type { UnlistenCallback } from '../../../health';
|
|
||||||
import { localize } from '../../../localize/localize';
|
import { localize } from '../../../localize/localize';
|
||||||
|
import type { UnsubscribeCallback } from '../../../types';
|
||||||
import { createRetryControl } from '../retry-control';
|
import { createRetryControl } from '../retry-control';
|
||||||
import type { Issue, IssueDescription } from '../types';
|
import type { Issue, IssueDescription } from '../types';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ export class EventSubscriptionIssue implements Issue {
|
|||||||
public readonly key = 'event_subscription' as const;
|
public readonly key = 'event_subscription' as const;
|
||||||
|
|
||||||
private _health: SubscriptionHealthInterface<string>;
|
private _health: SubscriptionHealthInterface<string>;
|
||||||
private _unsubscribe: UnlistenCallback;
|
private _unsubscribe: UnsubscribeCallback;
|
||||||
|
|
||||||
constructor(health: SubscriptionHealthInterface<string>, changeCallback: () => void) {
|
constructor(health: SubscriptionHealthInterface<string>, changeCallback: () => void) {
|
||||||
this._health = health;
|
this._health = health;
|
||||||
|
|||||||
+127
-57
@@ -1,9 +1,13 @@
|
|||||||
import type { IssueTriggerContext } from 'issue';
|
import type { IssueTriggerContext } from 'issue';
|
||||||
|
|
||||||
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
|
import type { ConditionState } from '../../../condition-trigger/conditions/types.js';
|
||||||
import type { Notification } from '../../../config/schema/actions/types.js';
|
import type {
|
||||||
|
Notification,
|
||||||
|
NotificationDetail,
|
||||||
|
} from '../../../config/schema/actions/types.js';
|
||||||
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
|
import { TROUBLESHOOTING_MEDIA_URL } from '../../../const.js';
|
||||||
import { localize } from '../../../localize/localize.js';
|
import { localize } from '../../../localize/localize.js';
|
||||||
|
import type { UnsubscribeCallback } from '../../../types.js';
|
||||||
import { Timer } from '../../../utils/timer.js';
|
import { Timer } from '../../../utils/timer.js';
|
||||||
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js';
|
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../view/target-id.js';
|
||||||
import { isAnyMediaViewName } from '../../../view/view.js';
|
import { isAnyMediaViewName } from '../../../view/view.js';
|
||||||
@@ -11,19 +15,52 @@ import type { CardIssueManagerAPI } from '../../types.js';
|
|||||||
import { createRetryControl } from '../retry-control.js';
|
import { createRetryControl } from '../retry-control.js';
|
||||||
import type { Issue, IssueDescription } from '../types.js';
|
import type { Issue, IssueDescription } from '../types.js';
|
||||||
|
|
||||||
|
// Why the media_unavailable issue fired, so the notification and the reconnecting
|
||||||
|
// placeholder can explain the specific cause rather than a generic message.
|
||||||
|
export type MediaUnavailableIssueReason =
|
||||||
|
| 'entity_unavailable'
|
||||||
|
| 'not_loading'
|
||||||
|
| 'playback_error'
|
||||||
|
| 'stalled';
|
||||||
|
|
||||||
declare module 'issue' {
|
declare module 'issue' {
|
||||||
interface IssueTriggerContext {
|
interface IssueTriggerContext {
|
||||||
media_load: { targetID: string };
|
media_unavailable: { targetID: string; reason: MediaUnavailableIssueReason };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
|
const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
|
||||||
|
|
||||||
export class MediaLoadIssue implements Issue {
|
// The per-cause presentation (localization key + icon), shared by the
|
||||||
public readonly key = 'media_load' as const;
|
// notification metadata and the reconnecting placeholder so each cause is
|
||||||
|
// described in exactly one place.
|
||||||
|
export const MEDIA_UNAVAILABLE_REASONS: Record<
|
||||||
|
MediaUnavailableIssueReason,
|
||||||
|
{ localizationKey: string; icon: string }
|
||||||
|
> = {
|
||||||
|
entity_unavailable: {
|
||||||
|
localizationKey: 'issues.media_unavailable.reasons.entity_unavailable',
|
||||||
|
icon: 'mdi:cctv-off',
|
||||||
|
},
|
||||||
|
not_loading: {
|
||||||
|
localizationKey: 'issues.media_unavailable.reasons.not_loading',
|
||||||
|
icon: 'mdi:progress-helper',
|
||||||
|
},
|
||||||
|
playback_error: {
|
||||||
|
localizationKey: 'issues.media_unavailable.reasons.playback_error',
|
||||||
|
icon: 'mdi:alert-circle',
|
||||||
|
},
|
||||||
|
stalled: {
|
||||||
|
localizationKey: 'issues.media_unavailable.reasons.stalled',
|
||||||
|
icon: 'mdi:motion-pause',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export class MediaUnavailableIssue implements Issue {
|
||||||
|
public readonly key = 'media_unavailable' as const;
|
||||||
|
|
||||||
private _issueActive = false;
|
private _issueActive = false;
|
||||||
private _erroredTargetIDs = new Set<string>();
|
private _erroredTargets = new Map<string, MediaUnavailableIssueReason>();
|
||||||
|
|
||||||
// Timer fires when a target has been loading too long without success.
|
// Timer fires when a target has been loading too long without success.
|
||||||
private _timer = new Timer();
|
private _timer = new Timer();
|
||||||
@@ -31,18 +68,31 @@ export class MediaLoadIssue implements Issue {
|
|||||||
|
|
||||||
private _api: CardIssueManagerAPI;
|
private _api: CardIssueManagerAPI;
|
||||||
private _onChange: (() => void) | null;
|
private _onChange: (() => void) | null;
|
||||||
|
private _unsubscribeCallback: UnsubscribeCallback;
|
||||||
|
|
||||||
constructor(api: CardIssueManagerAPI, onChange?: () => void) {
|
constructor(api: CardIssueManagerAPI, onChange?: () => void) {
|
||||||
this._api = api;
|
this._api = api;
|
||||||
this._onChange = onChange ?? null;
|
this._onChange = onChange ?? null;
|
||||||
|
|
||||||
|
// Clear a target's error on a genuine media (re)load.
|
||||||
|
this._unsubscribeCallback = this._api
|
||||||
|
.getMediaLoadedInfoManager()
|
||||||
|
.subscribe((change) => {
|
||||||
|
// A reconnect replay (`cached`) did not actually reload the media, and
|
||||||
|
// unload / select changes are irrelevant here; only a genuine load
|
||||||
|
// clears the error.
|
||||||
|
if (change.type === 'load' && !change.cached) {
|
||||||
|
this._onMediaLoad(change.targetID);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Explicit trigger -- called when a component fires an issue:trigger event.
|
// Explicit trigger -- called when a component fires an issue:trigger event.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
public trigger(context: IssueTriggerContext['media_load']): void {
|
public trigger(context: IssueTriggerContext['media_unavailable']): void {
|
||||||
this._erroredTargetIDs.add(context.targetID);
|
this._erroredTargets.set(context.targetID, context.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -55,10 +105,29 @@ export class MediaLoadIssue implements Issue {
|
|||||||
return;
|
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 `_onMediaLoad` on a genuine
|
||||||
|
// reload.
|
||||||
|
if (this._hasError(state)) {
|
||||||
|
this._activate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (state.mediaLoadedInfo) {
|
if (state.mediaLoadedInfo) {
|
||||||
this._handleMediaLoaded(state);
|
// Loaded with no known error: healthy.
|
||||||
} else {
|
this._deactivate();
|
||||||
this._handleMediaNotLoaded(state);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._handlePendingLoad(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genuine media (re)load for a target clears its error.
|
||||||
|
private _onMediaLoad(targetID: string): void {
|
||||||
|
if (this._erroredTargets.delete(targetID)) {
|
||||||
|
this._onChange?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,28 +151,26 @@ export class MediaLoadIssue implements Issue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getNotification(): Notification {
|
public getNotification(): Notification {
|
||||||
const targets = new Set(this._erroredTargetIDs);
|
const targets = new Map(this._erroredTargets);
|
||||||
if (this._timerTargetID) {
|
// The pending-load timer's target is a slow initial load that has not yet
|
||||||
targets.add(this._timerTargetID);
|
// errored.
|
||||||
|
if (this._timerTargetID && !targets.has(this._timerTargetID)) {
|
||||||
|
targets.set(this._timerTargetID, 'not_loading');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
heading: {
|
heading: {
|
||||||
text: localize('issues.media_load.heading'),
|
text: localize('issues.media_unavailable.heading'),
|
||||||
icon: 'mdi:cctv-off',
|
icon: 'mdi:cctv-off',
|
||||||
severity: 'high' as const,
|
severity: 'high' as const,
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
text: localize('issues.media_load.text'),
|
text: localize('issues.media_unavailable.text'),
|
||||||
},
|
},
|
||||||
...(targets.size && {
|
...(targets.size && {
|
||||||
metadata: Array.from(targets).map((id) => ({
|
metadata: Array.from(targets).map(([id, reason]) =>
|
||||||
text:
|
this._getTargetDetail(id, reason),
|
||||||
id === IMAGE_VIEW_TARGET_ID_SENTINEL
|
),
|
||||||
? localize('editor.image')
|
|
||||||
: this._api.getCameraManager().getCameraMetadata(id)?.title ?? id,
|
|
||||||
icon: id === IMAGE_VIEW_TARGET_ID_SENTINEL ? 'mdi:image' : 'mdi:cctv',
|
|
||||||
})),
|
|
||||||
}),
|
}),
|
||||||
link: {
|
link: {
|
||||||
url: TROUBLESHOOTING_MEDIA_URL,
|
url: TROUBLESHOOTING_MEDIA_URL,
|
||||||
@@ -113,6 +180,22 @@ export class MediaLoadIssue implements Issue {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A per-camera notification detail: the target's name and its specific cause
|
||||||
|
// (e.g. "Office: Stream stalled"), with that cause's icon.
|
||||||
|
private _getTargetDetail(
|
||||||
|
id: string,
|
||||||
|
reason: MediaUnavailableIssueReason,
|
||||||
|
): NotificationDetail {
|
||||||
|
const isImage = id === IMAGE_VIEW_TARGET_ID_SENTINEL;
|
||||||
|
const name = isImage
|
||||||
|
? localize('editor.image')
|
||||||
|
: this._api.getCameraManager().getCameraMetadata(id)?.title ?? id;
|
||||||
|
return {
|
||||||
|
text: `${name}: ${localize(MEDIA_UNAVAILABLE_REASONS[reason].localizationKey)}`,
|
||||||
|
icon: isImage ? 'mdi:image' : MEDIA_UNAVAILABLE_REASONS[reason].icon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Retry -- called by the manager to schedule a media reload.
|
// Retry -- called by the manager to schedule a media reload.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -125,7 +208,7 @@ export class MediaLoadIssue implements Issue {
|
|||||||
// Build the set of targets to retry: all errored targets plus the
|
// Build the set of targets to retry: all errored targets plus the
|
||||||
// target the pending timer was tracking (so a user-initiated retry
|
// target the pending timer was tracking (so a user-initiated retry
|
||||||
// works even before the timeout fires).
|
// works even before the timeout fires).
|
||||||
const retryTargets = new Set(this._erroredTargetIDs);
|
const retryTargets = new Set(this._erroredTargets.keys());
|
||||||
if (this._timerTargetID) {
|
if (this._timerTargetID) {
|
||||||
retryTargets.add(this._timerTargetID);
|
retryTargets.add(this._timerTargetID);
|
||||||
}
|
}
|
||||||
@@ -140,11 +223,11 @@ export class MediaLoadIssue implements Issue {
|
|||||||
mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1;
|
mediaEpoch[id] = (mediaEpoch[id] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intentionally keep _issueActive, _erroredTargetIDs, and the pending
|
// Intentionally keep _issueActive, _erroredTargets, and the pending
|
||||||
// timer in place. The issue stays visible while the provider
|
// timer in place. The issue stays visible while the provider
|
||||||
// re-attempts loading underneath. If the retry succeeds,
|
// re-attempts loading underneath. If the retry succeeds, the fresh media
|
||||||
// _handleMediaLoaded will clear everything when media:loaded fires. If
|
// load clears everything (_onMediaLoad drops the errored target). If it
|
||||||
// it fails silently (e.g. bogus stream name), the error stays visible
|
// fails silently (e.g. bogus stream name), the error stays visible
|
||||||
// immediately -- no new 10s grace period.
|
// immediately -- no new 10s grace period.
|
||||||
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
|
this._api.getViewManager().setViewWithMergedContext({ mediaEpoch });
|
||||||
return false;
|
return false;
|
||||||
@@ -156,14 +239,19 @@ export class MediaLoadIssue implements Issue {
|
|||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
this._deactivate();
|
this._deactivate();
|
||||||
this._erroredTargetIDs.clear();
|
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
|
// Stop the pending-load timer so offscreen time doesn't count toward the
|
||||||
// 10s threshold. Preserve _issueActive, _erroredTargetIDs, and
|
// 10s threshold. Preserve _issueActive, _erroredTargets, and
|
||||||
// _timerTargetID: already-visible errors remain visible on reattach, and
|
// _timerTargetID: already-visible errors remain visible on reattach, and
|
||||||
// retaining _timerTargetID lets the existing active/target-mismatch guard
|
// retaining _timerTargetID lets the existing active/target-mismatch guard
|
||||||
// in _handleMediaNotLoaded avoid spuriously deactivating the preserved
|
// in _handlePendingLoad avoid spuriously deactivating the preserved
|
||||||
// issue when the same target is still loading on resume. The timer is
|
// issue when the same target is still loading on resume. The timer is
|
||||||
// re-armed with a fresh window by the next detectDynamic pass.
|
// re-armed with a fresh window by the next detectDynamic pass.
|
||||||
public suspend(): void {
|
public suspend(): void {
|
||||||
@@ -174,36 +262,20 @@ export class MediaLoadIssue implements Issue {
|
|||||||
// Private helpers.
|
// Private helpers.
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
||||||
// Media loaded successfully: deactivate and clear the error for this target
|
// Media not yet loaded and no known error: start (or keep) a timeout to catch
|
||||||
// so it won't immediately re-trigger on the next evaluation.
|
// a slow or failed initial load. No targetID means no provider is rendering
|
||||||
private _handleMediaLoaded(state: ConditionState): void {
|
// media (e.g. the viewer shows "No media to display"), so there's nothing to
|
||||||
this._deactivate();
|
// wait for.
|
||||||
if (state.targetID) {
|
private _handlePendingLoad(state: ConditionState): void {
|
||||||
this._erroredTargetIDs.delete(state.targetID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Media not yet loaded: activate immediately if there is a known provider
|
|
||||||
// error for this target, otherwise start a timeout to detect slow loads.
|
|
||||||
private _handleMediaNotLoaded(state: ConditionState): void {
|
|
||||||
// No targetID means no provider is actively rendering media (e.g. the
|
|
||||||
// viewer shows "No media to display" instead of showing a player). Don't
|
|
||||||
// start the timeout as there's nothing to wait for.
|
|
||||||
if (!state.targetID) {
|
if (!state.targetID) {
|
||||||
this._deactivate();
|
this._deactivate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._hasError(state)) {
|
|
||||||
this._activate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetID = state.targetID;
|
const targetID = state.targetID;
|
||||||
|
|
||||||
// When the target changes to one without a known error, clear the active
|
// When the target changes, clear the active state so the new target gets
|
||||||
// state so the new target gets its own timeout window instead of
|
// its own timeout window instead of inheriting the previous target's.
|
||||||
// inheriting the previous target's error.
|
|
||||||
if (this._issueActive && this._timerTargetID !== targetID) {
|
if (this._issueActive && this._timerTargetID !== targetID) {
|
||||||
this._deactivate();
|
this._deactivate();
|
||||||
}
|
}
|
||||||
@@ -213,9 +285,7 @@ export class MediaLoadIssue implements Issue {
|
|||||||
this._timerTargetID = targetID;
|
this._timerTargetID = targetID;
|
||||||
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
|
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => {
|
||||||
// Record the error on timeout so retry() knows which epoch to bump.
|
// Record the error on timeout so retry() knows which epoch to bump.
|
||||||
// targetID is guaranteed non-null here -- the null case bails at the
|
this._erroredTargets.set(targetID, 'not_loading');
|
||||||
// top of _handleMediaNotLoaded.
|
|
||||||
this._erroredTargetIDs.add(targetID);
|
|
||||||
this._activate();
|
this._activate();
|
||||||
this._onChange?.();
|
this._onChange?.();
|
||||||
});
|
});
|
||||||
@@ -223,7 +293,7 @@ export class MediaLoadIssue implements Issue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _hasError(state: ConditionState): boolean {
|
private _hasError(state: ConditionState): boolean {
|
||||||
return !!state.targetID && this._erroredTargetIDs.has(state.targetID);
|
return !!state.targetID && this._erroredTargets.has(state.targetID);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _activate(): void {
|
private _activate(): void {
|
||||||
@@ -13,8 +13,8 @@ export type IssueKey =
|
|||||||
| 'event_subscription'
|
| 'event_subscription'
|
||||||
| 'initialization'
|
| 'initialization'
|
||||||
| 'legacy_resource'
|
| 'legacy_resource'
|
||||||
| 'media_load'
|
|
||||||
| 'media_query'
|
| 'media_query'
|
||||||
|
| 'media_unavailable'
|
||||||
| 'view_incompatible';
|
| 'view_incompatible';
|
||||||
|
|
||||||
export interface IssueDescription {
|
export interface IssueDescription {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
MediaLoadedInfo,
|
MediaLoadedInfo,
|
||||||
MediaLoadedInfoEventDetail,
|
MediaLoadedInfoEventDetail,
|
||||||
MediaLoadedInfoOwner,
|
MediaLoadedInfoOwner,
|
||||||
|
UnsubscribeCallback,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { onAbort } from '../utils/abort-signal';
|
import { onAbort } from '../utils/abort-signal';
|
||||||
import { log } from '../utils/debug';
|
import { log } from '../utils/debug';
|
||||||
@@ -13,6 +14,21 @@ interface ActiveEntry {
|
|||||||
owner: MediaLoadedInfoOwner;
|
owner: MediaLoadedInfoOwner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A change delivered to subscribers: a target's active media loading or
|
||||||
|
// unloading, or the selected target changing.
|
||||||
|
export type MediaLoadedInfoChange =
|
||||||
|
// A target's media (re)loaded. `cached` marks a replay (a reconnect
|
||||||
|
// re-dispatch, not an actual reload) rather than a genuine load.
|
||||||
|
| { type: 'load'; targetID: string; info: MediaLoadedInfo; cached: boolean }
|
||||||
|
|
||||||
|
// A target's media was retired.
|
||||||
|
| { type: 'unload'; targetID: string }
|
||||||
|
|
||||||
|
// The selected (current) target changed.
|
||||||
|
| { type: 'select'; targetID: string | null };
|
||||||
|
|
||||||
|
type MediaLoadedInfoChangeCallback = (change: MediaLoadedInfoChange) => void;
|
||||||
|
|
||||||
export class MediaLoadedInfoManager {
|
export class MediaLoadedInfoManager {
|
||||||
private _api: CardMediaLoadedAPI;
|
private _api: CardMediaLoadedAPI;
|
||||||
|
|
||||||
@@ -31,18 +47,32 @@ export class MediaLoadedInfoManager {
|
|||||||
// and card-level side effects. Driven by ViewManager on every view change.
|
// and card-level side effects. Driven by ViewManager on every view change.
|
||||||
private _selected: string | null = null;
|
private _selected: string | null = null;
|
||||||
|
|
||||||
|
// Subscribers are notified of every change to the tracked media: a target's
|
||||||
|
// load / unload, and selection changes.
|
||||||
|
private _subscribers = new Set<MediaLoadedInfoChangeCallback>();
|
||||||
|
|
||||||
constructor(api: CardMediaLoadedAPI) {
|
constructor(api: CardMediaLoadedAPI) {
|
||||||
this._api = api;
|
this._api = api;
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize(): void {
|
public initialize(): void {
|
||||||
|
const cleared = [...this._active.keys()];
|
||||||
|
const hadSelected = this._selected !== null;
|
||||||
this._active.clear();
|
this._active.clear();
|
||||||
this._lastKnown.clear();
|
this._lastKnown.clear();
|
||||||
this._selected = null;
|
this._selected = null;
|
||||||
this._api.getConditionStateManager().setState({ mediaLoadedInfo: null });
|
this._api.getConditionStateManager().setState({ mediaLoadedInfo: null });
|
||||||
|
cleared.forEach((targetID) => this._notify({ type: 'unload', targetID }));
|
||||||
|
if (hadSelected) {
|
||||||
|
this._notify({ type: 'select', targetID: null });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public set(mediaLoadedInfo: MediaLoadedInfo, owner: MediaLoadedInfoOwner): void {
|
public set(
|
||||||
|
mediaLoadedInfo: MediaLoadedInfo,
|
||||||
|
owner: MediaLoadedInfoOwner,
|
||||||
|
cached?: boolean,
|
||||||
|
): void {
|
||||||
if (!isValidMediaLoadedInfo(mediaLoadedInfo) || !mediaLoadedInfo.targetID) {
|
if (!isValidMediaLoadedInfo(mediaLoadedInfo) || !mediaLoadedInfo.targetID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -60,6 +90,15 @@ export class MediaLoadedInfoManager {
|
|||||||
if (targetID === this._selected) {
|
if (targetID === this._selected) {
|
||||||
this._emitChange(mediaLoadedInfo);
|
this._emitChange(mediaLoadedInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify for every target, not just the selected one (e.g. a background
|
||||||
|
// grid camera).
|
||||||
|
this._notify({
|
||||||
|
type: 'load',
|
||||||
|
targetID,
|
||||||
|
info: mediaLoadedInfo,
|
||||||
|
cached: cached ?? false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setSelected(targetID: string | null): void {
|
public setSelected(targetID: string | null): void {
|
||||||
@@ -69,6 +108,7 @@ export class MediaLoadedInfoManager {
|
|||||||
|
|
||||||
this._selected = targetID;
|
this._selected = targetID;
|
||||||
this._emitChange(targetID ? this._active.get(targetID)?.info ?? null : null);
|
this._emitChange(targetID ? this._active.get(targetID)?.info ?? null : null);
|
||||||
|
this._notify({ type: 'select', targetID });
|
||||||
}
|
}
|
||||||
|
|
||||||
public get(): MediaLoadedInfo | null {
|
public get(): MediaLoadedInfo | null {
|
||||||
@@ -92,16 +132,18 @@ export class MediaLoadedInfoManager {
|
|||||||
if (!(owner instanceof HTMLElement) || !targetID) {
|
if (!(owner instanceof HTMLElement) || !targetID) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.set(ev.detail.info, owner);
|
this.set(ev.detail.info, owner, ev.detail.cached);
|
||||||
onAbort(ev.detail.signal, () => this._clearTarget(targetID, owner));
|
onAbort(ev.detail.signal, () => this._clearTarget(targetID, owner));
|
||||||
}
|
}
|
||||||
|
|
||||||
public clear(): void {
|
public clear(): void {
|
||||||
|
const cleared = [...this._active.keys()];
|
||||||
const selectedHadInfo = !!this._selected && this._active.has(this._selected);
|
const selectedHadInfo = !!this._selected && this._active.has(this._selected);
|
||||||
this._active.clear();
|
this._active.clear();
|
||||||
if (selectedHadInfo) {
|
if (selectedHadInfo) {
|
||||||
this._api.getConditionStateManager().setState({ mediaLoadedInfo: null });
|
this._api.getConditionStateManager().setState({ mediaLoadedInfo: null });
|
||||||
}
|
}
|
||||||
|
cleared.forEach((targetID) => this._notify({ type: 'unload', targetID }));
|
||||||
}
|
}
|
||||||
|
|
||||||
private _clearTarget(targetID: string, owner: MediaLoadedInfoOwner): void {
|
private _clearTarget(targetID: string, owner: MediaLoadedInfoOwner): void {
|
||||||
@@ -114,6 +156,8 @@ export class MediaLoadedInfoManager {
|
|||||||
if (targetID === this._selected) {
|
if (targetID === this._selected) {
|
||||||
this._api.getConditionStateManager().setState({ mediaLoadedInfo: null });
|
this._api.getConditionStateManager().setState({ mediaLoadedInfo: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._notify({ type: 'unload', targetID });
|
||||||
}
|
}
|
||||||
|
|
||||||
private _emitChange(mediaLoadedInfo: MediaLoadedInfo | null): void {
|
private _emitChange(mediaLoadedInfo: MediaLoadedInfo | null): void {
|
||||||
@@ -121,4 +165,13 @@ export class MediaLoadedInfoManager {
|
|||||||
this._api.getStyleManager().setExpandedMode();
|
this._api.getStyleManager().setExpandedMode();
|
||||||
this._api.getCardElementManager().update();
|
this._api.getCardElementManager().update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public subscribe(listener: MediaLoadedInfoChangeCallback): UnsubscribeCallback {
|
||||||
|
this._subscribers.add(listener);
|
||||||
|
return () => this._subscribers.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _notify(change: MediaLoadedInfoChange): void {
|
||||||
|
[...this._subscribers].forEach((listener) => listener(change));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ export interface CardIssueManagerAPI {
|
|||||||
getConfigManager(): ConfigManager;
|
getConfigManager(): ConfigManager;
|
||||||
getInitializationManager(): InitializationManager;
|
getInitializationManager(): InitializationManager;
|
||||||
getInteractionManager(): InteractionManager;
|
getInteractionManager(): InteractionManager;
|
||||||
|
getMediaLoadedInfoManager(): MediaLoadedInfoManager;
|
||||||
getNotificationManager(): NotificationManager;
|
getNotificationManager(): NotificationManager;
|
||||||
getViewManager(): ViewManager;
|
getViewManager(): ViewManager;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,16 +354,26 @@ export class ViewManager implements ViewManagerInterface {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize = async (): Promise<void> => {
|
// Returns whether view initialization succeeded. A `false` return leaves the
|
||||||
|
// view "aspect" uninitialized so a later initialization attempt retries --
|
||||||
|
// the default-view set can legitimately produce no view (e.g. it declines to
|
||||||
|
// run while the cameras are concurrently re-initializing), and treating that
|
||||||
|
// as initialized would leave the card without a view permanently.
|
||||||
|
public initialize = async (): Promise<boolean> => {
|
||||||
// If the query string contains a view related action, we don't set any view
|
// If the query string contains a view related action, we don't set any view
|
||||||
// here and allow that action to be triggered by the next call of to execute
|
// here and allow that action to be triggered by the next call of to execute
|
||||||
// query actions (called at least once per render cycle).
|
// query actions (called at least once per render cycle).
|
||||||
// Related: https://github.com/dermotduffy/advanced-camera-card/issues/1200
|
// Related: https://github.com/dermotduffy/advanced-camera-card/issues/1200
|
||||||
if (!this._api.getQueryStringManager().hasViewRelatedActionsToRun()) {
|
if (this._api.getQueryStringManager().hasViewRelatedActionsToRun()) {
|
||||||
// This is not awaited to allow the initialization to complete before the
|
return true;
|
||||||
// query is answered.
|
|
||||||
void this.setViewDefaultWithNewQuery({ failSafe: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is not awaited to allow the initialization to complete before the
|
||||||
|
// query is answered. The view itself (without query results) is set
|
||||||
|
// synchronously before the query executes, so whether a view exists on
|
||||||
|
// return accurately reports success.
|
||||||
|
void this.setViewDefaultWithNewQuery({ failSafe: true });
|
||||||
|
return this.hasView();
|
||||||
};
|
};
|
||||||
|
|
||||||
private _setView(view: Readonly<View> | null): void {
|
private _setView(view: Readonly<View> | null): void {
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ class AdvancedCameraCard extends LitElement {
|
|||||||
<advanced-camera-card-views
|
<advanced-camera-card-views
|
||||||
${ref(this._refViews)}
|
${ref(this._refViews)}
|
||||||
.hass=${this._hass}
|
.hass=${this._hass}
|
||||||
|
.stateWatcher=${this._controller.getHASSManager().getStateWatcher()}
|
||||||
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
|
.viewManagerEpoch=${this._controller.getViewManager().getEpoch()}
|
||||||
.cameraManager=${cameraManager}
|
.cameraManager=${cameraManager}
|
||||||
.foldersManager=${this._controller.getFoldersManager()}
|
.foldersManager=${this._controller.getFoldersManager()}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
|
import type { StateWatcherSubscriptionInterface } from '../../../../card-controller/hass/state-watcher';
|
||||||
|
import type { HassStateDifference, HomeAssistant } from '../../../../ha/types';
|
||||||
|
import { Timer } from '../../../../utils/timer';
|
||||||
|
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
|
||||||
|
|
||||||
|
// A camera entity must stay `unavailable` this long before the stream is
|
||||||
|
// treated as lost. Shorter blips (e.g. during PTZ, see issue #2124) are
|
||||||
|
// tolerated.
|
||||||
|
export const LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS = 10;
|
||||||
|
|
||||||
|
interface EntityAvailabilityDetectorConfig {
|
||||||
|
getHASS: () => HomeAssistant | null;
|
||||||
|
getStateWatcher: () => StateWatcherSubscriptionInterface | null;
|
||||||
|
getCameraEntity: () => string | null;
|
||||||
|
|
||||||
|
// `always_error_if_entity_unavailable` (issue #1650): report the loss with no
|
||||||
|
// grace tolerance, so any unavailability surfaces immediately.
|
||||||
|
isAlwaysError: () => boolean;
|
||||||
|
|
||||||
|
onChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects a silent freeze via the camera entity: a `camera_entity` that stays
|
||||||
|
* `unavailable` past the grace window (e.g. a Frigate restart or camera
|
||||||
|
* power-cycle) is reported as not live (asking for a reconnecting placeholder),
|
||||||
|
* and live again when it returns. Observes the entity via StateWatcher (an event
|
||||||
|
* subscription), not by polling.
|
||||||
|
*/
|
||||||
|
export class EntityAvailabilityDetector implements LivenessDetector {
|
||||||
|
private _config: EntityAvailabilityDetectorConfig;
|
||||||
|
private _timer = new Timer();
|
||||||
|
|
||||||
|
private _active = false;
|
||||||
|
private _watchedEntity: string | null = null;
|
||||||
|
private _verdict: LivenessVerdict = { state: 'unknown' };
|
||||||
|
|
||||||
|
constructor(config: EntityAvailabilityDetectorConfig) {
|
||||||
|
this._config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe(): void {
|
||||||
|
this._active = true;
|
||||||
|
this._watch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsubscribe(): void {
|
||||||
|
// Stop watching and pause the grace timer, but keep `_verdict` so a
|
||||||
|
// reconnect resumes rather than restarts.
|
||||||
|
this._active = false;
|
||||||
|
this._config.getStateWatcher()?.unsubscribe(this._onEntityStateChange);
|
||||||
|
this._watchedEntity = null;
|
||||||
|
this._timer.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
// Re-point the subscription at the (possibly different) camera entity and
|
||||||
|
// start fresh.
|
||||||
|
this._verdict = { state: 'unknown' };
|
||||||
|
this._timer.stop();
|
||||||
|
this._watch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getVerdict(): LivenessVerdict {
|
||||||
|
return this._verdict;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point the subscription at the current camera entity and re-check its state.
|
||||||
|
private _watch(): void {
|
||||||
|
if (!this._active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stateWatcher = this._config.getStateWatcher();
|
||||||
|
const entityID = this._config.getCameraEntity();
|
||||||
|
if (entityID !== this._watchedEntity) {
|
||||||
|
stateWatcher?.unsubscribe(this._onEntityStateChange);
|
||||||
|
this._watchedEntity = entityID;
|
||||||
|
if (entityID) {
|
||||||
|
stateWatcher?.subscribe(this._onEntityStateChange, [entityID]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._check();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onEntityStateChange = (difference: HassStateDifference): void =>
|
||||||
|
// Trap: Evaluate the state carried by the event, not `getHASS()`: the
|
||||||
|
// StateWatcher fires synchronously from the card-level hass update, before
|
||||||
|
// the wrapper's `hass` prop (what `getHASS()` reads) has propagated via
|
||||||
|
// Lit, so re-reading it would still see the pre-change state and miss the
|
||||||
|
// transition.
|
||||||
|
this._evaluate(difference.newState.state);
|
||||||
|
|
||||||
|
private _check(): void {
|
||||||
|
const stateObj = this._watchedEntity
|
||||||
|
? this._config.getHASS()?.states[this._watchedEntity]
|
||||||
|
: undefined;
|
||||||
|
this._evaluate(stateObj?.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _evaluate(state?: string): void {
|
||||||
|
if (state === 'unavailable') {
|
||||||
|
if (this._config.isAlwaysError()) {
|
||||||
|
// No grace time, and authoritative (overrides direct frame evidence):
|
||||||
|
// the user opted into treating any unavailability as an error.
|
||||||
|
this._setVerdict({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'hard',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'entity_unavailable',
|
||||||
|
});
|
||||||
|
} else if (this._verdict.state !== 'not_live' && !this._timer.isRunning()) {
|
||||||
|
// Wait out the grace window before declaring the stream lost, so short
|
||||||
|
// blips are tolerated. This is only an indirect signal of stream health,
|
||||||
|
// so it is suppressed when the media itself is confirmed live (frames
|
||||||
|
// arriving).
|
||||||
|
this._timer.start(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS, () =>
|
||||||
|
this._setVerdict({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'indirect',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'entity_unavailable',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Entity available is not positive proof the stream is live (it can be
|
||||||
|
// available while the stream is frozen), so report `unknown`, not `live`.
|
||||||
|
this._timer.stop();
|
||||||
|
this._setVerdict({ state: 'unknown' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setVerdict(verdict: LivenessVerdict): void {
|
||||||
|
if (isEqual(this._verdict, verdict)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._verdict = verdict;
|
||||||
|
this._config.onChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
MediaLoadedInfoEventDetail,
|
||||||
|
MediaPlayerController,
|
||||||
|
UnsubscribeCallback,
|
||||||
|
} from '../../../../types';
|
||||||
|
import { onAbort } from '../../../../utils/abort-signal';
|
||||||
|
import { VisibilityObserver } from '../../../visibility-observer';
|
||||||
|
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
|
||||||
|
|
||||||
|
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects a silent freeze by observing the media player's own liveness signal
|
||||||
|
* (`subscribeLiveness`): when the player reports it has stopped delivering
|
||||||
|
* media, this reports the stream not live so the wrapper shows the reconnecting
|
||||||
|
* placeholder. How a player detects that (e.g. a video watching frame progress)
|
||||||
|
* is the player's concern.
|
||||||
|
*
|
||||||
|
* It only watches while a stall is actionable -- the stream is loaded AND this
|
||||||
|
* provider is actually visible to the user (on-screen and the tab is focused).
|
||||||
|
* An off-screen or backgrounded video legitimately stops presenting media (the
|
||||||
|
* browser pauses `requestVideoFrameCallback`), which is not considered a real
|
||||||
|
* freeze. Visibility comes from the shared `VisibilityObserver` (intersection +
|
||||||
|
* tab visibility), which correctly tracks every visible provider in a grid, not
|
||||||
|
* just the single selected camera.
|
||||||
|
*
|
||||||
|
* Recovery is not self-driven. Once a freeze is reported and the placeholder
|
||||||
|
* unmounts the frozen stream, the media player is gone -- so the detector holds
|
||||||
|
* its not-live verdict rather than flipping back to live, which would clear the
|
||||||
|
* placeholder and remount immediately in an unthrottled loop. The throttled
|
||||||
|
* media_unavailable issue (see issue-manager.ts) retry remounts a fresh provider (a
|
||||||
|
* new detector) to re-check the stream instead.
|
||||||
|
*/
|
||||||
|
export class MediaPlayerLivenessDetector implements LivenessDetector {
|
||||||
|
private _host: HTMLElement;
|
||||||
|
private _onChange: () => void;
|
||||||
|
|
||||||
|
private _verdict: LivenessVerdict = { state: 'unknown' };
|
||||||
|
|
||||||
|
private _visibilityObserver: VisibilityObserver | null = null;
|
||||||
|
private _visible = false;
|
||||||
|
|
||||||
|
private _mediaPlayer: MediaPlayerController | null = null;
|
||||||
|
|
||||||
|
private _watchedPlayer: MediaPlayerController | null = null;
|
||||||
|
private _unsubscribeLiveness: UnsubscribeCallback | null = null;
|
||||||
|
|
||||||
|
constructor(host: HTMLElement, onChange: () => void) {
|
||||||
|
this._host = host;
|
||||||
|
this._onChange = onChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe(): void {
|
||||||
|
this._host.addEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
|
||||||
|
this._visibilityObserver = new VisibilityObserver(this._onVisibleChange, {
|
||||||
|
emitInitial: true,
|
||||||
|
});
|
||||||
|
this._visibilityObserver.setRoot(this._host);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsubscribe(): void {
|
||||||
|
// Retain the verdict so a reconnect resumes where it left off; use reset()
|
||||||
|
// to discard it.
|
||||||
|
this._host.removeEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
|
||||||
|
this._visibilityObserver?.destroy();
|
||||||
|
this._visibilityObserver = null;
|
||||||
|
this._unwatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
// The underlying stream changed (e.g. a substream switch): discard the
|
||||||
|
// verdict and re-evaluate against the new media.
|
||||||
|
this._unwatch();
|
||||||
|
this._setVerdict({ state: 'unknown' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public getVerdict(): LivenessVerdict {
|
||||||
|
return this._verdict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onVisibleChange = (visible: boolean): void => {
|
||||||
|
this._visible = visible;
|
||||||
|
this._watch();
|
||||||
|
};
|
||||||
|
|
||||||
|
private _onMediaLoaded = (ev: CustomEvent<MediaLoadedInfoEventDetail>): void => {
|
||||||
|
const player = ev.detail.info.mediaPlayerController ?? null;
|
||||||
|
this._mediaPlayer = player;
|
||||||
|
|
||||||
|
// Drop the player when the source retires this media (unmount), so a stale
|
||||||
|
// player is never watched.
|
||||||
|
onAbort(ev.detail.signal, () => {
|
||||||
|
if (this._mediaPlayer === player) {
|
||||||
|
this._mediaPlayer = null;
|
||||||
|
this._watch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._watch();
|
||||||
|
};
|
||||||
|
|
||||||
|
private _watch(): void {
|
||||||
|
// Only a visible player that exposes the liveness capability carries an
|
||||||
|
// actionable stall.
|
||||||
|
const player = this._visible ? this._mediaPlayer : null;
|
||||||
|
const target = player?.subscribeLiveness ? player : null;
|
||||||
|
if (target === this._watchedPlayer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._unwatch();
|
||||||
|
this._watchedPlayer = target;
|
||||||
|
|
||||||
|
if (player?.subscribeLiveness) {
|
||||||
|
// Start (or resume) watching; the verdict stays `unknown` until a real
|
||||||
|
// frame or a stall is observed.
|
||||||
|
this._unsubscribeLiveness = player.subscribeLiveness((isLive) =>
|
||||||
|
this._onLiveness(isLive),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The media is gone because our own not-live placeholder unmounted the
|
||||||
|
// frozen stream: hold that verdict. Flipping back to live here would clear
|
||||||
|
// the placeholder and remount into the same freeze; recovery is instead the
|
||||||
|
// throttled media_unavailable retry, which replaces this whole provider.
|
||||||
|
if (!this._mediaPlayer && this._verdict.state === 'not_live') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise there is no current evidence: the provider is off-screen (an
|
||||||
|
// off-screen video legitimately stops presenting frames) or exposes no
|
||||||
|
// liveness signal, or the media went away for an ordinary reason. Report
|
||||||
|
// `unknown` rather than leaving a stale `live` that would suppress other
|
||||||
|
// detectors (e.g. entity availability).
|
||||||
|
this._setVerdict({ state: 'unknown' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onLiveness(isLive: boolean): void {
|
||||||
|
this._setVerdict(
|
||||||
|
isLive
|
||||||
|
? { state: 'live', authority: 'direct' }
|
||||||
|
: {
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'direct',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'stalled',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _unwatch(): void {
|
||||||
|
this._unsubscribeLiveness?.();
|
||||||
|
this._unsubscribeLiveness = null;
|
||||||
|
this._watchedPlayer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setVerdict(verdict: LivenessVerdict): void {
|
||||||
|
if (isEqual(this._verdict, verdict)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._verdict = verdict;
|
||||||
|
this._onChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { LivenessDetector, LivenessVerdict } from '../stream-liveness-controller';
|
||||||
|
|
||||||
|
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects a provider-reported stream failure: a live provider (or one of its
|
||||||
|
* inner players) dispatches `live:error` when it cannot play. The listener sits
|
||||||
|
* on the wrapper host and catches errors bubbling up from any descendant
|
||||||
|
* provider.
|
||||||
|
*
|
||||||
|
* The provider is expected to render its own error, so no reconnecting
|
||||||
|
* placeholder is asked for; the wrapper leaves the provider mounted (and
|
||||||
|
* suppresses the load image).
|
||||||
|
*/
|
||||||
|
export class ProviderErrorDetector implements LivenessDetector {
|
||||||
|
private _host: HTMLElement;
|
||||||
|
private _onChange: () => void;
|
||||||
|
private _verdict: LivenessVerdict = { state: 'unknown' };
|
||||||
|
|
||||||
|
constructor(host: HTMLElement, onChange: () => void) {
|
||||||
|
this._host = host;
|
||||||
|
this._onChange = onChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe(): void {
|
||||||
|
this._host.addEventListener(LIVE_ERROR_EVENT, this._handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsubscribe(): void {
|
||||||
|
this._host.removeEventListener(LIVE_ERROR_EVENT, this._handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
|
this._verdict = { state: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getVerdict(): LivenessVerdict {
|
||||||
|
return this._verdict;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handler = (ev: Event): void => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (this._verdict.state !== 'not_live') {
|
||||||
|
// Authoritative: an explicit provider error overrides even direct frame
|
||||||
|
// evidence. No placeholder -- the provider renders its own error.
|
||||||
|
this._verdict = { state: 'not_live', authority: 'hard', reason: 'playback_error' };
|
||||||
|
this._onChange();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
||||||
|
|
||||||
|
import type { Camera } from '../../../camera-manager/camera';
|
||||||
|
import type { StateWatcherSubscriptionInterface } from '../../../card-controller/hass/state-watcher';
|
||||||
|
import type { MediaUnavailableIssueReason } from '../../../card-controller/issues/issues/media-unavailable';
|
||||||
|
import type { IssueTriggerEventData } from '../../../card-controller/issues/types';
|
||||||
|
import type { CameraConfig } from '../../../config/schema/cameras';
|
||||||
|
import type { HomeAssistant } from '../../../ha/types';
|
||||||
|
import { fireAdvancedCameraCardEvent } from '../../../utils/fire-advanced-camera-card-event';
|
||||||
|
import { EntityAvailabilityDetector } from './detectors/entity-availability';
|
||||||
|
import { MediaPlayerLivenessDetector } from './detectors/media-player-liveness';
|
||||||
|
import { ProviderErrorDetector } from './detectors/provider-error';
|
||||||
|
|
||||||
|
// How far a verdict's evidence is trusted, so direct observation of the media
|
||||||
|
// outweighs an indirect signal:
|
||||||
|
// - `direct`: observed from the media itself (e.g. frames arriving or
|
||||||
|
// stalling).
|
||||||
|
// - `indirect`: inferred from a correlated signal (e.g. the camera entity's
|
||||||
|
// state).
|
||||||
|
// - `hard`: an authoritative failure (e.g. a provider error, or the user's
|
||||||
|
// always_error opt-in) that overrides even direct evidence of life.
|
||||||
|
type LivenessAuthority = 'hard' | 'direct' | 'indirect';
|
||||||
|
|
||||||
|
export type LivenessVerdict =
|
||||||
|
// No evidence: the detector is not observing, so it neither confirms nor
|
||||||
|
// denies liveness. A silent detector must never masquerade as proof of life.
|
||||||
|
| { state: 'unknown' }
|
||||||
|
|
||||||
|
// Media is confirmed to be flowing.
|
||||||
|
| { state: 'live'; authority: LivenessAuthority }
|
||||||
|
|
||||||
|
// Media is confirmed not to be flowing.
|
||||||
|
| {
|
||||||
|
state: 'not_live';
|
||||||
|
authority: LivenessAuthority;
|
||||||
|
reason: MediaUnavailableIssueReason;
|
||||||
|
|
||||||
|
// Whether the wrapper should replace the provider with a reconnecting
|
||||||
|
// placeholder (a silent freeze, e.g. an unavailable camera). Omitted when
|
||||||
|
// the provider renders its own error and should stay mounted.
|
||||||
|
renderPlaceholder?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The reconnecting placeholder the wrapper renders in place of a frozen
|
||||||
|
// provider, carrying the cause so it can show a cause-specific message.
|
||||||
|
interface LivenessPlaceholder {
|
||||||
|
reason: MediaUnavailableIssueReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LivenessDetector {
|
||||||
|
// Start observing the signal.
|
||||||
|
subscribe(): void;
|
||||||
|
|
||||||
|
// Stop observing (e.g. on disconnect). Accumulated state is retained so a
|
||||||
|
// later reconnect resumes where it left off; use reset() to discard it.
|
||||||
|
unsubscribe(): void;
|
||||||
|
|
||||||
|
// Discard accumulated state because the underlying stream changed (e.g. a
|
||||||
|
// substream switch), so detection restarts from scratch.
|
||||||
|
reset?(): void;
|
||||||
|
|
||||||
|
// Reports the stream's current liveness, calling `onChange` (passed at
|
||||||
|
// construction) whenever that verdict changes.
|
||||||
|
getVerdict(): LivenessVerdict;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreamLivenessControllerConfig {
|
||||||
|
getTargetID: () => string | null;
|
||||||
|
getHASS: () => HomeAssistant | null;
|
||||||
|
getCamera: () => Camera | null;
|
||||||
|
getStateWatcher: () => StateWatcherSubscriptionInterface | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinates liveness detection for a single live provider and surfaces a
|
||||||
|
* `media_unavailable` "issue" when the underlying stream stops delivering media. The
|
||||||
|
* issue framework owns the throttled reload that recovers the stream.
|
||||||
|
*/
|
||||||
|
export class StreamLivenessController implements ReactiveController {
|
||||||
|
private _host: ReactiveControllerHost & HTMLElement;
|
||||||
|
private _config: StreamLivenessControllerConfig;
|
||||||
|
private _detectors: LivenessDetector[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
host: ReactiveControllerHost & HTMLElement,
|
||||||
|
config: StreamLivenessControllerConfig,
|
||||||
|
) {
|
||||||
|
this._host = host;
|
||||||
|
this._config = config;
|
||||||
|
|
||||||
|
const onChange = (): void => this._onDetectorChange();
|
||||||
|
const getCameraConfig = (): CameraConfig | null =>
|
||||||
|
config.getCamera()?.getConfig() ?? null;
|
||||||
|
|
||||||
|
this._detectors = [
|
||||||
|
new ProviderErrorDetector(host, onChange),
|
||||||
|
new EntityAvailabilityDetector({
|
||||||
|
getHASS: config.getHASS,
|
||||||
|
getStateWatcher: config.getStateWatcher,
|
||||||
|
getCameraEntity: () => getCameraConfig()?.camera_entity ?? null,
|
||||||
|
isAlwaysError: () =>
|
||||||
|
getCameraConfig()?.always_error_if_entity_unavailable ?? false,
|
||||||
|
onChange,
|
||||||
|
}),
|
||||||
|
new MediaPlayerLivenessDetector(host, onChange),
|
||||||
|
];
|
||||||
|
this._host.addController(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostConnected(): void {
|
||||||
|
this._detectors.forEach((detector) => detector.subscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
public hostDisconnected(): void {
|
||||||
|
this._detectors.forEach((detector) => detector.unsubscribe());
|
||||||
|
}
|
||||||
|
|
||||||
|
public isLive(): boolean {
|
||||||
|
return this._getVerdict().state !== 'not_live';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reconnecting placeholder to render in place of the (frozen) provider,
|
||||||
|
// carrying the cause so the wrapper can show a cause-specific message. Null
|
||||||
|
// when the provider should stay mounted (live, or a provider error that
|
||||||
|
// renders its own error).
|
||||||
|
public getPlaceholder(): LivenessPlaceholder | null {
|
||||||
|
const verdict = this._getVerdict();
|
||||||
|
return verdict.state === 'not_live' && verdict.renderPlaceholder
|
||||||
|
? { reason: verdict.reason }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard detector state on a stream change (e.g. a stream switch).
|
||||||
|
public reset(): void {
|
||||||
|
this._detectors.forEach((detector) => detector.reset?.());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reduce the detectors to a single verdict. Direct evidence from the media
|
||||||
|
// itself (e.g. lack of frame stalls) outranks indirect signals (e.g.
|
||||||
|
// entity-availability), so a stream that is demonstrably delivering frames is
|
||||||
|
// never torn down just because its camera entity blipped unavailable. `hard`
|
||||||
|
// failures (a provider error, or the always_error opt-in) outrank everything.
|
||||||
|
private _getVerdict(): LivenessVerdict {
|
||||||
|
const verdicts = this._detectors.map((detector) => detector.getVerdict());
|
||||||
|
const find = (
|
||||||
|
state: 'live' | 'not_live',
|
||||||
|
authority: LivenessAuthority,
|
||||||
|
): LivenessVerdict | null =>
|
||||||
|
verdicts.find(
|
||||||
|
(v) => 'authority' in v && v.state === state && v.authority === authority,
|
||||||
|
) ?? null;
|
||||||
|
|
||||||
|
// `unknown` verdicts carry no authority, so they match none of these lookups
|
||||||
|
// and are skipped; if every detector is silent the reduction is `unknown`.
|
||||||
|
return (
|
||||||
|
find('not_live', 'hard') ??
|
||||||
|
find('not_live', 'direct') ??
|
||||||
|
find('live', 'direct') ??
|
||||||
|
find('not_live', 'indirect') ?? { state: 'unknown' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onDetectorChange(): void {
|
||||||
|
const verdict = this._getVerdict();
|
||||||
|
if (verdict.state === 'not_live') {
|
||||||
|
this._triggerMediaUnavailableIssue(verdict.reason);
|
||||||
|
}
|
||||||
|
this._host.requestUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the issue framework this target's media is not loaded, surfacing the
|
||||||
|
// media_unavailable issue (status bar + retry) and its throttled reload.
|
||||||
|
private _triggerMediaUnavailableIssue(reason: MediaUnavailableIssueReason): void {
|
||||||
|
const targetID = this._config.getTargetID();
|
||||||
|
if (!targetID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this._host, 'issue:trigger', {
|
||||||
|
key: 'media_unavailable',
|
||||||
|
targetID,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,7 +84,8 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
|
|||||||
// flipped while we were disconnected. Replaying the cached info under a
|
// flipped while we were disconnected. Replaying the cached info under a
|
||||||
// stale targetID would misregister with the manager.
|
// stale targetID would misregister with the manager.
|
||||||
if (this._lastSet.targetID === this._config.getTargetID()) {
|
if (this._lastSet.targetID === this._config.getTargetID()) {
|
||||||
this._dispatchLoad(this._lastSet);
|
// A reconnect replay of the last load, not a fresh media load.
|
||||||
|
this._dispatchLoad(this._lastSet, true);
|
||||||
} else {
|
} else {
|
||||||
this._lastSet = null;
|
this._lastSet = null;
|
||||||
}
|
}
|
||||||
@@ -135,10 +136,11 @@ export class MediaLoadedInfoSourceController implements ReactiveController {
|
|||||||
this._abort = null;
|
this._abort = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _dispatchLoad(info: TargetedMediaLoadedInfo): void {
|
private _dispatchLoad(info: TargetedMediaLoadedInfo, cached?: boolean): void {
|
||||||
this._abort = new AbortController();
|
this._abort = new AbortController();
|
||||||
fireAdvancedCameraCardEvent<MediaLoadedInfoEventDetail>(this._host, 'media:loaded', {
|
fireAdvancedCameraCardEvent<MediaLoadedInfoEventDetail>(this._host, 'media:loaded', {
|
||||||
info,
|
info,
|
||||||
|
cached,
|
||||||
signal: this._abort.signal,
|
signal: this._abort.signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { LivenessCallback, UnsubscribeCallback } from '../../types';
|
||||||
|
import { Timer } from '../../utils/timer';
|
||||||
|
|
||||||
|
// A stream that presents no new frame for this long while playback is expected
|
||||||
|
// is treated as not live -- a silent freeze, or a source stuck without a frame
|
||||||
|
// (buffering / reconnecting). Long enough that a low-frame-rate stream still
|
||||||
|
// presents a frame within it, and that a brief in-place source swap / reconnect
|
||||||
|
// resumes before it fires.
|
||||||
|
export const FRAME_STALL_SECONDS = 10;
|
||||||
|
|
||||||
|
export interface FrameStallWatchdogConfig {
|
||||||
|
// Whether the source is expected to be presenting frames right now. Returns
|
||||||
|
// false while it is legitimately idle (paused / seeking / ended), so an idle
|
||||||
|
// stream is never reported as not live.
|
||||||
|
isPlaybackExpected: () => boolean;
|
||||||
|
|
||||||
|
// Begin receiving frames (the caller wires its frame source to
|
||||||
|
// `notifyFrame`). Returns false when no source is available -- then the stall
|
||||||
|
// timer is never armed, so no stall is ever reported (e.g. video missing
|
||||||
|
// `requestVideoFrameCallback`). Defaults to an always-available source.
|
||||||
|
startSource?: () => boolean;
|
||||||
|
|
||||||
|
// Stop receiving frames. Defaults to a no-op, for a source that needs no
|
||||||
|
// teardown.
|
||||||
|
stopSource?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects a silent freeze in a frame-based stream: each presented frame kicks a
|
||||||
|
* single timer (via `notifyFrame`); if the timer ever fires, no frame arrived
|
||||||
|
* within the window and the stream is reported stalled. The next frame reports
|
||||||
|
* it live again.
|
||||||
|
*
|
||||||
|
* Source-agnostic: the caller feeds frames from whatever it has (a video's
|
||||||
|
* `requestVideoFrameCallback`, a jsmpeg decode callback, ...) and supplies the
|
||||||
|
* `isPlaybackExpected` predicate. Multiple subscribers share one watchdog,
|
||||||
|
* refcounted -- the source starts on the first subscriber and stops on the
|
||||||
|
* last.
|
||||||
|
*/
|
||||||
|
export class FrameStallWatchdog {
|
||||||
|
private _config: FrameStallWatchdogConfig;
|
||||||
|
|
||||||
|
private _timer = new Timer();
|
||||||
|
private _callbacks = new Set<LivenessCallback>();
|
||||||
|
|
||||||
|
// `null` means no frame has been observed yet (unconfirmed): a subscriber is
|
||||||
|
// told `true` only once a real frame arrives, so merely being subscribed
|
||||||
|
// never masquerades as confirmed liveness.
|
||||||
|
private _isLive: boolean | null = null;
|
||||||
|
private _sourceActive = false;
|
||||||
|
|
||||||
|
constructor(config: FrameStallWatchdogConfig) {
|
||||||
|
this._config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscribe(callback: LivenessCallback): UnsubscribeCallback {
|
||||||
|
const hadNoSubscribers = this._callbacks.size === 0;
|
||||||
|
this._callbacks.add(callback);
|
||||||
|
if (hadNoSubscribers) {
|
||||||
|
this._start();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (): void => {
|
||||||
|
this._callbacks.delete(callback);
|
||||||
|
if (this._callbacks.size === 0) {
|
||||||
|
this._stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick the watchdog: a frame was presented, so the stream is live and the
|
||||||
|
// stall timer restarts.
|
||||||
|
public notifyFrame(): void {
|
||||||
|
if (!this._sourceActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._timer.start(FRAME_STALL_SECONDS, () => this._onStall());
|
||||||
|
|
||||||
|
// Notify last: if this recovery notification prompts the final subscriber
|
||||||
|
// to unsubscribe, `_stop` then clears the timer just armed instead of
|
||||||
|
// leaving it running with no subscribers.
|
||||||
|
this._setLive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _start(): void {
|
||||||
|
this._isLive = null;
|
||||||
|
this._sourceActive = this._config.startSource?.() ?? true;
|
||||||
|
|
||||||
|
// Arm now, not only on the first frame, so a stream already frozen when
|
||||||
|
// watching begins is still detected. With no source there is nothing to
|
||||||
|
// arm, so nothing is ever reported.
|
||||||
|
if (this._sourceActive) {
|
||||||
|
this._timer.start(FRAME_STALL_SECONDS, () => this._onStall());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _stop(): void {
|
||||||
|
this._timer.stop();
|
||||||
|
if (this._sourceActive) {
|
||||||
|
this._config.stopSource?.();
|
||||||
|
this._sourceActive = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onStall(): void {
|
||||||
|
if (this._config.isPlaybackExpected()) {
|
||||||
|
// Playback expected but no frame arrived within the window: not live --
|
||||||
|
// a silent freeze, or a source stuck without a frame.
|
||||||
|
this._setLive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legitimately idle (paused / seeking / ended). Re-arm rather than stop: a
|
||||||
|
// source that later resumes already frozen delivers no frame to kick the
|
||||||
|
// timer, so a freeze that only becomes actionable later is still caught.
|
||||||
|
this._timer.start(FRAME_STALL_SECONDS, () => this._onStall());
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setLive(isLive: boolean): void {
|
||||||
|
if (this._isLive === isLive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._isLive = isLive;
|
||||||
|
|
||||||
|
// Snapshot so a callback that unsubscribes while handling the change cannot
|
||||||
|
// perturb the iteration.
|
||||||
|
[...this._callbacks].forEach((callback) => callback(isLive));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,28 @@
|
|||||||
import type JSMpeg from '@cycjimmy/jsmpeg-player';
|
import type JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||||
import type { LitElement } from 'lit';
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
import type { FullscreenElement, MediaPlayerController, PIPElement } from '../../types';
|
import type {
|
||||||
|
FullscreenElement,
|
||||||
|
LivenessCallback,
|
||||||
|
MediaPlayerController,
|
||||||
|
PIPElement,
|
||||||
|
UnsubscribeCallback,
|
||||||
|
} from '../../types';
|
||||||
|
import { FrameStallWatchdog } from './frame-stall-watchdog';
|
||||||
|
|
||||||
export class JSMPEGMediaPlayerController implements MediaPlayerController {
|
export class JSMPEGMediaPlayerController implements MediaPlayerController {
|
||||||
private _host: LitElement;
|
private _host: LitElement;
|
||||||
private _getJSMPEGVideoElementCallback: () => JSMpeg.VideoElement | null;
|
private _getJSMPEGVideoElementCallback: () => JSMpeg.VideoElement | null;
|
||||||
private _getCanvasElementCallback: () => HTMLCanvasElement | null;
|
private _getCanvasElementCallback: () => HTMLCanvasElement | null;
|
||||||
|
|
||||||
|
// Frames are fed by the provider via `notifyFrameDecoded` (JSMpeg's
|
||||||
|
// per-decode callback), so the source needs no explicit start/stop -- the
|
||||||
|
// watchdog's always-available defaults apply. Playback is expected whenever
|
||||||
|
// not paused; a decode gap for the frame-stall window is then a stall.
|
||||||
|
private _stallWatchdog = new FrameStallWatchdog({
|
||||||
|
isPlaybackExpected: () => !this.isPaused(),
|
||||||
|
});
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
host: LitElement,
|
host: LitElement,
|
||||||
_getJSMPEGVideoElementCallback: () => JSMpeg.VideoElement | null,
|
_getJSMPEGVideoElementCallback: () => JSMpeg.VideoElement | null,
|
||||||
@@ -18,6 +33,15 @@ export class JSMPEGMediaPlayerController implements MediaPlayerController {
|
|||||||
this._getCanvasElementCallback = _getCanvasElementCallback;
|
this._getCanvasElementCallback = _getCanvasElementCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public subscribeLiveness(callback: LivenessCallback): UnsubscribeCallback {
|
||||||
|
return this._stallWatchdog.subscribe(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by the provider on every decoded frame (JSMpeg's `onVideoDecode`).
|
||||||
|
public notifyFrameDecoded(): void {
|
||||||
|
this._stallWatchdog.notifyFrame();
|
||||||
|
}
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
public async play(): Promise<void> {
|
||||||
await this._host.updateComplete;
|
await this._host.updateComplete;
|
||||||
return this._getJSMPEGVideoElementCallback()?.play();
|
return this._getJSMPEGVideoElementCallback()?.play();
|
||||||
|
|||||||
@@ -1,14 +1,39 @@
|
|||||||
import type { LitElement } from 'lit';
|
import type { LitElement } from 'lit';
|
||||||
|
|
||||||
import type { FullscreenElement, MediaPlayerController, PIPElement } from '../../types';
|
import type {
|
||||||
|
FullscreenElement,
|
||||||
|
LivenessCallback,
|
||||||
|
MediaPlayerController,
|
||||||
|
PIPElement,
|
||||||
|
UnsubscribeCallback,
|
||||||
|
} from '../../types';
|
||||||
import { hideMediaControlsTemporarily, setControlsOnVideo } from '../../utils/controls';
|
import { hideMediaControlsTemporarily, setControlsOnVideo } from '../../utils/controls';
|
||||||
import { screenshotVideo } from '../../utils/screenshot';
|
import { screenshotVideo } from '../../utils/screenshot';
|
||||||
|
import { FrameStallWatchdog } from './frame-stall-watchdog';
|
||||||
|
|
||||||
export class VideoMediaPlayerController implements MediaPlayerController {
|
export class VideoMediaPlayerController implements MediaPlayerController {
|
||||||
private _host: LitElement;
|
private _host: LitElement;
|
||||||
private _getVideoCallback: () => HTMLVideoElement | null;
|
private _getVideoCallback: () => HTMLVideoElement | null;
|
||||||
private _getControlsDefaultCallback: (() => boolean) | null;
|
private _getControlsDefaultCallback: (() => boolean) | null;
|
||||||
|
|
||||||
|
private _rvfcHandle: number | null = null;
|
||||||
|
private _stallWatchdog = new FrameStallWatchdog({
|
||||||
|
// Playback is expected unless the video is legitimately idle. Seeking /
|
||||||
|
// ended is idle. A paused video is idle only if it holds a current frame --
|
||||||
|
// a genuine user pause; paused with no current frame is not a real pause but
|
||||||
|
// a source mid-reconnect or buffering (nothing to pause on), so playback is
|
||||||
|
// still expected and a missing frame is a stall.
|
||||||
|
isPlaybackExpected: () => {
|
||||||
|
const video = this._getVideoCallback();
|
||||||
|
if (!video || video.seeking || video.ended) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !video.paused || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA;
|
||||||
|
},
|
||||||
|
startSource: () => this._startFrameSource(),
|
||||||
|
stopSource: () => this._stopFrameSource(),
|
||||||
|
});
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
host: LitElement,
|
host: LitElement,
|
||||||
getVideoCallback: () => HTMLVideoElement | null,
|
getVideoCallback: () => HTMLVideoElement | null,
|
||||||
@@ -112,4 +137,43 @@ export class VideoMediaPlayerController implements MediaPlayerController {
|
|||||||
public getPIPElement(): PIPElement | null {
|
public getPIPElement(): PIPElement | null {
|
||||||
return this._getVideoCallback() ?? null;
|
return this._getVideoCallback() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public subscribeLiveness(callback: LivenessCallback): UnsubscribeCallback {
|
||||||
|
return this._stallWatchdog.subscribe(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The frame source is `requestVideoFrameCallback`, which is in every current
|
||||||
|
// browser. On the rare one without it no frame is observed, so the watchdog is
|
||||||
|
// told there is no source and reports no stall (liveness falls to other
|
||||||
|
// detectors).
|
||||||
|
private _startFrameSource(): boolean {
|
||||||
|
const video = this._getVideoCallback();
|
||||||
|
if (!video || !('requestVideoFrameCallback' in video)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this._rvfcHandle = video.requestVideoFrameCallback(this._onVideoFrame);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _stopFrameSource(): void {
|
||||||
|
const video = this._getVideoCallback();
|
||||||
|
if (video && this._rvfcHandle !== null) {
|
||||||
|
video.cancelVideoFrameCallback(this._rvfcHandle);
|
||||||
|
}
|
||||||
|
this._rvfcHandle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onVideoFrame = (): void => {
|
||||||
|
this._stallWatchdog.notifyFrame();
|
||||||
|
|
||||||
|
// Re-arm only if still watching: `notifyFrame`'s live notification may have
|
||||||
|
// dropped the last subscriber, which stops the source and nulls the handle.
|
||||||
|
if (this._rvfcHandle === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const video = this._getVideoCallback();
|
||||||
|
if (video) {
|
||||||
|
this._rvfcHandle = video.requestVideoFrameCallback(this._onVideoFrame);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
type VisibilityChangeHandler = (visible: boolean) => Promise<void> | void;
|
type VisibilityChangeHandler = (visible: boolean) => Promise<void> | void;
|
||||||
|
|
||||||
|
interface VisibilityObserverOptions {
|
||||||
|
// Also emit the first known visibility value, not only subsequent
|
||||||
|
// transitions. Consumers that need the current state on startup (rather than
|
||||||
|
// reacting only to changes) opt in; the default stays transition-only.
|
||||||
|
emitInitial?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Observes a root element's visibility, emitting a single boolean whenever it
|
* Observes a root element's visibility, emitting a single boolean whenever it
|
||||||
* transitions. The element is "visible" only when both:
|
* transitions. The element is "visible" only when both:
|
||||||
@@ -25,9 +32,11 @@ export class VisibilityObserver {
|
|||||||
this._handleIntersection.bind(this),
|
this._handleIntersection.bind(this),
|
||||||
);
|
);
|
||||||
private _onChange: VisibilityChangeHandler;
|
private _onChange: VisibilityChangeHandler;
|
||||||
|
private _emitInitial: boolean;
|
||||||
|
|
||||||
constructor(onChange: VisibilityChangeHandler) {
|
constructor(onChange: VisibilityChangeHandler, options?: VisibilityObserverOptions) {
|
||||||
this._onChange = onChange;
|
this._onChange = onChange;
|
||||||
|
this._emitInitial = options?.emitInitial ?? false;
|
||||||
document.addEventListener('visibilitychange', this._handleVisibility);
|
document.addEventListener('visibilitychange', this._handleVisibility);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,8 +85,11 @@ export class VisibilityObserver {
|
|||||||
|
|
||||||
if (this._lastEmitted === null) {
|
if (this._lastEmitted === null) {
|
||||||
// First time we have an intersection value: this is the baseline. Record
|
// First time we have an intersection value: this is the baseline. Record
|
||||||
// and return without emitting.
|
// it, and emit only if the consumer opted into the initial value.
|
||||||
this._lastEmitted = visible;
|
this._lastEmitted = visible;
|
||||||
|
if (this._emitInitial) {
|
||||||
|
await this._onChange(visible);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (visible === this._lastEmitted) {
|
if (visible === this._lastEmitted) {
|
||||||
|
|||||||
@@ -474,8 +474,9 @@ export class AdvancedCameraCardImageUpdatingPlayer
|
|||||||
this,
|
this,
|
||||||
'issue:trigger',
|
'issue:trigger',
|
||||||
{
|
{
|
||||||
key: 'media_load',
|
key: 'media_unavailable',
|
||||||
targetID: this.targetID,
|
targetID: this.targetID,
|
||||||
|
reason: 'not_loading',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
|||||||
import type { CameraManager } from '../../camera-manager/manager.js';
|
import type { CameraManager } from '../../camera-manager/manager.js';
|
||||||
import type { CameraManagerCameraMetadata } from '../../camera-manager/types.js';
|
import type { CameraManagerCameraMetadata } from '../../camera-manager/types.js';
|
||||||
import type { CallSession } from '../../card-controller/call/types.js';
|
import type { CallSession } from '../../card-controller/call/types.js';
|
||||||
|
import type { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher.js';
|
||||||
import type { MicrophoneState } from '../../card-controller/types.js';
|
import type { MicrophoneState } from '../../card-controller/types.js';
|
||||||
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
import { resolveAutoHideState } from '../../components-lib/auto-hide.js';
|
import { resolveAutoHideState } from '../../components-lib/auto-hide.js';
|
||||||
@@ -60,6 +61,9 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public stateWatcher?: StateWatcherSubscriptionInterface;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public viewManagerEpoch?: ViewManagerEpoch;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@@ -274,6 +278,7 @@ export class AdvancedCameraCardLiveCarousel extends LitElement {
|
|||||||
.label=${cameraMetadata?.title ?? ''}
|
.label=${cameraMetadata?.title ?? ''}
|
||||||
.liveConfig=${this.liveConfig}
|
.liveConfig=${this.liveConfig}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
|
.stateWatcher=${this.stateWatcher}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
|
.zoomSettings=${view?.context?.zoom?.[cameraID]?.requested}
|
||||||
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
|
.zoom=${!this._isGesturesPTZActive(view, cameraID)}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ifDefined } from 'lit/directives/if-defined.js';
|
|||||||
|
|
||||||
import type { CameraManager } from '../../camera-manager/manager.js';
|
import type { CameraManager } from '../../camera-manager/manager.js';
|
||||||
import type { CallSession } from '../../card-controller/call/types.js';
|
import type { CallSession } from '../../card-controller/call/types.js';
|
||||||
|
import type { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher.js';
|
||||||
import type { MicrophoneState } from '../../card-controller/types.js';
|
import type { MicrophoneState } from '../../card-controller/types.js';
|
||||||
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
import type { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
|
import type { MediaGridSelected } from '../../components-lib/media-grid-controller.js';
|
||||||
@@ -27,6 +28,9 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public stateWatcher?: StateWatcherSubscriptionInterface;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public viewManagerEpoch?: ViewManagerEpoch;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ export class AdvancedCameraCardLiveGrid extends LitElement {
|
|||||||
grid-id=${ifDefined(cameraID)}
|
grid-id=${ifDefined(cameraID)}
|
||||||
grid-width-factor=${ifDefined(gridWidthFactor)}
|
grid-width-factor=${ifDefined(gridWidthFactor)}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
|
.stateWatcher=${this.stateWatcher}
|
||||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.viewFilterCameraID=${cameraID}
|
.viewFilterCameraID=${cameraID}
|
||||||
.liveConfig=${this.liveConfig}
|
.liveConfig=${this.liveConfig}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { customElement, property } from 'lit/decorators.js';
|
|||||||
|
|
||||||
import type { CameraManager } from '../../camera-manager/manager.js';
|
import type { CameraManager } from '../../camera-manager/manager.js';
|
||||||
import type { CallSession } from '../../card-controller/call/types.js';
|
import type { CallSession } from '../../card-controller/call/types.js';
|
||||||
|
import type { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher.js';
|
||||||
import type { MicrophoneManager } from '../../card-controller/microphone-manager.js';
|
import type { MicrophoneManager } from '../../card-controller/microphone-manager.js';
|
||||||
import type { MicrophoneState } from '../../card-controller/types.js';
|
import type { MicrophoneState } from '../../card-controller/types.js';
|
||||||
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
import type { ViewManagerEpoch } from '../../card-controller/view/types.js';
|
||||||
@@ -30,6 +31,9 @@ export class AdvancedCameraCardLive extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public stateWatcher?: StateWatcherSubscriptionInterface;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public viewManagerEpoch?: ViewManagerEpoch;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@@ -106,6 +110,7 @@ export class AdvancedCameraCardLive extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
<advanced-camera-card-live-grid
|
<advanced-camera-card-live-grid
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
|
.stateWatcher=${this.stateWatcher}
|
||||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.liveConfig=${this.liveConfig}
|
.liveConfig=${this.liveConfig}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import { guard } from 'lit/directives/guard.js';
|
|||||||
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
import { createRef, ref, type Ref } from 'lit/directives/ref.js';
|
||||||
|
|
||||||
import type { Camera } from '../../camera-manager/camera.js';
|
import type { Camera } from '../../camera-manager/camera.js';
|
||||||
|
import type { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher.js';
|
||||||
|
import { MEDIA_UNAVAILABLE_REASONS } from '../../card-controller/issues/issues/media-unavailable.js';
|
||||||
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
import { LazyLoadController } from '../../components-lib/lazy-load-controller.js';
|
||||||
import { isAudioIntendedOnLoad } from '../../components-lib/live/audio-intent.js';
|
import { isAudioIntendedOnLoad } from '../../components-lib/live/audio-intent.js';
|
||||||
import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js';
|
import { StreamLivenessController } from '../../components-lib/live/liveness/stream-liveness-controller.js';
|
||||||
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
|
import { MediaLoadedInfoSinkController } from '../../components-lib/media-loaded-info-sink-controller.js';
|
||||||
import type { PartialZoomSettings } from '../../components-lib/zoom/types.js';
|
import type { PartialZoomSettings } from '../../components-lib/zoom/types.js';
|
||||||
import type { LiveConfig } from '../../config/schema/live.js';
|
import type { LiveConfig } from '../../config/schema/live.js';
|
||||||
@@ -44,6 +46,9 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public camera?: Camera;
|
public camera?: Camera;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public stateWatcher?: StateWatcherSubscriptionInterface;
|
||||||
|
|
||||||
// The BASE camera ID (camera property may be a substream)
|
// The BASE camera ID (camera property may be a substream)
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public targetID?: string;
|
public targetID?: string;
|
||||||
@@ -84,18 +89,16 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
getTargetID: () => this.targetID ?? null,
|
getTargetID: () => this.targetID ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private _streamLivenessController = new StreamLivenessController(this, {
|
||||||
|
getTargetID: () => this.targetID ?? null,
|
||||||
|
getHASS: () => this.hass ?? null,
|
||||||
|
getCamera: () => this.camera ?? null,
|
||||||
|
getStateWatcher: () => this.stateWatcher ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private _zoomed = false;
|
private _zoomed = false;
|
||||||
|
|
||||||
@state()
|
|
||||||
private _hasProviderError = false;
|
|
||||||
|
|
||||||
// Whether the camera entity has ever been in a non-unavailable state. Used
|
|
||||||
// to suppress transient unavailability errors for entities that were
|
|
||||||
// previously working (e.g. during PTZ operations).
|
|
||||||
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2124
|
|
||||||
private _entityHasBeenAvailable = false;
|
|
||||||
|
|
||||||
private _refProvider: Ref<MediaPlayerElement> = createRef();
|
private _refProvider: Ref<MediaPlayerElement> = createRef();
|
||||||
|
|
||||||
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
|
private _lazyLoadController: LazyLoadController = new LazyLoadController(this);
|
||||||
@@ -128,27 +131,10 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
!!this.camera?.getConfig()?.camera_entity &&
|
!!this.camera?.getConfig()?.camera_entity &&
|
||||||
!!this.hass &&
|
!!this.hass &&
|
||||||
!!this.liveConfig?.show_image_during_load &&
|
!!this.liveConfig?.show_image_during_load &&
|
||||||
!this._hasProviderError
|
this._streamLivenessController.isLive()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public disconnectedCallback(): void {
|
|
||||||
this._entityHasBeenAvailable = false;
|
|
||||||
super.disconnectedCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _providerErrorHandler(ev: Event): void {
|
|
||||||
ev.stopPropagation();
|
|
||||||
this._hasProviderError = true;
|
|
||||||
|
|
||||||
if (this.targetID) {
|
|
||||||
fireAdvancedCameraCardEvent(this, 'issue:trigger', {
|
|
||||||
key: 'media_load' as const,
|
|
||||||
targetID: this.targetID,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected willUpdate(changedProps: PropertyValues): void {
|
protected willUpdate(changedProps: PropertyValues): void {
|
||||||
if (changedProps.has('liveConfig') || changedProps.has('forceSelected')) {
|
if (changedProps.has('liveConfig') || changedProps.has('forceSelected')) {
|
||||||
this._lazyLoadController.setConfiguration({
|
this._lazyLoadController.setConfiguration({
|
||||||
@@ -168,8 +154,7 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (changedProps.has('camera')) {
|
if (changedProps.has('camera')) {
|
||||||
this._hasProviderError = false;
|
this._streamLivenessController.reset();
|
||||||
this._entityHasBeenAvailable = false;
|
|
||||||
|
|
||||||
const provider = getResolvedLiveProvider(this.camera?.getConfig());
|
const provider = getResolvedLiveProvider(this.camera?.getConfig());
|
||||||
if (provider === 'jsmpeg') {
|
if (provider === 'jsmpeg') {
|
||||||
@@ -248,50 +233,59 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If a card *re*-initializes (e.g. was already initialized and then there's
|
||||||
|
// a use of the editor to change the config), cameras will re-initialize in
|
||||||
|
// place, which means they might be asked to render (here) whilst not yet
|
||||||
|
// being initialized. This can cause spurious errors (e.g. lack of resolved
|
||||||
|
// endpoints). Instead, simply never render uninitialized cameras.
|
||||||
|
if (!this.camera.isInitialized()) {
|
||||||
|
return renderNotificationBlockFromText(
|
||||||
|
`${localize('error.awaiting_live')}${this.label ? `: ${this.label}` : ''}`,
|
||||||
|
{ icon: 'mdi:progress-helper', in_progress: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Set title and ariaLabel from the provided label property.
|
// Set title and ariaLabel from the provided label property.
|
||||||
this.title = this.label;
|
this.title = this.label;
|
||||||
this.ariaLabel = this.label;
|
this.ariaLabel = this.label;
|
||||||
|
|
||||||
const provider = getResolvedLiveProvider(this.camera?.getConfig());
|
const provider = getResolvedLiveProvider(this.camera?.getConfig());
|
||||||
|
|
||||||
|
// `ha`/`image` cannot stream without a camera entity, so validate that
|
||||||
|
// here. Entity *availability* (including the always_error immediate path)
|
||||||
|
// is owned by the liveness controller's EntityAvailabilityDetector and
|
||||||
|
// surfaces via getPlaceholder() below, for all providers.
|
||||||
if (
|
if (
|
||||||
provider === 'ha' ||
|
provider === 'ha' ||
|
||||||
provider === 'image' ||
|
provider === 'image' ||
|
||||||
(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
|
(cameraConfig?.camera_entity && cameraConfig.always_error_if_entity_unavailable)
|
||||||
) {
|
) {
|
||||||
if (!cameraConfig?.camera_entity) {
|
if (!cameraConfig?.camera_entity) {
|
||||||
dispatchLiveErrorEvent(this);
|
|
||||||
return renderNotificationBlockFromText(localize('error.no_live_camera'), {
|
return renderNotificationBlockFromText(localize('error.no_live_camera'), {
|
||||||
icon: 'mdi:camera',
|
icon: 'mdi:camera',
|
||||||
context: cameraConfig,
|
context: cameraConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!this.hass.states[cameraConfig.camera_entity]) {
|
||||||
const stateObj = this.hass.states[cameraConfig.camera_entity];
|
|
||||||
if (!stateObj) {
|
|
||||||
dispatchLiveErrorEvent(this);
|
|
||||||
return renderNotificationBlockFromText(localize('error.live_camera_not_found'), {
|
return renderNotificationBlockFromText(localize('error.live_camera_not_found'), {
|
||||||
icon: 'mdi:camera',
|
icon: 'mdi:camera',
|
||||||
context: cameraConfig,
|
context: cameraConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (stateObj.state === 'unavailable') {
|
// A detector reports the stream is silently lost (the camera entity is
|
||||||
if (
|
// unavailable, or the stream stalled): render a reconnecting placeholder,
|
||||||
!this._entityHasBeenAvailable ||
|
// which unmounts the provider and unloads it via the existing media-loaded
|
||||||
cameraConfig.always_error_if_entity_unavailable
|
// abort. The message names the specific cause.
|
||||||
) {
|
const placeholder = this._streamLivenessController.getPlaceholder();
|
||||||
dispatchLiveErrorEvent(this);
|
if (placeholder) {
|
||||||
return renderNotificationBlockFromText(
|
const { localizationKey: textKey, icon } =
|
||||||
`${localize('error.live_camera_unavailable')}${
|
MEDIA_UNAVAILABLE_REASONS[placeholder.reason];
|
||||||
this.label ? `: ${this.label}` : ''
|
return renderNotificationBlockFromText(
|
||||||
}`,
|
`${localize(textKey)}${this.label ? `: ${this.label}` : ''}`,
|
||||||
{ icon: 'mdi:cctv-off', in_progress: true },
|
{ icon, in_progress: true },
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this._entityHasBeenAvailable = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const showImageDuringLoading = this._shouldShowImageDuringLoading();
|
const showImageDuringLoading = this._shouldShowImageDuringLoading();
|
||||||
@@ -314,8 +308,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
// so it should not be hidden.
|
// so it should not be hidden.
|
||||||
hidden: false,
|
hidden: false,
|
||||||
})}
|
})}
|
||||||
@advanced-camera-card:live:error=${(ev: Event) =>
|
|
||||||
this._providerErrorHandler(ev)}
|
|
||||||
@advanced-camera-card:media:loaded=${(ev: Event) => {
|
@advanced-camera-card:media:loaded=${(ev: Event) => {
|
||||||
// When the image is rendered as a placeholder behind another
|
// When the image is rendered as a placeholder behind another
|
||||||
// provider, suppress its load event so it doesn't reach the
|
// provider, suppress its load event so it doesn't reach the
|
||||||
@@ -339,8 +331,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
.preferAudioStream=${this.forceSelected &&
|
.preferAudioStream=${this.forceSelected &&
|
||||||
isAudioIntendedOnLoad(this.liveConfig?.auto_unmute ?? [])}
|
isAudioIntendedOnLoad(this.liveConfig?.auto_unmute ?? [])}
|
||||||
?controls=${this._getEffectiveBuiltinControls()}
|
?controls=${this._getEffectiveBuiltinControls()}
|
||||||
@advanced-camera-card:live:error=${(ev: Event) =>
|
|
||||||
this._providerErrorHandler(ev)}
|
|
||||||
>
|
>
|
||||||
</advanced-camera-card-live-ha>`
|
</advanced-camera-card-live-ha>`
|
||||||
: provider === 'go2rtc'
|
: provider === 'go2rtc'
|
||||||
@@ -353,8 +343,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
.microphoneStream=${this.microphoneStream}
|
.microphoneStream=${this.microphoneStream}
|
||||||
.microphoneConfig=${this.liveConfig.microphone}
|
.microphoneConfig=${this.liveConfig.microphone}
|
||||||
?controls=${this._getEffectiveBuiltinControls()}
|
?controls=${this._getEffectiveBuiltinControls()}
|
||||||
@advanced-camera-card:live:error=${(ev: Event) =>
|
|
||||||
this._providerErrorHandler(ev)}
|
|
||||||
>
|
>
|
||||||
</advanced-camera-card-live-go2rtc>`
|
</advanced-camera-card-live-go2rtc>`
|
||||||
: provider === 'webrtc-card'
|
: provider === 'webrtc-card'
|
||||||
@@ -366,8 +354,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
.targetID=${this.targetID}
|
.targetID=${this.targetID}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
?controls=${this._getEffectiveBuiltinControls()}
|
?controls=${this._getEffectiveBuiltinControls()}
|
||||||
@advanced-camera-card:live:error=${(ev: Event) =>
|
|
||||||
this._providerErrorHandler(ev)}
|
|
||||||
>
|
>
|
||||||
</advanced-camera-card-live-webrtc-card>`
|
</advanced-camera-card-live-webrtc-card>`
|
||||||
: provider === 'jsmpeg'
|
: provider === 'jsmpeg'
|
||||||
@@ -378,8 +364,6 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
.camera=${this.camera}
|
.camera=${this.camera}
|
||||||
.targetID=${this.targetID}
|
.targetID=${this.targetID}
|
||||||
.cardWideConfig=${this.cardWideConfig}
|
.cardWideConfig=${this.cardWideConfig}
|
||||||
@advanced-camera-card:live:error=${(ev: Event) =>
|
|
||||||
this._providerErrorHandler(ev)}
|
|
||||||
>
|
>
|
||||||
</advanced-camera-card-live-jsmpeg>`
|
</advanced-camera-card-live-jsmpeg>`
|
||||||
: html``}
|
: html``}
|
||||||
@@ -388,7 +372,8 @@ export class AdvancedCameraCardLiveProvider extends LitElement implements MediaP
|
|||||||
? html`<advanced-camera-card-icon
|
? html`<advanced-camera-card-icon
|
||||||
title=${localize('error.awaiting_live')}
|
title=${localize('error.awaiting_live')}
|
||||||
.icon=${{ icon: 'mdi:progress-helper' }}
|
.icon=${{ icon: 'mdi:progress-helper' }}
|
||||||
@click=${() => fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_load')}
|
@click=${() =>
|
||||||
|
fireAdvancedCameraCardEvent(this, 'issue:notify', 'media_unavailable')}
|
||||||
></advanced-camera-card-icon>`
|
></advanced-camera-card-icon>`
|
||||||
: ''}`;
|
: ''}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -526,8 +526,19 @@ export class VideoRTC extends HTMLElement {
|
|||||||
|
|
||||||
this.ws = new WebSocket(this.wsURL);
|
this.ws = new WebSocket(this.wsURL);
|
||||||
this.ws.binaryType = 'arraybuffer';
|
this.ws.binaryType = 'arraybuffer';
|
||||||
this.ws.addEventListener('open', () => this.onopen());
|
|
||||||
this.ws.addEventListener('close', () => this.onclose());
|
// A socket superseded by teardown or reconnect while still connecting can
|
||||||
|
// still fire 'open'/'close' after `this.ws` was nulled or replaced. Ignore
|
||||||
|
// events from a socket that is no longer the active one -- otherwise a
|
||||||
|
// late 'open' dereferences a null `this.ws` (crash) and a late 'close'
|
||||||
|
// schedules a spurious reconnect.
|
||||||
|
const ws = this.ws;
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
if (this.ws === ws) this.onopen();
|
||||||
|
});
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
if (this.ws === ws) this.onclose();
|
||||||
|
});
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -628,6 +639,16 @@ export class VideoRTC extends HTMLElement {
|
|||||||
this.wsState = WebSocket.CONNECTING;
|
this.wsState = WebSocket.CONNECTING;
|
||||||
this.ws = null;
|
this.ws = null;
|
||||||
|
|
||||||
|
// onconnect() refuses to run while a peer connection lingers, so both `ws`
|
||||||
|
// and `pc` need to be clearer so the scheduled reconnect can proceed
|
||||||
|
// instead of no-opping and leaving a stream permanently wedged.
|
||||||
|
if (this.pc) {
|
||||||
|
this.pc.close();
|
||||||
|
this.pc = null;
|
||||||
|
this.pcState = WebSocket.CLOSED;
|
||||||
|
this._microphoneTransceiver = null;
|
||||||
|
}
|
||||||
|
|
||||||
// reconnect no more than once every X seconds
|
// reconnect no more than once every X seconds
|
||||||
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
|
const delay = Math.max(this.RECONNECT_TIMEOUT - (Date.now() - this.connectTS), 0);
|
||||||
|
|
||||||
@@ -694,7 +715,17 @@ export class VideoRTC extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sb.updating && sb.buffered && sb.buffered.length) {
|
// For Advanced Camera Card: a queued `updateend` can fire after a
|
||||||
|
// teardown/remount has detached this SourceBuffer from the MediaSource,
|
||||||
|
// at which point reading `sb.buffered` throws InvalidStateError. The
|
||||||
|
// buffered-range trim is best-effort, so skip it once the source is no
|
||||||
|
// longer open.
|
||||||
|
if (
|
||||||
|
ms.readyState === 'open' &&
|
||||||
|
!sb.updating &&
|
||||||
|
sb.buffered &&
|
||||||
|
sb.buffered.length
|
||||||
|
) {
|
||||||
const end = sb.buffered.end(sb.buffered.length - 1);
|
const end = sb.buffered.end(sb.buffered.length - 1);
|
||||||
const start = end - 5;
|
const start = end - 5;
|
||||||
const start0 = sb.buffered.start(0);
|
const start0 = sb.buffered.start(0);
|
||||||
|
|||||||
@@ -114,9 +114,14 @@ export class AdvancedCameraCardLiveJSMPEG extends LitElement implements MediaPla
|
|||||||
// restart.
|
// restart.
|
||||||
reconnectInterval: 0,
|
reconnectInterval: 0,
|
||||||
onVideoDecode: () => {
|
onVideoDecode: () => {
|
||||||
// This is the only callback that is called after the dimensions
|
// Called on every frame decode: feed the liveness watchdog so a
|
||||||
// are available. It's called on every frame decode, so just
|
// silent freeze (decodes stop while the WebSocket stays open) is
|
||||||
// ignore any subsequent calls.
|
// detected.
|
||||||
|
this._mediaPlayerController.notifyFrameDecoded();
|
||||||
|
|
||||||
|
// This is also the first callback called after the dimensions are
|
||||||
|
// available, so use the first call to resolve the player; ignore
|
||||||
|
// subsequent calls for that purpose.
|
||||||
if (!videoDecoded && this._jsmpegCanvasElement) {
|
if (!videoDecoded && this._jsmpegCanvasElement) {
|
||||||
videoDecoded = true;
|
videoDecoded = true;
|
||||||
resolve(player);
|
resolve(player);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { classMap } from 'lit/directives/class-map.js';
|
|||||||
import type { CameraManager } from '../camera-manager/manager.js';
|
import type { CameraManager } from '../camera-manager/manager.js';
|
||||||
import type { CallSession } from '../card-controller/call/types.js';
|
import type { CallSession } from '../card-controller/call/types.js';
|
||||||
import type { FoldersManager } from '../card-controller/folders/manager.js';
|
import type { FoldersManager } from '../card-controller/folders/manager.js';
|
||||||
|
import type { StateWatcherSubscriptionInterface } from '../card-controller/hass/state-watcher.js';
|
||||||
import type { IssuePresence } from '../card-controller/issues/types.js';
|
import type { IssuePresence } from '../card-controller/issues/types.js';
|
||||||
import type { MicrophoneManager } from '../card-controller/microphone-manager.js';
|
import type { MicrophoneManager } from '../card-controller/microphone-manager.js';
|
||||||
import type { MicrophoneState } from '../card-controller/types.js';
|
import type { MicrophoneState } from '../card-controller/types.js';
|
||||||
@@ -39,6 +40,9 @@ export class AdvancedCameraCardViews extends LitElement {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public hass?: HomeAssistant;
|
public hass?: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public stateWatcher?: StateWatcherSubscriptionInterface;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
public viewManagerEpoch?: ViewManagerEpoch;
|
public viewManagerEpoch?: ViewManagerEpoch;
|
||||||
|
|
||||||
@@ -242,6 +246,7 @@ export class AdvancedCameraCardViews extends LitElement {
|
|||||||
? html`
|
? html`
|
||||||
<advanced-camera-card-live
|
<advanced-camera-card-live
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
|
.stateWatcher=${this.stateWatcher}
|
||||||
.viewManagerEpoch=${this.viewManagerEpoch}
|
.viewManagerEpoch=${this.viewManagerEpoch}
|
||||||
.liveConfig=${this.config.live}
|
.liveConfig=${this.config.live}
|
||||||
.cameraManager=${this.cameraManager}
|
.cameraManager=${this.cameraManager}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type MediaQueryWatcherUnsubscribeCallback = () => void;
|
import type { UnsubscribeCallback } from '../../types';
|
||||||
|
|
||||||
// Watches a single CSS media query via `window.matchMedia`. The shared change
|
// Watches a single CSS media query via `window.matchMedia`. The shared change
|
||||||
// source for the `screen` condition and trigger, whose match state lives
|
// source for the `screen` condition and trigger, whose match state lives
|
||||||
@@ -16,7 +16,7 @@ export class MediaQueryWatcher {
|
|||||||
return window.matchMedia(this._query).matches;
|
return window.matchMedia(this._query).matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
public subscribe(callback: () => void): MediaQueryWatcherUnsubscribeCallback {
|
public subscribe(callback: () => void): UnsubscribeCallback {
|
||||||
this._callback = callback;
|
this._callback = callback;
|
||||||
this._mediaQuery = window.matchMedia(this._query);
|
this._mediaQuery = window.matchMedia(this._query);
|
||||||
this._mediaQuery.addEventListener('change', this._handler);
|
this._mediaQuery.addEventListener('change', this._handler);
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import type { TemplateRenderer } from '../../card-controller/templates';
|
import type { TemplateRenderer } from '../../card-controller/templates';
|
||||||
import type { Condition } from '../../config/schema/condition-trigger/conditions/types';
|
import type { Condition } from '../../config/schema/condition-trigger/conditions/types';
|
||||||
|
import type { UnsubscribeCallback } from '../../types';
|
||||||
import { isEnabled } from '../common/is-enabled';
|
import { isEnabled } from '../common/is-enabled';
|
||||||
import type {
|
import type { ConditionEvaluator } from './conditions/types';
|
||||||
ConditionEvaluator,
|
|
||||||
ExternalInvalidationUnsubscribeCallback,
|
|
||||||
} from './conditions/types';
|
|
||||||
import { createConditionEvaluator } from './factory';
|
import { createConditionEvaluator } from './factory';
|
||||||
import type {
|
import type {
|
||||||
ConditionsEvaluationResult,
|
ConditionsEvaluationResult,
|
||||||
@@ -32,7 +30,7 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface {
|
|||||||
|
|
||||||
private _listeners: ConditionsListener[] = [];
|
private _listeners: ConditionsListener[] = [];
|
||||||
private _evaluation: ConditionsEvaluationResult = { result: false };
|
private _evaluation: ConditionsEvaluationResult = { result: false };
|
||||||
private _unsubscribeCallbacks: ExternalInvalidationUnsubscribeCallback[] = [];
|
private _unsubscribeCallbacks: UnsubscribeCallback[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
conditions: Condition[],
|
conditions: Condition[],
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import type { TemplateRenderer } from '../../../card-controller/templates';
|
import type { TemplateRenderer } from '../../../card-controller/templates';
|
||||||
import type { Condition } from '../../../config/schema/condition-trigger/conditions/types';
|
import type { Condition } from '../../../config/schema/condition-trigger/conditions/types';
|
||||||
|
import type { UnsubscribeCallback } from '../../../types';
|
||||||
import type { ConditionsEvaluationResult, ConditionState } from '../types';
|
import type { ConditionsEvaluationResult, ConditionState } from '../types';
|
||||||
|
|
||||||
export type ExternalInvalidationUnsubscribeCallback = () => void;
|
|
||||||
|
|
||||||
// A source of change outside the card's `ConditionState` that can invalidate a
|
// A source of change outside the card's `ConditionState` that can invalidate a
|
||||||
// condition's result (currently only `screen`, via `matchMedia`). A condition
|
// condition's result (currently only `screen`, via `matchMedia`). A condition
|
||||||
// declares its sources so a reactive consumer knows what to watch; a pull
|
// declares its sources so a reactive consumer knows what to watch; a pull
|
||||||
// consumer ignores them.
|
// consumer ignores them.
|
||||||
export interface ExternalInvalidationSource {
|
export interface ExternalInvalidationSource {
|
||||||
subscribe(callback: () => void): ExternalInvalidationUnsubscribeCallback;
|
subscribe(callback: () => void): UnsubscribeCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import {
|
import type { UnsubscribeCallback } from '../../../types';
|
||||||
MediaQueryWatcher,
|
import { MediaQueryWatcher } from '../../common/media-query-watcher';
|
||||||
type MediaQueryWatcherUnsubscribeCallback,
|
|
||||||
} from '../../common/media-query-watcher';
|
|
||||||
import { buildCardTriggerData } from '../build-trigger-data';
|
import { buildCardTriggerData } from '../build-trigger-data';
|
||||||
import type { TriggerCallback, TriggerEvaluator, TriggerOfType } from './types';
|
import type { TriggerCallback, TriggerEvaluator, TriggerOfType } from './types';
|
||||||
|
|
||||||
@@ -14,7 +12,7 @@ export class ScreenTrigger implements TriggerEvaluator {
|
|||||||
|
|
||||||
private _callback: TriggerCallback | null = null;
|
private _callback: TriggerCallback | null = null;
|
||||||
private _watcher: MediaQueryWatcher | null = null;
|
private _watcher: MediaQueryWatcher | null = null;
|
||||||
private _unsubscribeCallback: MediaQueryWatcherUnsubscribeCallback | null = null;
|
private _unsubscribeCallback: UnsubscribeCallback | null = null;
|
||||||
private _matched = false;
|
private _matched = false;
|
||||||
|
|
||||||
constructor(trigger: TriggerOfType<'screen'>) {
|
constructor(trigger: TriggerOfType<'screen'>) {
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ export const TROUBLESHOOTING_CONFIG_UPGRADE_FAILURE_URL =
|
|||||||
export const TROUBLESHOOTING_LEGACY_RESOURCE_URL =
|
export const TROUBLESHOOTING_LEGACY_RESOURCE_URL =
|
||||||
`${TROUBLESHOOTING_URL}?id=legacy-dashboard-resource-detected` as const;
|
`${TROUBLESHOOTING_URL}?id=legacy-dashboard-resource-detected` as const;
|
||||||
export const TROUBLESHOOTING_MEDIA_URL =
|
export const TROUBLESHOOTING_MEDIA_URL =
|
||||||
`${TROUBLESHOOTING_URL}?id=media-does-not-load` as const;
|
`${TROUBLESHOOTING_URL}?id=media-unavailable` as const;
|
||||||
|
|
||||||
export const CONF_AUTOMATIONS = 'automations' as const;
|
export const CONF_AUTOMATIONS = 'automations' as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { RecoverableHealthInterface, UnlistenCallback } from '../../health';
|
import type { RecoverableHealthInterface } from '../../health';
|
||||||
|
import type { UnsubscribeCallback } from '../../types';
|
||||||
import type { HASSWebSocketSubscriptionStatus } from './subscription-manager';
|
import type { HASSWebSocketSubscriptionStatus } from './subscription-manager';
|
||||||
|
|
||||||
// One keyed subscription that is currently failing, with the most recent
|
// One keyed subscription that is currently failing, with the most recent
|
||||||
@@ -76,7 +77,7 @@ export class SubscriptionHealthMonitor<K, R> implements SubscriptionHealthInterf
|
|||||||
return [...failing.values()];
|
return [...failing.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
public addListener(listener: () => void): UnlistenCallback {
|
public addListener(listener: () => void): UnsubscribeCallback {
|
||||||
this._listeners.add(listener);
|
this._listeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
this._listeners.delete(listener);
|
this._listeners.delete(listener);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { Connection } from 'home-assistant-js-websocket';
|
import type { Connection } from 'home-assistant-js-websocket';
|
||||||
|
|
||||||
|
import type { UnsubscribeCallback } from '../../types';
|
||||||
import {
|
import {
|
||||||
KeyedSubscriptionManager,
|
KeyedSubscriptionManager,
|
||||||
type GetKeyCallback,
|
type GetKeyCallback,
|
||||||
} from '../../utils/concurrency/keyed-subscription-manager';
|
} from '../../utils/concurrency/keyed-subscription-manager';
|
||||||
import { RetryTimer } from '../../utils/retry-timer';
|
import { RetryTimer } from '../../utils/retry-timer';
|
||||||
import { isHassReady } from '../is-hass-ready';
|
import { isHassReady } from '../is-hass-ready';
|
||||||
import type { HASSSource, HASSUnlistenCallback } from '../source';
|
import type { HASSSource } from '../source';
|
||||||
import type { HomeAssistant } from '../types';
|
import type { HomeAssistant } from '../types';
|
||||||
import type { HASSWebSocketLiveness, HASSWebSocketOpenCallback } from './types';
|
import type { HASSWebSocketLiveness, HASSWebSocketOpenCallback } from './types';
|
||||||
|
|
||||||
@@ -117,7 +118,7 @@ export class HASSConnectionSubscriptionManager<K, R> {
|
|||||||
// subscribe/unsubscribe.
|
// subscribe/unsubscribe.
|
||||||
private _requests = new Map<R, RequestRegistration<K, R>>();
|
private _requests = new Map<R, RequestRegistration<K, R>>();
|
||||||
|
|
||||||
private _unlistenCallback: HASSUnlistenCallback | null = null;
|
private _unlistenCallback: UnsubscribeCallback | null = null;
|
||||||
|
|
||||||
constructor(getKeyCallback: GetKeyCallback<R, K>, source: HASSSource) {
|
constructor(getKeyCallback: GetKeyCallback<R, K>, source: HASSSource) {
|
||||||
this._getKeyCallback = getKeyCallback;
|
this._getKeyCallback = getKeyCallback;
|
||||||
|
|||||||
+2
-3
@@ -1,3 +1,4 @@
|
|||||||
|
import type { UnsubscribeCallback } from '../types';
|
||||||
import type { HomeAssistant } from './types';
|
import type { HomeAssistant } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,9 +9,7 @@ import type { HomeAssistant } from './types';
|
|||||||
*/
|
*/
|
||||||
export type HASSListener = (hass: HomeAssistant, oldHass: HomeAssistant | null) => void;
|
export type HASSListener = (hass: HomeAssistant, oldHass: HomeAssistant | null) => void;
|
||||||
|
|
||||||
export type HASSUnlistenCallback = () => void;
|
|
||||||
|
|
||||||
export interface HASSSource {
|
export interface HASSSource {
|
||||||
getHASS(): HomeAssistant | null;
|
getHASS(): HomeAssistant | null;
|
||||||
addListener(listener: HASSListener): HASSUnlistenCallback;
|
addListener(listener: HASSListener): UnsubscribeCallback;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -1,11 +1,10 @@
|
|||||||
// Returned by `addListener`; invoke it to stop listening.
|
import type { UnsubscribeCallback } from './types';
|
||||||
export type UnlistenCallback = () => void;
|
|
||||||
|
|
||||||
// A source of health information: the current failures (of some domain-specific
|
// A source of health information: the current failures (of some domain-specific
|
||||||
// shape `F`) and a way to observe changes to them.
|
// shape `F`) and a way to observe changes to them.
|
||||||
interface HealthInterface<F> {
|
interface HealthInterface<F> {
|
||||||
getFailures(): F[];
|
getFailures(): F[];
|
||||||
addListener(listener: () => void): UnlistenCallback;
|
addListener(listener: () => void): UnsubscribeCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Health that also supports a user-driven retry of whatever is currently
|
// Health that also supports a user-driven retry of whatever is currently
|
||||||
|
|||||||
@@ -463,7 +463,6 @@
|
|||||||
"jsmpeg_no_player": "No s'ha pogut iniciar el reproductor JSMPEG",
|
"jsmpeg_no_player": "No s'ha pogut iniciar el reproductor JSMPEG",
|
||||||
"live_camera_no_endpoint": "No s'ha pogut obtenir el punt final de la càmera per a aquest proveïdor en directe (configuració incompleta?)",
|
"live_camera_no_endpoint": "No s'ha pogut obtenir el punt final de la càmera per a aquest proveïdor en directe (configuració incompleta?)",
|
||||||
"live_camera_not_found": "No s'ha trobat la 'camera_entity' (entitat de la càmera) configurada",
|
"live_camera_not_found": "No s'ha trobat la 'camera_entity' (entitat de la càmera) configurada",
|
||||||
"live_camera_unavailable": "Càmera no disponible",
|
|
||||||
"no_camera_engine": "No s'ha pogut determinar el motor adequat per a la càmera",
|
"no_camera_engine": "No s'ha pogut determinar el motor adequat per a la càmera",
|
||||||
"no_camera_entity": "No s'ha pogut trobar l'entitat de la càmera",
|
"no_camera_entity": "No s'ha pogut trobar l'entitat de la càmera",
|
||||||
"no_camera_id": "No s'ha pogut determinar l'identificador de la càmera per a la següent càmera, és possible que hagis d'establir el paràmetre 'id' manualment",
|
"no_camera_id": "No s'ha pogut determinar l'identificador de la càmera per a la següent càmera, és possible que hagis d'establir el paràmetre 'id' manualment",
|
||||||
@@ -476,6 +475,13 @@
|
|||||||
"webrtc_card_reported_error": "La targeta WebRTC ha informat d'un error",
|
"webrtc_card_reported_error": "La targeta WebRTC ha informat d'un error",
|
||||||
"webrtc_card_waiting": "S'està esperant que es carregui la targeta WebRTC ..."
|
"webrtc_card_waiting": "S'està esperant que es carregui la targeta WebRTC ..."
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"media_unavailable": {
|
||||||
|
"reasons": {
|
||||||
|
"entity_unavailable": "Càmera no disponible"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"media_filter": {
|
"media_filter": {
|
||||||
"camera": "Càmera",
|
"camera": "Càmera",
|
||||||
"favorite": "Preferit",
|
"favorite": "Preferit",
|
||||||
|
|||||||
@@ -392,7 +392,7 @@
|
|||||||
"too_many_automations": "Zu viele automatisierte Anfragen, bitte prüfe deine Konfiguration auf etwaige Schleifen"
|
"too_many_automations": "Zu viele automatisierte Anfragen, bitte prüfe deine Konfiguration auf etwaige Schleifen"
|
||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
"media_load": {
|
"media_unavailable": {
|
||||||
"text": "Der Videostream wurde nicht geladen. Dafür kann es verschiedene Gründe geben. Wenn konfiguriert (Standard), wird ein Bild jede Sekunde angezeigt, bis der Steam richtig lädt"
|
"text": "Der Videostream wurde nicht geladen. Dafür kann es verschiedene Gründe geben. Wenn konfiguriert (Standard), wird ein Bild jede Sekunde angezeigt, bis der Steam richtig lädt"
|
||||||
},
|
},
|
||||||
"view_incompatible": {
|
"view_incompatible": {
|
||||||
|
|||||||
@@ -806,7 +806,6 @@
|
|||||||
"jsmpeg_no_player": "Could not start JSMPEG player",
|
"jsmpeg_no_player": "Could not start JSMPEG player",
|
||||||
"live_camera_no_endpoint": "Could not get camera endpoint for this live provider (incomplete configuration?)",
|
"live_camera_no_endpoint": "Could not get camera endpoint for this live provider (incomplete configuration?)",
|
||||||
"live_camera_not_found": "The configured camera_entity was not found",
|
"live_camera_not_found": "The configured camera_entity was not found",
|
||||||
"live_camera_unavailable": "Camera unavailable",
|
|
||||||
"microphone_not_supported": "Microphone is not supported in this browser",
|
"microphone_not_supported": "Microphone is not supported in this browser",
|
||||||
"no_camera_engine": "Could not determine suitable engine for camera",
|
"no_camera_engine": "Could not determine suitable engine for camera",
|
||||||
"no_camera_entity": "Could not find camera entity",
|
"no_camera_entity": "Could not find camera entity",
|
||||||
@@ -857,14 +856,20 @@
|
|||||||
"text_both": "The legacy 'frigate-hass-card.js' resource is still registered, please either manually remove it or click the delete icon to automatically remove it. It will be removed in a future release",
|
"text_both": "The legacy 'frigate-hass-card.js' resource is still registered, please either manually remove it or click the delete icon to automatically remove it. It will be removed in a future release",
|
||||||
"text_only_legacy": "The legacy 'frigate-hass-card.js' resource must be replaced with 'advanced-camera-card.js'. It will be removed in a future release"
|
"text_only_legacy": "The legacy 'frigate-hass-card.js' resource must be replaced with 'advanced-camera-card.js'. It will be removed in a future release"
|
||||||
},
|
},
|
||||||
"media_load": {
|
|
||||||
"heading": "Media not loading",
|
|
||||||
"text": "The media has not yet loaded. This could be for any number of reasons. For live views and if configured (and by default), there will be an image refreshing every second until the stream loads correctly or the media loading is retried"
|
|
||||||
},
|
|
||||||
"media_query": {
|
"media_query": {
|
||||||
"heading": "Media query failed",
|
"heading": "Media query failed",
|
||||||
"text": "An error occurred fetching media (e.g. thumbnails) for this view."
|
"text": "An error occurred fetching media (e.g. thumbnails) for this view."
|
||||||
},
|
},
|
||||||
|
"media_unavailable": {
|
||||||
|
"heading": "Media unavailable",
|
||||||
|
"reasons": {
|
||||||
|
"entity_unavailable": "Camera entity unavailable",
|
||||||
|
"not_loading": "Media not loading",
|
||||||
|
"playback_error": "Playback error",
|
||||||
|
"stalled": "Stream stalled"
|
||||||
|
},
|
||||||
|
"text": "The media is not currently available. This can happen for several reasons, for example a camera becoming unavailable, a stream stalling or failing, or media that has not finished loading. The card keeps retrying automatically. For live views, a still image may be shown while a stream is loading (if configured, and by default)"
|
||||||
|
},
|
||||||
"troubleshooting_guide": "Troubleshooting guide",
|
"troubleshooting_guide": "Troubleshooting guide",
|
||||||
"view_incompatible": {
|
"view_incompatible": {
|
||||||
"heading": "View not supported",
|
"heading": "View not supported",
|
||||||
|
|||||||
@@ -615,7 +615,6 @@
|
|||||||
"jsmpeg_no_player": "Impossible de démarrer le lecteur JSMPEG",
|
"jsmpeg_no_player": "Impossible de démarrer le lecteur JSMPEG",
|
||||||
"live_camera_no_endpoint": "Impossible d'obtenir le point de terminaison de la caméra pour ce fournisseur en direct (configuration incomplète ?)",
|
"live_camera_no_endpoint": "Impossible d'obtenir le point de terminaison de la caméra pour ce fournisseur en direct (configuration incomplète ?)",
|
||||||
"live_camera_not_found": "L'entité caméra configurée n'a pas été trouvée",
|
"live_camera_not_found": "L'entité caméra configurée n'a pas été trouvée",
|
||||||
"live_camera_unavailable": "Caméra indisponible",
|
|
||||||
"no_camera_engine": "Impossible de déterminer le moteur approprié pour la caméra",
|
"no_camera_engine": "Impossible de déterminer le moteur approprié pour la caméra",
|
||||||
"no_camera_entity": "Impossible de trouver l'entité de caméra",
|
"no_camera_entity": "Impossible de trouver l'entité de caméra",
|
||||||
"no_camera_id": "Impossible de déterminer l'identifiant de la caméra suivante. Il faudra peut-être définir le paramètre « ID » manuellement",
|
"no_camera_id": "Impossible de déterminer l'identifiant de la caméra suivante. Il faudra peut-être définir le paramètre « ID » manuellement",
|
||||||
@@ -629,7 +628,10 @@
|
|||||||
"webrtc_card_waiting": "En attente du chargement de la carte WebRTC..."
|
"webrtc_card_waiting": "En attente du chargement de la carte WebRTC..."
|
||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
"media_load": {
|
"media_unavailable": {
|
||||||
|
"reasons": {
|
||||||
|
"entity_unavailable": "Caméra indisponible"
|
||||||
|
},
|
||||||
"text": "Le flux vidéo ou le média enregistré n'a pas encore été chargé. Cela peut être du à plusieurs raisons. La carte tentera de recharger automatiquement après un délai d'attente."
|
"text": "Le flux vidéo ou le média enregistré n'a pas encore été chargé. Cela peut être du à plusieurs raisons. La carte tentera de recharger automatiquement après un délai d'attente."
|
||||||
},
|
},
|
||||||
"view_incompatible": {
|
"view_incompatible": {
|
||||||
|
|||||||
@@ -347,7 +347,6 @@
|
|||||||
"jsmpeg_no_player": "Impossibile avviare JSMPEG Player",
|
"jsmpeg_no_player": "Impossibile avviare JSMPEG Player",
|
||||||
"live_camera_no_endpoint": "Impossibile ottenere l'endpoint della videocamera per questo provider live (configurazione incompleta?)",
|
"live_camera_no_endpoint": "Impossibile ottenere l'endpoint della videocamera per questo provider live (configurazione incompleta?)",
|
||||||
"live_camera_not_found": "La telecamera configurata non è stata trovata",
|
"live_camera_not_found": "La telecamera configurata non è stata trovata",
|
||||||
"live_camera_unavailable": "Telecamera non disponibile",
|
|
||||||
"no_camera_engine": "Impossibile determinare il motore adatto per la fotocamera",
|
"no_camera_engine": "Impossibile determinare il motore adatto per la fotocamera",
|
||||||
"no_camera_entity": "Impossibile trovare l'entità fotocamera",
|
"no_camera_entity": "Impossibile trovare l'entità fotocamera",
|
||||||
"no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'",
|
"no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'",
|
||||||
@@ -358,6 +357,13 @@
|
|||||||
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
|
"webrtc_card_reported_error": "La scheda WebRTC ha riportato un errore",
|
||||||
"webrtc_card_waiting": "Aspettando che la scheda WebRTC si carichi ..."
|
"webrtc_card_waiting": "Aspettando che la scheda WebRTC si carichi ..."
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"media_unavailable": {
|
||||||
|
"reasons": {
|
||||||
|
"entity_unavailable": "Telecamera non disponibile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"media_filter": {
|
"media_filter": {
|
||||||
"camera": "Telecamera",
|
"camera": "Telecamera",
|
||||||
"favorite": "Preferito",
|
"favorite": "Preferito",
|
||||||
|
|||||||
@@ -677,7 +677,6 @@
|
|||||||
"jsmpeg_no_player": "Nie można uruchomić odtwarzacza JSMPEG",
|
"jsmpeg_no_player": "Nie można uruchomić odtwarzacza JSMPEG",
|
||||||
"live_camera_no_endpoint": "Nie można uzyskać punktu końcowego kamery dla tego dostawcy (niepełna konfiguracja?)",
|
"live_camera_no_endpoint": "Nie można uzyskać punktu końcowego kamery dla tego dostawcy (niepełna konfiguracja?)",
|
||||||
"live_camera_not_found": "Skonfigurowana camera_entity nie została znaleziona",
|
"live_camera_not_found": "Skonfigurowana camera_entity nie została znaleziona",
|
||||||
"live_camera_unavailable": "Kamera niedostępna",
|
|
||||||
"no_camera_engine": "Nie można określić odpowiedniego silnika dla kamery",
|
"no_camera_engine": "Nie można określić odpowiedniego silnika dla kamery",
|
||||||
"no_camera_entity": "Nie można znaleźć encji kamery",
|
"no_camera_entity": "Nie można znaleźć encji kamery",
|
||||||
"no_camera_id": "Nie można określić ID kamery, może być konieczne ręczne ustawienie parametru 'id'",
|
"no_camera_id": "Nie można określić ID kamery, może być konieczne ręczne ustawienie parametru 'id'",
|
||||||
@@ -692,7 +691,10 @@
|
|||||||
"webrtc_card_waiting": "Oczekiwanie na załadowanie karty WebRTC..."
|
"webrtc_card_waiting": "Oczekiwanie na załadowanie karty WebRTC..."
|
||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
"media_load": {
|
"media_unavailable": {
|
||||||
|
"reasons": {
|
||||||
|
"entity_unavailable": "Kamera niedostępna"
|
||||||
|
},
|
||||||
"text": "Strumień wideo jeszcze się nie załadował. Może to wynikać z wielu przyczyn. Domyślnie będzie wyświetlany odświeżany obraz, dopóki strumień nie załaduje się poprawnie"
|
"text": "Strumień wideo jeszcze się nie załadował. Może to wynikać z wielu przyczyn. Domyślnie będzie wyświetlany odświeżany obraz, dopóki strumień nie załaduje się poprawnie"
|
||||||
},
|
},
|
||||||
"view_incompatible": {
|
"view_incompatible": {
|
||||||
|
|||||||
@@ -354,7 +354,6 @@
|
|||||||
"jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG",
|
"jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG",
|
||||||
"live_camera_no_endpoint": "Não foi possível obter o endereço da câmera para este provedor ao vivo (configuração incompleta?)",
|
"live_camera_no_endpoint": "Não foi possível obter o endereço da câmera para este provedor ao vivo (configuração incompleta?)",
|
||||||
"live_camera_not_found": "A entidade de câmera configurada não foi encontrada",
|
"live_camera_not_found": "A entidade de câmera configurada não foi encontrada",
|
||||||
"live_camera_unavailable": "Câmera indisponível",
|
|
||||||
"no_camera_engine": "Não foi possível determinar o motor adequado para a câmera",
|
"no_camera_engine": "Não foi possível determinar o motor adequado para a câmera",
|
||||||
"no_camera_entity": "Não foi possível encontrar a entidade da câmera",
|
"no_camera_entity": "Não foi possível encontrar a entidade da câmera",
|
||||||
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
|
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
|
||||||
@@ -365,6 +364,13 @@
|
|||||||
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
|
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
|
||||||
"webrtc_card_waiting": "Aguardando o cartão WebRTC carregar ..."
|
"webrtc_card_waiting": "Aguardando o cartão WebRTC carregar ..."
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"media_unavailable": {
|
||||||
|
"reasons": {
|
||||||
|
"entity_unavailable": "Câmera indisponível"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"media_filter": {
|
"media_filter": {
|
||||||
"camera": "Câmera",
|
"camera": "Câmera",
|
||||||
"favorite": "Favorito",
|
"favorite": "Favorito",
|
||||||
|
|||||||
@@ -347,7 +347,6 @@
|
|||||||
"jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG",
|
"jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG",
|
||||||
"live_camera_no_endpoint": "Nenhuma câmera ao vivo",
|
"live_camera_no_endpoint": "Nenhuma câmera ao vivo",
|
||||||
"live_camera_not_found": "Nenhuma câmera ao vivo não foi encontrada",
|
"live_camera_not_found": "Nenhuma câmera ao vivo não foi encontrada",
|
||||||
"live_camera_unavailable": "Câmera ao vivo indisponivel",
|
|
||||||
"no_camera_engine": "Não existe câmera",
|
"no_camera_engine": "Não existe câmera",
|
||||||
"no_camera_entity": "Não existe uma entidade câmera",
|
"no_camera_entity": "Não existe uma entidade câmera",
|
||||||
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
|
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
|
||||||
@@ -358,6 +357,13 @@
|
|||||||
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
|
"webrtc_card_reported_error": "O cartão WebRTC relatou um erro",
|
||||||
"webrtc_card_waiting": "Aguardar o cartão WebRTC carregar ..."
|
"webrtc_card_waiting": "Aguardar o cartão WebRTC carregar ..."
|
||||||
},
|
},
|
||||||
|
"issues": {
|
||||||
|
"media_unavailable": {
|
||||||
|
"reasons": {
|
||||||
|
"entity_unavailable": "Câmera ao vivo indisponivel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"media_filter": {
|
"media_filter": {
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"favorite": "Favoritos",
|
"favorite": "Favoritos",
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ export type MediaLoadedInfoOwner = HTMLElement;
|
|||||||
export interface MediaLoadedInfoEventDetail {
|
export interface MediaLoadedInfoEventDetail {
|
||||||
info: MediaLoadedInfo;
|
info: MediaLoadedInfo;
|
||||||
|
|
||||||
|
// Absent (the default, a fresh media load) unless `true`, which marks a
|
||||||
|
// replay: the source re-dispatched its last load on reconnect without the
|
||||||
|
// media actually reloading (e.g. preloaded camera in the background when
|
||||||
|
// brought to the foreground).
|
||||||
|
cached?: boolean;
|
||||||
|
|
||||||
// Aborts when the source retires this media. The source aborts on host
|
// Aborts when the source retires this media. The source aborts on host
|
||||||
// disconnect, and when a subsequent `set()` arrives under a different
|
// disconnect, and when a subsequent `set()` arrives under a different
|
||||||
// `targetID` (replacing this dispatch). Independent of DOM connectedness, so
|
// `targetID` (replacing this dispatch). Independent of DOM connectedness, so
|
||||||
@@ -78,6 +84,11 @@ export type WebkitHTMLVideoElement = HTMLVideoElement & {
|
|||||||
export type FullscreenElement = HTMLElement;
|
export type FullscreenElement = HTMLElement;
|
||||||
export type PIPElement = HTMLVideoElement;
|
export type PIPElement = HTMLVideoElement;
|
||||||
|
|
||||||
|
export type UnsubscribeCallback = () => void;
|
||||||
|
|
||||||
|
// Reports each live/stalled transition of a player's media stream.
|
||||||
|
export type LivenessCallback = (isLive: boolean) => void;
|
||||||
|
|
||||||
export interface MediaPlayerController {
|
export interface MediaPlayerController {
|
||||||
play(): Promise<void>;
|
play(): Promise<void>;
|
||||||
pause(): Promise<void>;
|
pause(): Promise<void>;
|
||||||
@@ -91,6 +102,13 @@ export interface MediaPlayerController {
|
|||||||
isPaused(): boolean;
|
isPaused(): boolean;
|
||||||
getFullscreenElement(): FullscreenElement | null;
|
getFullscreenElement(): FullscreenElement | null;
|
||||||
getPIPElement(): PIPElement | null;
|
getPIPElement(): PIPElement | null;
|
||||||
|
|
||||||
|
// Observe whether the player is actively delivering media, so a silent freeze
|
||||||
|
// (frames stop advancing while playing) can be detected. Optional:
|
||||||
|
// implemented only by players that can observe their own frame progress. The
|
||||||
|
// callback fires on each live/stalled transition; the returned callback stops
|
||||||
|
// the observation.
|
||||||
|
subscribeLiveness?(callback: LivenessCallback): UnsubscribeCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaPlayer {
|
export interface MediaPlayer {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
|
|
||||||
type UnsubscribeCallback = () => Promise<void>;
|
type AsyncUnsubscribeCallback = () => Promise<void>;
|
||||||
type SubscribeCallback = () => Promise<UnsubscribeCallback>;
|
type SubscribeCallback = () => Promise<AsyncUnsubscribeCallback>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts the key from a request. Used by `KeyedSubscriptionManager` and any
|
* Extracts the key from a request. Used by `KeyedSubscriptionManager` and any
|
||||||
@@ -22,7 +22,7 @@ export type GetKeyCallback<R, K> = (request: R) => K;
|
|||||||
*/
|
*/
|
||||||
export class KeyedSubscriptionManager<K, R> {
|
export class KeyedSubscriptionManager<K, R> {
|
||||||
private _requests: R[] = [];
|
private _requests: R[] = [];
|
||||||
private _unsubscribers = new Map<K, UnsubscribeCallback>();
|
private _unsubscribers = new Map<K, AsyncUnsubscribeCallback>();
|
||||||
private _queues = new Map<K, PQueue>();
|
private _queues = new Map<K, PQueue>();
|
||||||
private _getKeyCallback: GetKeyCallback<R, K>;
|
private _getKeyCallback: GetKeyCallback<R, K>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { allPromises } from '../basic';
|
import { allPromises } from '../basic';
|
||||||
|
|
||||||
type InitializationCallback = () => Promise<void>;
|
// An initializer returns `false` when it could not complete its work in this
|
||||||
|
// attempt; the aspect is then left uninitialized so a later attempt retries.
|
||||||
|
// Any other result marks the aspect initialized.
|
||||||
|
type InitializationCallback = () => Promise<boolean | void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages initialization state and runs initializers.
|
* Manages initialization state and runs initializers.
|
||||||
@@ -21,31 +24,34 @@ export class Initializer {
|
|||||||
|
|
||||||
public async initializeMultipleIfNecessary(
|
public async initializeMultipleIfNecessary(
|
||||||
aspects: Record<string, InitializationCallback>,
|
aspects: Record<string, InitializationCallback>,
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
await allPromises(
|
const results = await allPromises(
|
||||||
Object.entries(aspects),
|
Object.entries(aspects),
|
||||||
async ([aspect, options]) => await this.initializeIfNecessary(aspect, options),
|
async ([aspect, options]) => await this.initializeIfNecessary(aspect, options),
|
||||||
);
|
);
|
||||||
|
return results.every(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns whether the aspect is initialized once this attempt completes.
|
||||||
public async initializeIfNecessary(
|
public async initializeIfNecessary(
|
||||||
aspect: string,
|
aspect: string,
|
||||||
initializer?: InitializationCallback,
|
initializer?: InitializationCallback,
|
||||||
): Promise<void> {
|
): Promise<boolean> {
|
||||||
if (this._initialized.has(aspect)) {
|
if (this._initialized.has(aspect)) {
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
const generation = this._generation.get(aspect) ?? 0;
|
const generation = this._generation.get(aspect) ?? 0;
|
||||||
if (initializer) {
|
if (initializer && (await initializer()) === false) {
|
||||||
await initializer();
|
return false;
|
||||||
}
|
}
|
||||||
// If `uninitialize()` ran while we were awaiting, a newer attempt has taken
|
// If `uninitialize()` ran while we were awaiting, a newer attempt has taken
|
||||||
// over -- throw this result away (don't mark it initialized) so a stale
|
// over -- throw this result away (don't mark it initialized) so a stale
|
||||||
// result can't leave the card stuck, and a fresh attempt runs next time.
|
// result can't leave the card stuck, and a fresh attempt runs next time.
|
||||||
if ((this._generation.get(aspect) ?? 0) !== generation) {
|
if ((this._generation.get(aspect) ?? 0) !== generation) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
this._initialized.add(aspect);
|
this._initialized.add(aspect);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public uninitialize(aspect: string): void {
|
public uninitialize(aspect: string): void {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ describe('Camera', () => {
|
|||||||
}),
|
}),
|
||||||
new GenericCameraManagerEngine(createHASSManager()),
|
new GenericCameraManagerEngine(createHASSManager()),
|
||||||
);
|
);
|
||||||
|
expect(camera.isInitialized()).toBe(false);
|
||||||
|
|
||||||
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
await camera.initialize({
|
await camera.initialize({
|
||||||
@@ -101,6 +102,7 @@ describe('Camera', () => {
|
|||||||
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
capabilityOptions: { capabilities: createCapabilities({ trigger: true }) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(camera.isInitialized()).toBe(true);
|
||||||
expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), [
|
expect(stateWatcher.subscribe).toBeCalledWith(expect.any(Function), [
|
||||||
'camera.foo',
|
'camera.foo',
|
||||||
]);
|
]);
|
||||||
@@ -128,6 +130,7 @@ describe('Camera', () => {
|
|||||||
|
|
||||||
expect(stateWatcher.subscribe).not.toBeCalled();
|
expect(stateWatcher.subscribe).not.toBeCalled();
|
||||||
expect(camera.getCapabilities()).toBeNull();
|
expect(camera.getCapabilities()).toBeNull();
|
||||||
|
expect(camera.isInitialized()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set capabilities and use go2rtc metadata endpoint', async () => {
|
it('should set capabilities and use go2rtc metadata endpoint', async () => {
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ describe('FrigateCamera', () => {
|
|||||||
expect(camera.getConfig().frigate.client_id).toBe('remote_x');
|
expect(camera.getConfig().frigate.client_id).toBe('remote_x');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves client_id untouched when the camera entity is unavailable', async () => {
|
it('falls back to "frigate" when the camera entity is unavailable', async () => {
|
||||||
const camera = new FrigateCamera(
|
const camera = new FrigateCamera(
|
||||||
createCameraConfig({
|
createCameraConfig({
|
||||||
camera_entity: 'camera.front_door',
|
camera_entity: 'camera.front_door',
|
||||||
@@ -285,7 +285,7 @@ describe('FrigateCamera', () => {
|
|||||||
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
frigateEventWatcher: mock<FrigateEventWatcher>(),
|
||||||
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
frigateReviewWatcher: mock<FrigateReviewWatcher>(),
|
||||||
});
|
});
|
||||||
expect(camera.getConfig().frigate.client_id).toBeUndefined();
|
expect(camera.getConfig().frigate.client_id).toBe('frigate');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ describe('InitializationManager', () => {
|
|||||||
|
|
||||||
// First call (languages/side-load) succeeds, second (cameras) fails.
|
// First call (languages/side-load) succeeds, second (cameras) fails.
|
||||||
initializer.initializeMultipleIfNecessary
|
initializer.initializeMultipleIfNecessary
|
||||||
.mockResolvedValueOnce(undefined)
|
.mockResolvedValueOnce(true)
|
||||||
.mockRejectedValueOnce(new Error('cameras failed'));
|
.mockRejectedValueOnce(new Error('cameras failed'));
|
||||||
|
|
||||||
await manager.initializeMandatory();
|
await manager.initializeMandatory();
|
||||||
@@ -250,10 +250,12 @@ describe('InitializationManager', () => {
|
|||||||
const initializer = mock<Initializer>();
|
const initializer = mock<Initializer>();
|
||||||
const manager = new InitializationManager(api, initializer);
|
const manager = new InitializationManager(api, initializer);
|
||||||
|
|
||||||
|
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
|
||||||
|
|
||||||
// First initializeIfNecessary call (view) succeeds, second
|
// First initializeIfNecessary call (view) succeeds, second
|
||||||
// (initial_trigger) fails.
|
// (initial_trigger) fails.
|
||||||
initializer.initializeIfNecessary
|
initializer.initializeIfNecessary
|
||||||
.mockResolvedValueOnce(undefined)
|
.mockResolvedValueOnce(true)
|
||||||
.mockRejectedValueOnce(new Error('triggers failed'));
|
.mockRejectedValueOnce(new Error('triggers failed'));
|
||||||
|
|
||||||
await manager.initializeMandatory();
|
await manager.initializeMandatory();
|
||||||
@@ -272,6 +274,7 @@ describe('InitializationManager', () => {
|
|||||||
|
|
||||||
const initializer = mock<Initializer>();
|
const initializer = mock<Initializer>();
|
||||||
const manager = new InitializationManager(api, initializer);
|
const manager = new InitializationManager(api, initializer);
|
||||||
|
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
|
||||||
initializer.initializeIfNecessary.mockRejectedValueOnce(
|
initializer.initializeIfNecessary.mockRejectedValueOnce(
|
||||||
new Error('view initialization failed'),
|
new Error('view initialization failed'),
|
||||||
);
|
);
|
||||||
@@ -285,6 +288,29 @@ describe('InitializationManager', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should stop without an error when an aspect reports failure', async () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||||
|
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
|
||||||
|
|
||||||
|
const initializer = mock<Initializer>();
|
||||||
|
const manager = new InitializationManager(api, initializer);
|
||||||
|
initializer.initializeMultipleIfNecessary.mockResolvedValue(true);
|
||||||
|
|
||||||
|
// An aspect that could not complete (e.g. the view when no view could be
|
||||||
|
// set) reports failure rather than throwing: the chain stops so a later
|
||||||
|
// attempt retries, and no initialization error is raised.
|
||||||
|
initializer.initializeIfNecessary.mockResolvedValueOnce(false);
|
||||||
|
|
||||||
|
await manager.initializeMandatory();
|
||||||
|
|
||||||
|
expect(manager.wasEverInitialized()).toBeFalsy();
|
||||||
|
expect(api.getIssueManager().trigger).not.toBeCalledWith(
|
||||||
|
'initialization',
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle non-Error thrown during initialization', async () => {
|
it('should handle non-Error thrown during initialization', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ describe('createIssueManager', () => {
|
|||||||
'initialization',
|
'initialization',
|
||||||
'legacy_resource',
|
'legacy_resource',
|
||||||
'media_query',
|
'media_query',
|
||||||
'media_load',
|
'media_unavailable',
|
||||||
'view_incompatible',
|
'view_incompatible',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -84,15 +84,19 @@ describe('createIssueManager', () => {
|
|||||||
|
|
||||||
const manager = createIssueManager(api, createSubscriptionHealth());
|
const manager = createIssueManager(api, createSubscriptionHealth());
|
||||||
|
|
||||||
// Setting view starts the media_load timer (via the condition state
|
// Setting view starts the media_unavailable timer (via the condition state
|
||||||
// listener → evaluate → detectDynamic).
|
// listener → evaluate → detectDynamic).
|
||||||
stateManager.setState({ targetID: 'camera-1', view: 'live' });
|
stateManager.setState({ targetID: 'camera-1', view: 'live' });
|
||||||
expect(manager.getStateManager().getIssuePresence().has('media_load')).toBe(false);
|
expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
// After the timeout, the changeCallback fires evaluate which
|
// After the timeout, the changeCallback fires evaluate which
|
||||||
// updates the card element.
|
// updates the card element.
|
||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
|
|
||||||
expect(manager.getStateManager().getIssuePresence().has('media_load')).toBe(true);
|
expect(manager.getStateManager().getIssuePresence().has('media_unavailable')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
RETRY_EXPONENTIAL_BASE_SECONDS,
|
RETRY_EXPONENTIAL_BASE_SECONDS,
|
||||||
RETRY_EXPONENTIAL_MAX_SECONDS,
|
RETRY_EXPONENTIAL_MAX_SECONDS,
|
||||||
} from '../../../src/card-controller/issues/issue-manager';
|
} from '../../../src/card-controller/issues/issue-manager';
|
||||||
|
import { MediaUnavailableIssue } from '../../../src/card-controller/issues/issues/media-unavailable';
|
||||||
import { createRetryControl } from '../../../src/card-controller/issues/retry-control';
|
import { createRetryControl } from '../../../src/card-controller/issues/retry-control';
|
||||||
import type {
|
import type {
|
||||||
Issue,
|
Issue,
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
createCardAPI,
|
createCardAPI,
|
||||||
createConfig,
|
createConfig,
|
||||||
createHASS,
|
createHASS,
|
||||||
|
createMediaLoadedInfo,
|
||||||
flushPromises,
|
flushPromises,
|
||||||
} from '../../test-utils';
|
} from '../../test-utils';
|
||||||
|
|
||||||
@@ -74,7 +76,7 @@ const createRetriableSetup = (options?: {
|
|||||||
|
|
||||||
const manager = new IssueManager(api);
|
const manager = new IssueManager(api);
|
||||||
|
|
||||||
const issue = createIssue('media_load', {
|
const issue = createIssue('media_unavailable', {
|
||||||
hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
|
hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
|
||||||
needsRetry: vi.fn().mockReturnValue(true),
|
needsRetry: vi.fn().mockReturnValue(true),
|
||||||
retry: vi.fn().mockReturnValue(false),
|
retry: vi.fn().mockReturnValue(false),
|
||||||
@@ -110,6 +112,35 @@ describe('IssueManager', () => {
|
|||||||
expect(issue.detectDynamic).toBeCalled();
|
expect(issue.detectDynamic).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should keep a liveness-triggered error active while its media still reads as loaded', () => {
|
||||||
|
// Regression: a runtime liveness loss (stall / provider error) fires a
|
||||||
|
// trigger while the (now frozen) media is still loaded. The trigger's
|
||||||
|
// synchronous evaluate must not clear the just-set error via the stale
|
||||||
|
// loaded state.
|
||||||
|
const api = createCardAPI();
|
||||||
|
const conditionStateManager = new ConditionStateManager();
|
||||||
|
vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager);
|
||||||
|
|
||||||
|
const manager = new IssueManager(api);
|
||||||
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
manager.addIssue(issue);
|
||||||
|
|
||||||
|
conditionStateManager.setState({
|
||||||
|
view: 'live',
|
||||||
|
targetID: 'camera.office',
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera.office' }),
|
||||||
|
});
|
||||||
|
expect(issue.hasIssue()).toBe(false);
|
||||||
|
|
||||||
|
manager.trigger('media_unavailable', {
|
||||||
|
targetID: 'camera.office',
|
||||||
|
reason: 'stalled',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
expect(issue.needsRetry()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
describe('addIssue / getStateManager', () => {
|
describe('addIssue / getStateManager', () => {
|
||||||
it('should make added issues accessible via getManager', () => {
|
it('should make added issues accessible via getManager', () => {
|
||||||
const manager = new IssueManager(createCardAPI());
|
const manager = new IssueManager(createCardAPI());
|
||||||
@@ -240,7 +271,7 @@ describe('IssueManager', () => {
|
|||||||
|
|
||||||
// Start the timer via evaluate, then immediately retry.
|
// Start the timer via evaluate, then immediately retry.
|
||||||
manager.evaluate();
|
manager.evaluate();
|
||||||
manager.retry('media_load');
|
manager.retry('media_unavailable');
|
||||||
|
|
||||||
expect(issue.retry).toBeCalled();
|
expect(issue.retry).toBeCalled();
|
||||||
|
|
||||||
@@ -255,12 +286,12 @@ describe('IssueManager', () => {
|
|||||||
it('should force retry even when needsRetry is false', () => {
|
it('should force retry even when needsRetry is false', () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
const manager = new IssueManager(api);
|
const manager = new IssueManager(api);
|
||||||
const issue = createIssue('media_load', {
|
const issue = createIssue('media_unavailable', {
|
||||||
retry: vi.fn().mockReturnValue(false),
|
retry: vi.fn().mockReturnValue(false),
|
||||||
});
|
});
|
||||||
manager.addIssue(issue);
|
manager.addIssue(issue);
|
||||||
|
|
||||||
manager.retry('media_load', true);
|
manager.retry('media_unavailable', true);
|
||||||
|
|
||||||
expect(issue.retry).toBeCalled();
|
expect(issue.retry).toBeCalled();
|
||||||
});
|
});
|
||||||
@@ -370,12 +401,12 @@ describe('IssueManager', () => {
|
|||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
|
|
||||||
const manager = new IssueManager(api);
|
const manager = new IssueManager(api);
|
||||||
const issue = createIssue('media_load', {
|
const issue = createIssue('media_unavailable', {
|
||||||
hasIssue: vi.fn().mockReturnValue(true),
|
hasIssue: vi.fn().mockReturnValue(true),
|
||||||
getIssue: vi.fn().mockImplementation(() =>
|
getIssue: vi.fn().mockImplementation(() =>
|
||||||
createIssueDescription({
|
createIssueDescription({
|
||||||
notification: {
|
notification: {
|
||||||
controls: [createRetryControl('media_load')],
|
controls: [createRetryControl('media_unavailable')],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -454,7 +485,7 @@ describe('IssueManager', () => {
|
|||||||
|
|
||||||
const manager = new IssueManager(api);
|
const manager = new IssueManager(api);
|
||||||
|
|
||||||
const issue = createIssue('media_load', {
|
const issue = createIssue('media_unavailable', {
|
||||||
hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
|
hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
|
||||||
needsRetry: vi.fn().mockReturnValue(true),
|
needsRetry: vi.fn().mockReturnValue(true),
|
||||||
retry: vi.fn().mockReturnValue(false),
|
retry: vi.fn().mockReturnValue(false),
|
||||||
@@ -615,21 +646,31 @@ describe('IssueManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should cap the backoff at the max delay', () => {
|
it('should cap the backoff at the max delay', () => {
|
||||||
// Drive 5 pre-cap attempts (30, 60, 120, 240, 480 seconds), then assert
|
// Drive attempts through the exponential region (base, base*2, base*4,
|
||||||
// the 6th attempt clamps to MAX instead of the would-be 960.
|
// ...) until the delay reaches MAX, then assert the next attempt clamps to
|
||||||
|
// MAX instead of continuing to double, and stays there.
|
||||||
vi.spyOn(Math, 'random').mockReturnValue(1);
|
vi.spyOn(Math, 'random').mockReturnValue(1);
|
||||||
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
|
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
|
||||||
manager.evaluate();
|
manager.evaluate();
|
||||||
|
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 1 * 1000);
|
let attempts = 0;
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 1000);
|
for (
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 4 * 1000);
|
let delaySeconds = RETRY_EXPONENTIAL_BASE_SECONDS;
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 8 * 1000);
|
delaySeconds < RETRY_EXPONENTIAL_MAX_SECONDS;
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 16 * 1000);
|
delaySeconds *= 2
|
||||||
expect(issue.retry).toBeCalledTimes(5);
|
) {
|
||||||
|
vi.advanceTimersByTime(delaySeconds * 1000);
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
expect(issue.retry).toBeCalledTimes(attempts);
|
||||||
|
|
||||||
|
// The next attempt clamps to MAX instead of the would-be larger delay.
|
||||||
vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000);
|
vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000);
|
||||||
expect(issue.retry).toBeCalledTimes(6);
|
expect(issue.retry).toBeCalledTimes(attempts + 1);
|
||||||
|
|
||||||
|
// And it stays capped at MAX rather than growing further.
|
||||||
|
vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000);
|
||||||
|
expect(issue.retry).toBeCalledTimes(attempts + 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reset the attempt counter when the issue clears', () => {
|
it('should reset the attempt counter when the issue clears', () => {
|
||||||
@@ -834,7 +875,7 @@ describe('IssueManager', () => {
|
|||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
|
|
||||||
const manager = new IssueManager(api);
|
const manager = new IssueManager(api);
|
||||||
const issue = createIssue('media_load', { suspend: vi.fn() });
|
const issue = createIssue('media_unavailable', { suspend: vi.fn() });
|
||||||
manager.addIssue(issue);
|
manager.addIssue(issue);
|
||||||
|
|
||||||
manager.suspend();
|
manager.suspend();
|
||||||
|
|||||||
+196
-88
@@ -1,7 +1,8 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
import { MediaLoadIssue } from '../../../../src/card-controller/issues/issues/media-load';
|
import { MediaUnavailableIssue } from '../../../../src/card-controller/issues/issues/media-unavailable';
|
||||||
|
import type { MediaLoadedInfoChange } from '../../../../src/card-controller/media-info-manager';
|
||||||
import type { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal';
|
import type { InternalCallbackActionConfig } from '../../../../src/config/schema/actions/custom/internal';
|
||||||
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../../src/view/target-id';
|
import { IMAGE_VIEW_TARGET_ID_SENTINEL } from '../../../../src/view/target-id';
|
||||||
import type { View } from '../../../../src/view/view';
|
import type { View } from '../../../../src/view/view';
|
||||||
@@ -9,8 +10,32 @@ import { createCardAPI, createMediaLoadedInfo } from '../../../test-utils';
|
|||||||
|
|
||||||
const createAPI = () => createCardAPI();
|
const createAPI = () => createCardAPI();
|
||||||
|
|
||||||
|
// Deliver a media change to the listener the issue registered with the
|
||||||
|
// media-loaded manager.
|
||||||
|
const fireMediaChange = (
|
||||||
|
api: ReturnType<typeof createAPI>,
|
||||||
|
change: MediaLoadedInfoChange,
|
||||||
|
): void => {
|
||||||
|
vi.mocked(api.getMediaLoadedInfoManager().subscribe).mock.calls[0]?.[0]?.(change);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simulate a media (re)load for `targetID`. `cached` marks a reconnect replay
|
||||||
|
// (a re-dispatch of the last load, not an actual reload).
|
||||||
|
const fireMediaLoad = (
|
||||||
|
api: ReturnType<typeof createAPI>,
|
||||||
|
targetID: string,
|
||||||
|
cached = false,
|
||||||
|
): void => {
|
||||||
|
fireMediaChange(api, {
|
||||||
|
type: 'load',
|
||||||
|
targetID,
|
||||||
|
info: createMediaLoadedInfo({ targetID }),
|
||||||
|
cached,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
describe('MediaLoadIssue', () => {
|
describe('MediaUnavailableIssue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
@@ -20,8 +45,8 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should have correct key', () => {
|
it('should have correct key', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
expect(issue.key).toBe('media_load');
|
expect(issue.key).toBe('media_unavailable');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('detectDynamic', () => {
|
describe('detectDynamic', () => {
|
||||||
@@ -35,7 +60,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
['review' as const],
|
['review' as const],
|
||||||
])('should start timer when view is %s and not loaded', (view) => {
|
])('should start timer when view is %s and not loaded', (view) => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'target-1', view });
|
issue.detectDynamic({ targetID: 'target-1', view });
|
||||||
|
|
||||||
@@ -48,7 +73,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not start timer when targetID is null (no provider rendering)', () => {
|
it('should not start timer when targetID is null (no provider rendering)', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
// Media view but no targetID, e.g. viewer showing "No media to display"
|
// Media view but no targetID, e.g. viewer showing "No media to display"
|
||||||
// instead of mounting a provider.
|
// instead of mounting a provider.
|
||||||
@@ -60,7 +85,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should deactivate when targetID becomes null', () => {
|
it('should deactivate when targetID becomes null', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
@@ -72,7 +97,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not start timer when view is not a media view', () => {
|
it('should not start timer when view is not a media view', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.detectDynamic({ view: 'timeline' });
|
issue.detectDynamic({ view: 'timeline' });
|
||||||
|
|
||||||
@@ -82,7 +107,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not start timer when view is undefined', () => {
|
it('should not start timer when view is undefined', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.detectDynamic({});
|
issue.detectDynamic({});
|
||||||
|
|
||||||
@@ -92,7 +117,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not start timer when media is loaded', () => {
|
it('should not start timer when media is loaded', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.detectDynamic({ view: 'live', mediaLoadedInfo: createMediaLoadedInfo() });
|
issue.detectDynamic({ view: 'live', mediaLoadedInfo: createMediaLoadedInfo() });
|
||||||
|
|
||||||
@@ -102,7 +127,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should clear timeout when media loads', () => {
|
it('should clear timeout when media loads', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
@@ -115,7 +140,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should clear timeout when view changes to a non-media view', () => {
|
it('should clear timeout when view changes to a non-media view', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
@@ -128,7 +153,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should remain active across media views for the same target', () => {
|
it('should remain active across media views for the same target', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
@@ -140,7 +165,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should deactivate when target changes to non-errored target', () => {
|
it('should deactivate when target changes to non-errored target', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
@@ -157,10 +182,10 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should stay active when target changes to errored target', () => {
|
it('should stay active when target changes to errored target', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1' });
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
issue.trigger({ targetID: 'camera-2' });
|
issue.trigger({ targetID: 'camera-2', reason: 'stalled' });
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
@@ -171,7 +196,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should clear timed-out state when media loads', () => {
|
it('should clear timed-out state when media loads', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
@@ -183,7 +208,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
|
|
||||||
it('should restart timer when target changes', () => {
|
it('should restart timer when target changes', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||||
|
|
||||||
issue.detectDynamic({
|
issue.detectDynamic({
|
||||||
targetID: 'camera-1',
|
targetID: 'camera-1',
|
||||||
@@ -209,7 +234,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
|
|
||||||
it('should not restart timer for same target while running', () => {
|
it('should not restart timer for same target while running', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||||
|
|
||||||
issue.detectDynamic({
|
issue.detectDynamic({
|
||||||
targetID: 'camera-1',
|
targetID: 'camera-1',
|
||||||
@@ -231,7 +256,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
|
|
||||||
it('should not restart timer when targetID is undefined and matches', () => {
|
it('should not restart timer when targetID is undefined and matches', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
@@ -246,7 +271,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
|
|
||||||
it('should not restart timer if already timed out', () => {
|
it('should not restart timer if already timed out', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
@@ -261,9 +286,9 @@ describe('MediaLoadIssue', () => {
|
|||||||
|
|
||||||
describe('trigger', () => {
|
describe('trigger', () => {
|
||||||
it('should activate immediately when target has error and view is a media view', () => {
|
it('should activate immediately when target has error and view is a media view', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1' });
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
issue.detectDynamic({
|
issue.detectDynamic({
|
||||||
targetID: 'camera-1',
|
targetID: 'camera-1',
|
||||||
view: 'live',
|
view: 'live',
|
||||||
@@ -273,46 +298,97 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not activate with only a trigger', () => {
|
it('should not activate with only a trigger', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1' });
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
|
|
||||||
expect(issue.hasIssue()).toBe(false);
|
expect(issue.hasIssue()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear target error when media loads', () => {
|
it('should clear a target error on a genuine media load', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const api = createAPI();
|
||||||
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1' });
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
|
||||||
|
// A genuine (re)load for the target clears the error.
|
||||||
|
fireMediaLoad(api, 'camera-1');
|
||||||
|
|
||||||
|
// The error is gone, so this unloaded state falls back to the timer
|
||||||
|
// (would not activate until the timeout).
|
||||||
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
|
expect(issue.hasIssue()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep an errored target active while its media still reads as loaded', () => {
|
||||||
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
|
|
||||||
|
// The (now frozen) media still reads as loaded, but the existing loaded
|
||||||
|
// level must not clear the error -- only a genuine media load does. The
|
||||||
|
// loaded media is the frozen stream a liveness detector just condemned.
|
||||||
issue.detectDynamic({
|
issue.detectDynamic({
|
||||||
targetID: 'camera-1',
|
targetID: 'camera-1',
|
||||||
view: 'live',
|
view: 'live',
|
||||||
|
mediaLoadedInfo: createMediaLoadedInfo({ targetID: 'camera-1' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
// Media loaded clears the error for this target.
|
it('should not clear a target error when a different target loads', () => {
|
||||||
issue.detectDynamic({
|
const api = createAPI();
|
||||||
targetID: 'camera-1',
|
const issue = new MediaUnavailableIssue(api);
|
||||||
view: 'live',
|
|
||||||
mediaLoadedInfo: createMediaLoadedInfo(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Target error was cleared by the successful load, so this unloaded state
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
// falls back to the timer (issue would not activate until after the
|
|
||||||
// timer is reached).
|
|
||||||
issue.detectDynamic({
|
|
||||||
targetID: 'camera-1',
|
|
||||||
view: 'live',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(issue.hasIssue()).toBe(false);
|
// A load for a different target must not clear camera-1's error.
|
||||||
|
fireMediaLoad(api, 'camera-2');
|
||||||
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
|
|
||||||
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not clear a target error on a reconnect replay', () => {
|
||||||
|
const api = createAPI();
|
||||||
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
|
|
||||||
|
fireMediaLoad(
|
||||||
|
api,
|
||||||
|
'camera-1',
|
||||||
|
|
||||||
|
// A cached replay (reconnect re-dispatch) did not actually reload the
|
||||||
|
// media, so it must not clear the error.
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
|
|
||||||
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not clear a target error on unload or select changes', () => {
|
||||||
|
const api = createAPI();
|
||||||
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
|
|
||||||
|
// Only a genuine load clears; unload / select changes are irrelevant.
|
||||||
|
fireMediaChange(api, { type: 'unload', targetID: 'camera-1' });
|
||||||
|
fireMediaChange(api, { type: 'select', targetID: 'camera-1' });
|
||||||
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
|
|
||||||
|
expect(issue.hasIssue()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not activate for a different target', () => {
|
it('should not activate for a different target', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1' });
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
issue.detectDynamic({
|
issue.detectDynamic({
|
||||||
targetID: 'camera-2',
|
targetID: 'camera-2',
|
||||||
view: 'live',
|
view: 'live',
|
||||||
@@ -325,7 +401,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
|
|
||||||
describe('getNotification', () => {
|
describe('getNotification', () => {
|
||||||
it('should return notification regardless of active state', () => {
|
it('should return notification regardless of active state', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
const notification = issue.getNotification();
|
const notification = issue.getNotification();
|
||||||
expect(notification).toEqual(
|
expect(notification).toEqual(
|
||||||
@@ -340,25 +416,18 @@ describe('MediaLoadIssue', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include metadata for errored targets', () => {
|
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
|
||||||
issue.trigger({ targetID: 'camera.office' });
|
|
||||||
|
|
||||||
const notification = issue.getNotification();
|
|
||||||
expect(notification.metadata).toEqual([
|
|
||||||
expect.objectContaining({ text: 'camera.office', icon: 'mdi:cctv' }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include the pending timer target in metadata', () => {
|
it('should include the pending timer target in metadata', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
// Start a load timer (no explicit error yet, just slow-loading).
|
// Start a load timer (no explicit error yet, just slow-loading).
|
||||||
issue.detectDynamic({ targetID: 'camera.garden', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera.garden', view: 'live' });
|
||||||
|
|
||||||
const notification = issue.getNotification();
|
const notification = issue.getNotification();
|
||||||
expect(notification.metadata).toEqual([
|
expect(notification.metadata).toEqual([
|
||||||
expect.objectContaining({ text: 'camera.garden', icon: 'mdi:cctv' }),
|
expect.objectContaining({
|
||||||
|
text: 'camera.garden: Media not loading',
|
||||||
|
icon: 'mdi:progress-helper',
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -368,28 +437,43 @@ describe('MediaLoadIssue', () => {
|
|||||||
title: 'Office',
|
title: 'Office',
|
||||||
icon: { icon: 'mdi:cctv' },
|
icon: { icon: 'mdi:cctv' },
|
||||||
});
|
});
|
||||||
const issue = new MediaLoadIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
issue.trigger({ targetID: 'camera.office' });
|
issue.trigger({ targetID: 'camera.office', reason: 'stalled' });
|
||||||
|
|
||||||
const notification = issue.getNotification();
|
const notification = issue.getNotification();
|
||||||
expect(notification.metadata).toEqual([
|
expect(notification.metadata).toEqual([
|
||||||
expect.objectContaining({ text: 'Office' }),
|
expect.objectContaining({ text: 'Office: Stream stalled' }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use localized label and image icon for the image-view sentinel', () => {
|
it('should use localized label and image icon for the image-view sentinel', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL });
|
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL, reason: 'stalled' });
|
||||||
|
|
||||||
const notification = issue.getNotification();
|
const notification = issue.getNotification();
|
||||||
expect(notification.metadata).toEqual([
|
expect(notification.metadata).toEqual([
|
||||||
expect.objectContaining({ text: 'Image', icon: 'mdi:image' }),
|
expect.objectContaining({ text: 'Image: Stream stalled', icon: 'mdi:image' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['entity_unavailable' as const, 'Camera entity unavailable', 'mdi:cctv-off'],
|
||||||
|
['not_loading' as const, 'Media not loading', 'mdi:progress-helper'],
|
||||||
|
['playback_error' as const, 'Playback error', 'mdi:alert-circle'],
|
||||||
|
['stalled' as const, 'Stream stalled', 'mdi:motion-pause'],
|
||||||
|
])('should give the %s cause its own text and icon', (reason, text, icon) => {
|
||||||
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
issue.trigger({ targetID: 'camera.office', reason });
|
||||||
|
|
||||||
|
const notification = issue.getNotification();
|
||||||
|
expect(notification.metadata).toEqual([
|
||||||
|
expect.objectContaining({ text: `camera.office: ${text}`, icon }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include a retry control with wired callback', async () => {
|
it('should include a retry control with wired callback', async () => {
|
||||||
const api = createCardAPI();
|
const api = createCardAPI();
|
||||||
const issue = new MediaLoadIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
const control = issue.getNotification().controls?.[0];
|
const control = issue.getNotification().controls?.[0];
|
||||||
expect(control).toMatchObject({ icon: 'mdi:refresh', dismiss: true });
|
expect(control).toMatchObject({ icon: 'mdi:refresh', dismiss: true });
|
||||||
@@ -397,13 +481,13 @@ describe('MediaLoadIssue', () => {
|
|||||||
const tapAction = control?.actions?.tap_action as InternalCallbackActionConfig;
|
const tapAction = control?.actions?.tap_action as InternalCallbackActionConfig;
|
||||||
await tapAction.callback(api);
|
await tapAction.callback(api);
|
||||||
|
|
||||||
expect(api.getIssueManager().retry).toBeCalledWith('media_load', true);
|
expect(api.getIssueManager().retry).toBeCalledWith('media_unavailable', true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getIssue', () => {
|
describe('getIssue', () => {
|
||||||
it('should return result when timed out', () => {
|
it('should return result when timed out', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
@@ -423,7 +507,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return null when not timed out', () => {
|
it('should return null when not timed out', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
expect(issue.getIssue()).toBeNull();
|
expect(issue.getIssue()).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -431,7 +515,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
|
|
||||||
describe('needsRetry', () => {
|
describe('needsRetry', () => {
|
||||||
it('should return true when issue is active', () => {
|
it('should return true when issue is active', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
@@ -440,7 +524,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false when issue is not active', () => {
|
it('should return false when issue is not active', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
expect(issue.needsRetry()).toBe(false);
|
expect(issue.needsRetry()).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -449,7 +533,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
describe('retry', () => {
|
describe('retry', () => {
|
||||||
it('should keep issue active after retry so error stays visible', () => {
|
it('should keep issue active after retry so error stays visible', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(10000);
|
vi.advanceTimersByTime(10000);
|
||||||
@@ -464,7 +548,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
|
|
||||||
it('should return false when no targets have errors', () => {
|
it('should return false when no targets have errors', () => {
|
||||||
const api = createAPI();
|
const api = createAPI();
|
||||||
const issue = new MediaLoadIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
expect(issue.retry()).toBe(false);
|
expect(issue.retry()).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -472,10 +556,10 @@ describe('MediaLoadIssue', () => {
|
|||||||
it('should bump mediaEpoch for targets with errors and call setViewWithMergedContext', () => {
|
it('should bump mediaEpoch for targets with errors and call setViewWithMergedContext', () => {
|
||||||
const api = createAPI();
|
const api = createAPI();
|
||||||
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
||||||
const issue = new MediaLoadIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1' });
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
issue.trigger({ targetID: 'media-1' });
|
issue.trigger({ targetID: 'media-1', reason: 'stalled' });
|
||||||
|
|
||||||
const result = issue.retry();
|
const result = issue.retry();
|
||||||
|
|
||||||
@@ -488,9 +572,9 @@ describe('MediaLoadIssue', () => {
|
|||||||
it('should bump mediaEpoch for the image-view sentinel', () => {
|
it('should bump mediaEpoch for the image-view sentinel', () => {
|
||||||
const api = createAPI();
|
const api = createAPI();
|
||||||
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
||||||
const issue = new MediaLoadIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL });
|
issue.trigger({ targetID: IMAGE_VIEW_TARGET_ID_SENTINEL, reason: 'stalled' });
|
||||||
|
|
||||||
const result = issue.retry();
|
const result = issue.retry();
|
||||||
|
|
||||||
@@ -505,9 +589,9 @@ describe('MediaLoadIssue', () => {
|
|||||||
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
vi.mocked(api.getViewManager().getView).mockReturnValue(
|
||||||
mock<View>({ context: { mediaEpoch: { 'camera-1': 5, 'camera-2': 3 } } }),
|
mock<View>({ context: { mediaEpoch: { 'camera-1': 5, 'camera-2': 3 } } }),
|
||||||
);
|
);
|
||||||
const issue = new MediaLoadIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1' });
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
|
|
||||||
const result = issue.retry();
|
const result = issue.retry();
|
||||||
|
|
||||||
@@ -520,7 +604,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
it('should include pending timer target in retry', () => {
|
it('should include pending timer target in retry', () => {
|
||||||
const api = createAPI();
|
const api = createAPI();
|
||||||
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
||||||
const issue = new MediaLoadIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
// Start the timer for camera-1 (not yet timed out).
|
// Start the timer for camera-1 (not yet timed out).
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
@@ -535,17 +619,17 @@ describe('MediaLoadIssue', () => {
|
|||||||
it('should keep errored targets and issue state after retry', () => {
|
it('should keep errored targets and issue state after retry', () => {
|
||||||
const api = createAPI();
|
const api = createAPI();
|
||||||
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
vi.mocked(api.getViewManager().getView).mockReturnValue(mock<View>());
|
||||||
const issue = new MediaLoadIssue(api);
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
issue.trigger({ targetID: 'camera-1' });
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
|
|
||||||
issue.retry();
|
issue.retry();
|
||||||
|
|
||||||
// After retry, the issue stays active and the errored target is preserved
|
// After retry, the issue stays active and the errored target is preserved
|
||||||
// -- no new 10s grace period. If media:loaded fires, the existing
|
// -- no new 10s grace period. A genuine media load would clear everything
|
||||||
// _handleMediaLoaded path will clear everything.
|
// (_onMediaLoad drops the errored target).
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
expect(issue.hasIssue()).toBe(true);
|
expect(issue.hasIssue()).toBe(true);
|
||||||
@@ -555,7 +639,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
describe('reset', () => {
|
describe('reset', () => {
|
||||||
it('should stop timer', () => {
|
it('should stop timer', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
issue.reset();
|
issue.reset();
|
||||||
@@ -567,10 +651,34 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('media loads', () => {
|
||||||
|
it('should notify onChange when a media load clears an error', () => {
|
||||||
|
const api = createAPI();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const issue = new MediaUnavailableIssue(api, onChange);
|
||||||
|
|
||||||
|
issue.trigger({ targetID: 'camera-1', reason: 'stalled' });
|
||||||
|
fireMediaLoad(api, 'camera-1');
|
||||||
|
|
||||||
|
expect(onChange).toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unsubscribe from media loads on destroy', () => {
|
||||||
|
const api = createAPI();
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
vi.mocked(api.getMediaLoadedInfoManager().subscribe).mockReturnValue(unsubscribe);
|
||||||
|
const issue = new MediaUnavailableIssue(api);
|
||||||
|
|
||||||
|
issue.destroy();
|
||||||
|
|
||||||
|
expect(unsubscribe).toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('suspend', () => {
|
describe('suspend', () => {
|
||||||
it('should stop the pending-load timer so it cannot mature offscreen', () => {
|
it('should stop the pending-load timer so it cannot mature offscreen', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||||
|
|
||||||
// Enter loading state. Timer arms but has not yet fired.
|
// Enter loading state. Timer arms but has not yet fired.
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
@@ -588,7 +696,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should preserve an already-active issue across suspend', () => {
|
it('should preserve an already-active issue across suspend', () => {
|
||||||
const issue = new MediaLoadIssue(createAPI());
|
const issue = new MediaUnavailableIssue(createAPI());
|
||||||
|
|
||||||
// Issue activates (timeout fires).
|
// Issue activates (timeout fires).
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
@@ -603,7 +711,7 @@ describe('MediaLoadIssue', () => {
|
|||||||
|
|
||||||
it('should rearm a fresh timer window on resume via detectDynamic', () => {
|
it('should rearm a fresh timer window on resume via detectDynamic', () => {
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const issue = new MediaLoadIssue(createAPI(), onChange);
|
const issue = new MediaUnavailableIssue(createAPI(), onChange);
|
||||||
|
|
||||||
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
issue.detectDynamic({ targetID: 'camera-1', view: 'live' });
|
||||||
vi.advanceTimersByTime(5000);
|
vi.advanceTimersByTime(5000);
|
||||||
@@ -7,7 +7,7 @@ import { createCardAPI } from '../../test-utils';
|
|||||||
|
|
||||||
describe('createRetryControl', () => {
|
describe('createRetryControl', () => {
|
||||||
it('should return a control with expected icon, tooltip, and dismiss', () => {
|
it('should return a control with expected icon, tooltip, and dismiss', () => {
|
||||||
const control = createRetryControl('media_load');
|
const control = createRetryControl('media_unavailable');
|
||||||
|
|
||||||
expect(control.icon).toBe('mdi:refresh');
|
expect(control.icon).toBe('mdi:refresh');
|
||||||
expect(control.tooltip).toBe('Retry');
|
expect(control.tooltip).toBe('Retry');
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe('IssueStateManager', () => {
|
|||||||
|
|
||||||
mockConfigUpgrade = mock<Issue>({ key: 'config_upgrade' });
|
mockConfigUpgrade = mock<Issue>({ key: 'config_upgrade' });
|
||||||
mockLegacyResource = mock<Issue>({ key: 'legacy_resource' });
|
mockLegacyResource = mock<Issue>({ key: 'legacy_resource' });
|
||||||
mockMediaLoad = mock<Issue>({ key: 'media_load' });
|
mockMediaLoad = mock<Issue>({ key: 'media_unavailable' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should register all provided issues on construction', () => {
|
it('should register all provided issues on construction', () => {
|
||||||
@@ -52,7 +52,7 @@ describe('IssueStateManager', () => {
|
|||||||
|
|
||||||
expect(presence.has('config_upgrade')).toBe(false);
|
expect(presence.has('config_upgrade')).toBe(false);
|
||||||
expect(presence.has('legacy_resource')).toBe(false);
|
expect(presence.has('legacy_resource')).toBe(false);
|
||||||
expect(presence.has('media_load')).toBe(false);
|
expect(presence.has('media_unavailable')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('detectStatic', () => {
|
describe('detectStatic', () => {
|
||||||
@@ -97,10 +97,13 @@ describe('IssueStateManager', () => {
|
|||||||
it('should call trigger on the matching issue', () => {
|
it('should call trigger on the matching issue', () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
|
|
||||||
manager.trigger('media_load', { targetID: 'cam1' });
|
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
|
||||||
|
|
||||||
assert(mockMediaLoad.trigger);
|
assert(mockMediaLoad.trigger);
|
||||||
expect(mockMediaLoad.trigger).toBeCalledWith({ targetID: 'cam1' });
|
expect(mockMediaLoad.trigger).toBeCalledWith({
|
||||||
|
targetID: 'cam1',
|
||||||
|
reason: 'stalled',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should do nothing for unknown key', () => {
|
it('should do nothing for unknown key', () => {
|
||||||
@@ -210,7 +213,7 @@ describe('IssueStateManager', () => {
|
|||||||
|
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
|
|
||||||
expect(manager.getNotification('media_load')).toBe(notification);
|
expect(manager.getNotification('media_unavailable')).toBe(notification);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null for unknown key', () => {
|
it('should return null for unknown key', () => {
|
||||||
@@ -297,7 +300,7 @@ describe('IssueStateManager', () => {
|
|||||||
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(true);
|
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(true);
|
||||||
vi.mocked(mockMediaLoad.retry).mockReturnValue(false);
|
vi.mocked(mockMediaLoad.retry).mockReturnValue(false);
|
||||||
|
|
||||||
createManager().retry('media_load');
|
createManager().retry('media_unavailable');
|
||||||
|
|
||||||
expect(mockMediaLoad.retry).toBeCalled();
|
expect(mockMediaLoad.retry).toBeCalled();
|
||||||
});
|
});
|
||||||
@@ -306,7 +309,7 @@ describe('IssueStateManager', () => {
|
|||||||
assert(mockMediaLoad.needsRetry);
|
assert(mockMediaLoad.needsRetry);
|
||||||
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(false);
|
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(false);
|
||||||
|
|
||||||
createManager().retry('media_load');
|
createManager().retry('media_unavailable');
|
||||||
|
|
||||||
assert(mockMediaLoad.retry);
|
assert(mockMediaLoad.retry);
|
||||||
expect(mockMediaLoad.retry).not.toBeCalled();
|
expect(mockMediaLoad.retry).not.toBeCalled();
|
||||||
@@ -318,7 +321,7 @@ describe('IssueStateManager', () => {
|
|||||||
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(false);
|
vi.mocked(mockMediaLoad.needsRetry).mockReturnValue(false);
|
||||||
vi.mocked(mockMediaLoad.retry).mockReturnValue(false);
|
vi.mocked(mockMediaLoad.retry).mockReturnValue(false);
|
||||||
|
|
||||||
createManager().retry('media_load', true);
|
createManager().retry('media_unavailable', true);
|
||||||
|
|
||||||
expect(mockMediaLoad.retry).toBeCalled();
|
expect(mockMediaLoad.retry).toBeCalled();
|
||||||
});
|
});
|
||||||
@@ -374,7 +377,7 @@ describe('IssueStateManager', () => {
|
|||||||
manager.detectDynamic({ view: 'live' });
|
manager.detectDynamic({ view: 'live' });
|
||||||
|
|
||||||
expect(spy).toBeCalledWith(
|
expect(spy).toBeCalledWith(
|
||||||
'Advanced Camera Card [issue=media_load]: Stream issue',
|
'Advanced Camera Card [issue=media_unavailable]: Stream issue',
|
||||||
);
|
);
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
@@ -387,9 +390,11 @@ describe('IssueStateManager', () => {
|
|||||||
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(result);
|
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(result);
|
||||||
|
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
manager.trigger('media_load', { targetID: 'cam1' });
|
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
|
||||||
|
|
||||||
expect(spy).toBeCalledWith('Advanced Camera Card [issue=media_load]: Triggered');
|
expect(spy).toBeCalledWith(
|
||||||
|
'Advanced Camera Card [issue=media_unavailable]: Triggered',
|
||||||
|
);
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -398,7 +403,7 @@ describe('IssueStateManager', () => {
|
|||||||
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(null);
|
vi.mocked(mockMediaLoad.getIssue).mockReturnValue(null);
|
||||||
|
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
manager.trigger('media_load', { targetID: 'cam1' });
|
manager.trigger('media_unavailable', { targetID: 'cam1', reason: 'stalled' });
|
||||||
|
|
||||||
expect(spy).not.toBeCalled();
|
expect(spy).not.toBeCalled();
|
||||||
spy.mockRestore();
|
spy.mockRestore();
|
||||||
@@ -503,7 +508,7 @@ describe('IssueStateManager', () => {
|
|||||||
|
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
manager.detectDynamic({ view: 'live' });
|
manager.detectDynamic({ view: 'live' });
|
||||||
manager.reset('media_load');
|
manager.reset('media_unavailable');
|
||||||
// After reset, the issue reports cleared on next detect, releasing the
|
// After reset, the issue reports cleared on next detect, releasing the
|
||||||
// dedupe.
|
// dedupe.
|
||||||
manager.detectDynamic({ view: 'live' });
|
manager.detectDynamic({ view: 'live' });
|
||||||
@@ -518,7 +523,7 @@ describe('IssueStateManager', () => {
|
|||||||
describe('reset', () => {
|
describe('reset', () => {
|
||||||
it('should reset a specific issue by key', () => {
|
it('should reset a specific issue by key', () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
manager.reset('media_load');
|
manager.reset('media_unavailable');
|
||||||
|
|
||||||
assert(mockMediaLoad.reset);
|
assert(mockMediaLoad.reset);
|
||||||
expect(mockMediaLoad.reset).toBeCalled();
|
expect(mockMediaLoad.reset).toBeCalled();
|
||||||
|
|||||||
@@ -360,4 +360,116 @@ describe('MediaLoadedInfoManager', () => {
|
|||||||
expect(manager.getLastKnown()).toBeNull();
|
expect(manager.getLastKnown()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('subscribe', () => {
|
||||||
|
it('should notify of a fresh load, even for a non-selected target', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new MediaLoadedInfoManager(api);
|
||||||
|
const owner = document.createElement('div');
|
||||||
|
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
manager.setSelected('target-2');
|
||||||
|
manager.subscribe(listener);
|
||||||
|
manager.set(info, owner);
|
||||||
|
|
||||||
|
expect(listener).toBeCalledWith({
|
||||||
|
type: 'load',
|
||||||
|
targetID: 'target-1',
|
||||||
|
info,
|
||||||
|
cached: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mark a cached load', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new MediaLoadedInfoManager(api);
|
||||||
|
const owner = document.createElement('div');
|
||||||
|
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
manager.subscribe(listener);
|
||||||
|
manager.set(info, owner, true);
|
||||||
|
|
||||||
|
expect(listener).toBeCalledWith({
|
||||||
|
type: 'load',
|
||||||
|
targetID: 'target-1',
|
||||||
|
info,
|
||||||
|
cached: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify of selection changes', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new MediaLoadedInfoManager(api);
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
manager.subscribe(listener);
|
||||||
|
manager.setSelected('target-1');
|
||||||
|
manager.setSelected(null);
|
||||||
|
|
||||||
|
expect(listener).toBeCalledWith({ type: 'select', targetID: 'target-1' });
|
||||||
|
expect(listener).toBeCalledWith({ type: 'select', targetID: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify of an unload when a target is retired', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new MediaLoadedInfoManager(api);
|
||||||
|
const source = document.createElement('div');
|
||||||
|
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||||
|
const ac = new AbortController();
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
manager.subscribe(listener);
|
||||||
|
manager.handleLoadEvent(
|
||||||
|
createMediaLoadedInfoEvent({ source, info, signal: ac.signal }),
|
||||||
|
);
|
||||||
|
ac.abort();
|
||||||
|
|
||||||
|
expect(listener).toBeCalledWith({ type: 'unload', targetID: 'target-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify of an unload for each active target on clear', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new MediaLoadedInfoManager(api);
|
||||||
|
const owner = document.createElement('div');
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
manager.set(createMediaLoadedInfo({ targetID: 'target-1' }), owner);
|
||||||
|
manager.set(createMediaLoadedInfo({ targetID: 'target-2' }), owner);
|
||||||
|
manager.subscribe(listener);
|
||||||
|
manager.clear();
|
||||||
|
|
||||||
|
expect(listener).toBeCalledWith({ type: 'unload', targetID: 'target-1' });
|
||||||
|
expect(listener).toBeCalledWith({ type: 'unload', targetID: 'target-2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify of unloads and a deselect on initialize', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new MediaLoadedInfoManager(api);
|
||||||
|
const owner = document.createElement('div');
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
manager.setSelected('target-1');
|
||||||
|
manager.set(createMediaLoadedInfo({ targetID: 'target-1' }), owner);
|
||||||
|
manager.subscribe(listener);
|
||||||
|
manager.initialize();
|
||||||
|
|
||||||
|
expect(listener).toBeCalledWith({ type: 'unload', targetID: 'target-1' });
|
||||||
|
expect(listener).toBeCalledWith({ type: 'select', targetID: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop notifying after unsubscribe', () => {
|
||||||
|
const api = createCardAPI();
|
||||||
|
const manager = new MediaLoadedInfoManager(api);
|
||||||
|
const owner = document.createElement('div');
|
||||||
|
const info = createMediaLoadedInfo({ targetID: 'target-1' });
|
||||||
|
const listener = vi.fn();
|
||||||
|
|
||||||
|
manager.subscribe(listener)();
|
||||||
|
manager.set(info, owner);
|
||||||
|
|
||||||
|
expect(listener).not.toBeCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -732,7 +732,7 @@ describe('should initialize', () => {
|
|||||||
viewFactory: viewFactory,
|
viewFactory: viewFactory,
|
||||||
});
|
});
|
||||||
|
|
||||||
await manager.initialize();
|
expect(await manager.initialize()).toBe(true);
|
||||||
|
|
||||||
expect(manager.getView()).toBe(view);
|
expect(manager.getView()).toBe(view);
|
||||||
});
|
});
|
||||||
@@ -745,7 +745,23 @@ describe('should initialize', () => {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
await manager.initialize();
|
expect(await manager.initialize()).toBe(true);
|
||||||
|
|
||||||
|
expect(manager.hasView()).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report failure when no view could be set', async () => {
|
||||||
|
const viewFactory = mock<ViewFactory>();
|
||||||
|
viewFactory.getViewDefault.mockReturnValue(createView());
|
||||||
|
|
||||||
|
// Cameras not being initialized causes the default view set to decline to
|
||||||
|
// run, so initialization must report failure to allow a later retry.
|
||||||
|
const api = createInitializedCardAPI(false);
|
||||||
|
const manager = new ViewManager(api, {
|
||||||
|
viewFactory: viewFactory,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await manager.initialize()).toBe(false);
|
||||||
|
|
||||||
expect(manager.hasView()).toBeFalsy();
|
expect(manager.hasView()).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import type { StateWatcherSubscriptionInterface } from '../../../../../src/card-controller/hass/state-watcher';
|
||||||
|
import {
|
||||||
|
EntityAvailabilityDetector,
|
||||||
|
LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS,
|
||||||
|
} from '../../../../../src/components-lib/live/liveness/detectors/entity-availability';
|
||||||
|
import type { HomeAssistant } from '../../../../../src/ha/types';
|
||||||
|
import {
|
||||||
|
callStateWatcherCallback,
|
||||||
|
createHASS,
|
||||||
|
createStateEntity,
|
||||||
|
} from '../../../../test-utils';
|
||||||
|
|
||||||
|
const ENTITY = 'camera.office';
|
||||||
|
const GRACE_MS = LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 1000;
|
||||||
|
|
||||||
|
const setup = (options?: {
|
||||||
|
alwaysError?: boolean;
|
||||||
|
entity?: string | null;
|
||||||
|
initialState?: string;
|
||||||
|
}) => {
|
||||||
|
const entity = options?.entity === undefined ? ENTITY : options.entity;
|
||||||
|
let currentEntity = entity;
|
||||||
|
const hass: HomeAssistant = createHASS(
|
||||||
|
entity
|
||||||
|
? { [entity]: createStateEntity({ state: options?.initialState ?? 'idle' }) }
|
||||||
|
: {},
|
||||||
|
);
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
stateWatcher.subscribe.mockReturnValue(true);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
const detector = new EntityAvailabilityDetector({
|
||||||
|
getHASS: () => hass,
|
||||||
|
getStateWatcher: () => stateWatcher,
|
||||||
|
getCameraEntity: () => currentEntity,
|
||||||
|
isAlwaysError: () => options?.alwaysError ?? false,
|
||||||
|
onChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setEntityState = (state: string): void => {
|
||||||
|
if (entity) {
|
||||||
|
hass.states[entity] = createStateEntity({ state });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const setCameraEntity = (value: string | null): void => {
|
||||||
|
currentEntity = value;
|
||||||
|
};
|
||||||
|
const fireStateChange = (state: string): void =>
|
||||||
|
callStateWatcherCallback(stateWatcher, {
|
||||||
|
entityID: entity ?? ENTITY,
|
||||||
|
newState: createStateEntity({ state }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
detector,
|
||||||
|
onChange,
|
||||||
|
stateWatcher,
|
||||||
|
setEntityState,
|
||||||
|
setCameraEntity,
|
||||||
|
fireStateChange,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('EntityAvailabilityDetector', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should subscribe to the camera entity and start unknown', () => {
|
||||||
|
const { detector, stateWatcher, onChange } = setup();
|
||||||
|
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
expect(stateWatcher.subscribe).toHaveBeenCalledWith(expect.any(Function), [ENTITY]);
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not subscribe when the camera has no entity', () => {
|
||||||
|
const { detector, stateWatcher } = setup({ entity: null });
|
||||||
|
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
expect(stateWatcher.subscribe).not.toHaveBeenCalled();
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report not live after the grace window when the entity stays unavailable', () => {
|
||||||
|
const { detector, onChange, fireStateChange } = setup();
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
|
||||||
|
// Still live during the grace window.
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
vi.advanceTimersByTime(GRACE_MS);
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'indirect',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'entity_unavailable',
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should tolerate an unavailable blip shorter than the grace window', () => {
|
||||||
|
const { detector, onChange, fireStateChange } = setup();
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
vi.advanceTimersByTime(GRACE_MS - 1000);
|
||||||
|
fireStateChange('streaming');
|
||||||
|
vi.advanceTimersByTime(GRACE_MS);
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report not live immediately when always_error is set', () => {
|
||||||
|
const { detector, onChange, fireStateChange } = setup({
|
||||||
|
alwaysError: true,
|
||||||
|
});
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'hard',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'entity_unavailable',
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should act on the state from the event, not a lagging wrapper hass', () => {
|
||||||
|
const { detector, setEntityState, fireStateChange } = setup({ alwaysError: true });
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
// The wrapper's hass (what getHASS reads) has not yet propagated the change,
|
||||||
|
// so it still reports the pre-change available state, while the event carries
|
||||||
|
// the fresh unavailable state. The detector must act on the event.
|
||||||
|
setEntityState('streaming');
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'hard',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'entity_unavailable',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report live again when the entity returns', () => {
|
||||||
|
const { detector, onChange, fireStateChange } = setup();
|
||||||
|
detector.subscribe();
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
vi.advanceTimersByTime(GRACE_MS);
|
||||||
|
expect(detector.getVerdict().state).toBe('not_live');
|
||||||
|
onChange.mockClear();
|
||||||
|
|
||||||
|
fireStateChange('streaming');
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retain the verdict on unsubscribe and stop watching', () => {
|
||||||
|
const { detector, stateWatcher, fireStateChange } = setup();
|
||||||
|
detector.subscribe();
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
vi.advanceTimersByTime(GRACE_MS);
|
||||||
|
|
||||||
|
detector.unsubscribe();
|
||||||
|
|
||||||
|
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Verdict retained so a reconnect resumes where it left off.
|
||||||
|
expect(detector.getVerdict()).toEqual({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'indirect',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'entity_unavailable',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel the pending grace timer on unsubscribe', () => {
|
||||||
|
const { detector, onChange, fireStateChange } = setup();
|
||||||
|
detector.subscribe();
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
|
||||||
|
detector.unsubscribe();
|
||||||
|
vi.advanceTimersByTime(GRACE_MS);
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should discard the verdict on reset', () => {
|
||||||
|
const { detector, setEntityState, fireStateChange } = setup();
|
||||||
|
detector.subscribe();
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
vi.advanceTimersByTime(GRACE_MS);
|
||||||
|
expect(detector.getVerdict().state).toBe('not_live');
|
||||||
|
|
||||||
|
// Re-point at the (now available) entity from a fresh state.
|
||||||
|
setEntityState('streaming');
|
||||||
|
detector.reset();
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing on reset before subscribe', () => {
|
||||||
|
const { detector, stateWatcher } = setup();
|
||||||
|
|
||||||
|
detector.reset();
|
||||||
|
|
||||||
|
expect(stateWatcher.subscribe).not.toHaveBeenCalled();
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop the subscription when the camera entity is removed', () => {
|
||||||
|
const { detector, stateWatcher, setCameraEntity } = setup();
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
setCameraEntity(null);
|
||||||
|
detector.reset();
|
||||||
|
|
||||||
|
expect(stateWatcher.unsubscribe).toHaveBeenCalled();
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not restart the grace timer while it is already running', () => {
|
||||||
|
const { detector, onChange, fireStateChange } = setup();
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
|
||||||
|
// Still unavailable, grace timer already running
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
vi.advanceTimersByTime(GRACE_MS);
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'indirect',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'entity_unavailable',
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stay not live on further unavailable events after the grace window', () => {
|
||||||
|
const { detector, onChange, fireStateChange } = setup();
|
||||||
|
detector.subscribe();
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
vi.advanceTimersByTime(GRACE_MS);
|
||||||
|
onChange.mockClear();
|
||||||
|
|
||||||
|
// Still unavailable, verdict already not live
|
||||||
|
fireStateChange('unavailable');
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'indirect',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'entity_unavailable',
|
||||||
|
});
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import { MediaPlayerLivenessDetector } from '../../../../../src/components-lib/live/liveness/detectors/media-player-liveness';
|
||||||
|
import type { LivenessCallback, MediaPlayerController } from '../../../../../src/types';
|
||||||
|
import {
|
||||||
|
callIntersectionHandler,
|
||||||
|
createMediaLoadedInfo,
|
||||||
|
createMediaLoadedInfoEvent,
|
||||||
|
IntersectionObserverMock,
|
||||||
|
} from '../../../../test-utils';
|
||||||
|
|
||||||
|
const createPlayer = (): {
|
||||||
|
player: MediaPlayerController;
|
||||||
|
unsubscribe: ReturnType<typeof vi.fn>;
|
||||||
|
fireMediaPlayerLiveness: (isLive: boolean) => void;
|
||||||
|
} => {
|
||||||
|
const player = mock<MediaPlayerController>();
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
let captured: LivenessCallback | null = null;
|
||||||
|
player.subscribeLiveness = vi.fn((callback: LivenessCallback) => {
|
||||||
|
captured = callback;
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
player,
|
||||||
|
unsubscribe,
|
||||||
|
fireMediaPlayerLiveness: (isLive: boolean) => captured?.(isLive),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = () => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.append(host);
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const detector = new MediaPlayerLivenessDetector(host, onChange);
|
||||||
|
|
||||||
|
const loadMedia = (player?: MediaPlayerController, signal?: AbortSignal): void => {
|
||||||
|
host.dispatchEvent(
|
||||||
|
createMediaLoadedInfoEvent({
|
||||||
|
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { host, onChange, detector, loadMedia };
|
||||||
|
};
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('MediaPlayerLivenessDetector', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
|
||||||
|
vi.spyOn(global.document, 'addEventListener');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
value: 'visible',
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should start unknown', () => {
|
||||||
|
const { detector } = setup();
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should watch liveness only once both visible and media are loaded', async () => {
|
||||||
|
const { detector, loadMedia } = setup();
|
||||||
|
const { player } = createPlayer();
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
// Media loaded but not yet visible -> not watched.
|
||||||
|
loadMedia(player);
|
||||||
|
expect(player.subscribeLiveness).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Becoming visible (initial intersection baseline, via emitInitial) -> watch.
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
expect(player.subscribeLiveness).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report a stall as not live with a reconnecting placeholder', async () => {
|
||||||
|
const { detector, onChange, loadMedia } = setup();
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
detector.subscribe();
|
||||||
|
loadMedia(player);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
fireMediaPlayerLiveness(false);
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'direct',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'stalled',
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report live again when the stream recovers', async () => {
|
||||||
|
const { detector, loadMedia } = setup();
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
detector.subscribe();
|
||||||
|
loadMedia(player);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
fireMediaPlayerLiveness(false);
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'live', authority: 'direct' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not re-notify when the liveness verdict is unchanged', async () => {
|
||||||
|
const { detector, onChange, loadMedia } = setup();
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
detector.subscribe();
|
||||||
|
loadMedia(player);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
fireMediaPlayerLiveness(false);
|
||||||
|
fireMediaPlayerLiveness(false);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop watching when it becomes not visible', async () => {
|
||||||
|
const { detector, loadMedia } = setup();
|
||||||
|
const { player, unsubscribe } = createPlayer();
|
||||||
|
detector.subscribe();
|
||||||
|
loadMedia(player);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
await callIntersectionHandler(false);
|
||||||
|
|
||||||
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report unknown after going off-screen while confirmed live', async () => {
|
||||||
|
const { detector, onChange, loadMedia } = setup();
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
detector.subscribe();
|
||||||
|
loadMedia(player);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'live', authority: 'direct' });
|
||||||
|
onChange.mockClear();
|
||||||
|
|
||||||
|
await callIntersectionHandler(false);
|
||||||
|
|
||||||
|
// Off-screen: no current frame evidence, so drop the stale `live` to
|
||||||
|
// `unknown` rather than suppressing the entity proxy with stale confidence.
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not watch a player without the liveness capability', async () => {
|
||||||
|
const { detector, onChange, loadMedia } = setup();
|
||||||
|
const player = mock<MediaPlayerController>();
|
||||||
|
player.subscribeLiveness = undefined;
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
loadMedia(player);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hold the not-live verdict when the frozen media unmounts', async () => {
|
||||||
|
const { detector, loadMedia } = setup();
|
||||||
|
const { player, unsubscribe, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
const abort = new AbortController();
|
||||||
|
detector.subscribe();
|
||||||
|
loadMedia(player, abort.signal);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
fireMediaPlayerLiveness(false);
|
||||||
|
|
||||||
|
// The placeholder unmounts the frozen stream -> the media aborts.
|
||||||
|
abort.abort();
|
||||||
|
|
||||||
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Verdict held, not reset to live -- recovery is the throttled remount.
|
||||||
|
expect(detector.getVerdict()).toEqual({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'direct',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'stalled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should drop a confirmed-live verdict to unknown when the media unmounts externally', async () => {
|
||||||
|
const { detector, loadMedia } = setup();
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
const abort = new AbortController();
|
||||||
|
detector.subscribe();
|
||||||
|
loadMedia(player, abort.signal);
|
||||||
|
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'live', authority: 'direct' });
|
||||||
|
|
||||||
|
// The media unmounts while confirmed live (an ordinary unload, not our own
|
||||||
|
// not-live placeholder). Drop the stale `live` to `unknown` so it does not
|
||||||
|
// suppress other detectors (e.g. entity availability).
|
||||||
|
abort.abort();
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should discard the verdict on reset', async () => {
|
||||||
|
const { detector, loadMedia } = setup();
|
||||||
|
const { player, unsubscribe, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
detector.subscribe();
|
||||||
|
loadMedia(player);
|
||||||
|
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
fireMediaPlayerLiveness(false);
|
||||||
|
|
||||||
|
detector.reset();
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should tear down the watch and stop listening on unsubscribe', async () => {
|
||||||
|
const { detector, loadMedia } = setup();
|
||||||
|
const first = createPlayer();
|
||||||
|
detector.subscribe();
|
||||||
|
loadMedia(first.player);
|
||||||
|
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
detector.unsubscribe();
|
||||||
|
expect(first.unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// A later media:loaded is ignored (listener removed).
|
||||||
|
const second = createPlayer();
|
||||||
|
loadMedia(second.player);
|
||||||
|
expect(second.player.subscribeLiveness).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not watch when media loads without a player controller', async () => {
|
||||||
|
const { detector, onChange, loadMedia } = setup();
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
loadMedia(undefined);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore a stale media abort after a newer load', async () => {
|
||||||
|
const { detector, loadMedia } = setup();
|
||||||
|
const first = createPlayer();
|
||||||
|
const second = createPlayer();
|
||||||
|
const abortFirst = new AbortController();
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
loadMedia(first.player, abortFirst.signal);
|
||||||
|
loadMedia(second.player);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
// The stale abort of the first load must not drop the current player.
|
||||||
|
abortFirst.abort();
|
||||||
|
second.fireMediaPlayerLiveness(false);
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'direct',
|
||||||
|
renderPlaceholder: true,
|
||||||
|
reason: 'stalled',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ProviderErrorDetector } from '../../../../../src/components-lib/live/liveness/detectors/provider-error';
|
||||||
|
|
||||||
|
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
||||||
|
|
||||||
|
const createHostInDocument = (): HTMLElement => {
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.append(host);
|
||||||
|
return host;
|
||||||
|
};
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('ProviderErrorDetector', () => {
|
||||||
|
it('should start unknown', () => {
|
||||||
|
const detector = new ProviderErrorDetector(document.createElement('div'), vi.fn());
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report not live on a provider error, leaving the provider mounted', () => {
|
||||||
|
const host = createHostInDocument();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const detector = new ProviderErrorDetector(host, onChange);
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||||
|
|
||||||
|
// not_live but no renderPlaceholder: the provider renders its own error.
|
||||||
|
expect(detector.getVerdict()).toEqual({
|
||||||
|
state: 'not_live',
|
||||||
|
authority: 'hard',
|
||||||
|
reason: 'playback_error',
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should notify only on the transition to not live', () => {
|
||||||
|
const host = createHostInDocument();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const detector = new ProviderErrorDetector(host, onChange);
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||||
|
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop the error from propagating past the host', () => {
|
||||||
|
const host = createHostInDocument();
|
||||||
|
const parentListener = vi.fn();
|
||||||
|
document.body.addEventListener(LIVE_ERROR_EVENT, parentListener);
|
||||||
|
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||||
|
detector.subscribe();
|
||||||
|
|
||||||
|
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||||
|
|
||||||
|
expect(parentListener).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
document.body.removeEventListener(LIVE_ERROR_EVENT, parentListener);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should discard the verdict on reset', () => {
|
||||||
|
const host = createHostInDocument();
|
||||||
|
const detector = new ProviderErrorDetector(host, vi.fn());
|
||||||
|
detector.subscribe();
|
||||||
|
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||||
|
expect(detector.getVerdict().state).toBe('not_live');
|
||||||
|
|
||||||
|
detector.reset();
|
||||||
|
|
||||||
|
expect(detector.getVerdict()).toEqual({ state: 'unknown' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore errors after unsubscribe', () => {
|
||||||
|
const host = createHostInDocument();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const detector = new ProviderErrorDetector(host, onChange);
|
||||||
|
detector.subscribe();
|
||||||
|
detector.unsubscribe();
|
||||||
|
|
||||||
|
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||||
|
|
||||||
|
expect(detector.getVerdict().state).toBe('unknown');
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import type { Camera } from '../../../../src/camera-manager/camera';
|
||||||
|
import type { StateWatcherSubscriptionInterface } from '../../../../src/card-controller/hass/state-watcher';
|
||||||
|
import { LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS } from '../../../../src/components-lib/live/liveness/detectors/entity-availability';
|
||||||
|
import { StreamLivenessController } from '../../../../src/components-lib/live/liveness/stream-liveness-controller';
|
||||||
|
import type { LivenessCallback, MediaPlayerController } from '../../../../src/types';
|
||||||
|
import {
|
||||||
|
callIntersectionHandler,
|
||||||
|
callStateWatcherCallback,
|
||||||
|
createCameraConfig,
|
||||||
|
createHASS,
|
||||||
|
createLitElement,
|
||||||
|
createMediaLoadedInfo,
|
||||||
|
createMediaLoadedInfoEvent,
|
||||||
|
createStateEntity,
|
||||||
|
IntersectionObserverMock,
|
||||||
|
} from '../../../test-utils';
|
||||||
|
|
||||||
|
const LIVE_ERROR_EVENT = 'advanced-camera-card:live:error';
|
||||||
|
const ISSUE_TRIGGER_EVENT = 'advanced-camera-card:issue:trigger';
|
||||||
|
|
||||||
|
const setup = (options?: { targetID?: string | null }) => {
|
||||||
|
const host = createLitElement();
|
||||||
|
document.body.append(host);
|
||||||
|
|
||||||
|
const controller = new StreamLivenessController(host, {
|
||||||
|
getTargetID: () =>
|
||||||
|
options?.targetID === undefined ? 'camera.office' : options.targetID,
|
||||||
|
getHASS: () => null,
|
||||||
|
getCamera: () => null,
|
||||||
|
getStateWatcher: () => null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issueTriggers: unknown[] = [];
|
||||||
|
host.addEventListener(ISSUE_TRIGGER_EVENT, (ev) =>
|
||||||
|
issueTriggers.push((ev as CustomEvent).detail),
|
||||||
|
);
|
||||||
|
|
||||||
|
const failViaProviderError = (): void => {
|
||||||
|
host.dispatchEvent(new Event(LIVE_ERROR_EVENT, { bubbles: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return { host, controller, issueTriggers, failViaProviderError };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPlayer = (): {
|
||||||
|
player: MediaPlayerController;
|
||||||
|
fireMediaPlayerLiveness: (isLive: boolean) => void;
|
||||||
|
} => {
|
||||||
|
const player = mock<MediaPlayerController>();
|
||||||
|
let captured: LivenessCallback | null = null;
|
||||||
|
player.subscribeLiveness = vi.fn((callback: LivenessCallback) => {
|
||||||
|
captured = callback;
|
||||||
|
return vi.fn();
|
||||||
|
});
|
||||||
|
return { player, fireMediaPlayerLiveness: (isLive: boolean) => captured?.(isLive) };
|
||||||
|
};
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('StreamLivenessController', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock);
|
||||||
|
vi.spyOn(global.document, 'addEventListener');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
value: 'visible',
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should register itself as a controller on the host', () => {
|
||||||
|
const { host, controller } = setup();
|
||||||
|
|
||||||
|
expect(host.addController).toHaveBeenCalledWith(controller);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be live with no placeholder by default', () => {
|
||||||
|
const { controller } = setup();
|
||||||
|
|
||||||
|
expect(controller.isLive()).toBe(true);
|
||||||
|
expect(controller.getPlaceholder()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should aggregate a detector losing liveness only after host connect', () => {
|
||||||
|
const { controller, failViaProviderError } = setup();
|
||||||
|
|
||||||
|
// Not yet connected -> the provider-error listener is not attached.
|
||||||
|
failViaProviderError();
|
||||||
|
expect(controller.isLive()).toBe(true);
|
||||||
|
|
||||||
|
controller.hostConnected();
|
||||||
|
failViaProviderError();
|
||||||
|
|
||||||
|
expect(controller.isLive()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fire the media_unavailable issue and request an update when liveness is lost', () => {
|
||||||
|
const { host, controller, issueTriggers, failViaProviderError } = setup();
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
failViaProviderError();
|
||||||
|
|
||||||
|
expect(issueTriggers).toEqual([
|
||||||
|
{ key: 'media_unavailable', targetID: 'camera.office', reason: 'playback_error' },
|
||||||
|
]);
|
||||||
|
expect(host.requestUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not request a placeholder when the detector renders its own error', () => {
|
||||||
|
const { controller, failViaProviderError } = setup();
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
failViaProviderError();
|
||||||
|
|
||||||
|
// Provider-error is not-live but does not want a placeholder.
|
||||||
|
expect(controller.isLive()).toBe(false);
|
||||||
|
expect(controller.getPlaceholder()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not fire the issue without a target', () => {
|
||||||
|
const { host, controller, issueTriggers, failViaProviderError } = setup({
|
||||||
|
targetID: null,
|
||||||
|
});
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
failViaProviderError();
|
||||||
|
|
||||||
|
expect(issueTriggers).toEqual([]);
|
||||||
|
expect(host.requestUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should request a placeholder when a detector reports a silent freeze', async () => {
|
||||||
|
const { host, controller, issueTriggers } = setup();
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
host.dispatchEvent(
|
||||||
|
createMediaLoadedInfoEvent({
|
||||||
|
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
fireMediaPlayerLiveness(false);
|
||||||
|
|
||||||
|
expect(controller.isLive()).toBe(false);
|
||||||
|
expect(controller.getPlaceholder()).toEqual({ reason: 'stalled' });
|
||||||
|
expect(issueTriggers).toEqual([
|
||||||
|
{ key: 'media_unavailable', targetID: 'camera.office', reason: 'stalled' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop aggregating detector inputs after host disconnect', () => {
|
||||||
|
const { controller, failViaProviderError } = setup();
|
||||||
|
controller.hostConnected();
|
||||||
|
controller.hostDisconnected();
|
||||||
|
|
||||||
|
failViaProviderError();
|
||||||
|
|
||||||
|
expect(controller.isLive()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset all detectors', () => {
|
||||||
|
const { controller, failViaProviderError } = setup();
|
||||||
|
controller.hostConnected();
|
||||||
|
failViaProviderError();
|
||||||
|
expect(controller.isLive()).toBe(false);
|
||||||
|
|
||||||
|
controller.reset();
|
||||||
|
|
||||||
|
expect(controller.isLive()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should request an update without an issue when liveness recovers', async () => {
|
||||||
|
const { host, controller, issueTriggers } = setup();
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
controller.hostConnected();
|
||||||
|
host.dispatchEvent(
|
||||||
|
createMediaLoadedInfoEvent({
|
||||||
|
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
fireMediaPlayerLiveness(false);
|
||||||
|
issueTriggers.length = 0;
|
||||||
|
vi.mocked(host.requestUpdate).mockClear();
|
||||||
|
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
|
||||||
|
expect(issueTriggers).toEqual([]);
|
||||||
|
expect(host.requestUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should surface an always_error unavailable entity as a placeholder', () => {
|
||||||
|
const host = createLitElement();
|
||||||
|
document.body.append(host);
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
stateWatcher.subscribe.mockReturnValue(true);
|
||||||
|
const camera = mock<Camera>();
|
||||||
|
camera.getConfig.mockReturnValue(
|
||||||
|
createCameraConfig({
|
||||||
|
camera_entity: 'camera.office',
|
||||||
|
always_error_if_entity_unavailable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let currentCamera: Camera | null = camera;
|
||||||
|
const hass = createHASS({
|
||||||
|
'camera.office': createStateEntity({ state: 'unavailable' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new StreamLivenessController(host, {
|
||||||
|
getTargetID: () => 'camera.office',
|
||||||
|
getHASS: () => hass,
|
||||||
|
getCamera: () => currentCamera,
|
||||||
|
getStateWatcher: () => stateWatcher,
|
||||||
|
});
|
||||||
|
const issueTriggers: unknown[] = [];
|
||||||
|
host.addEventListener(ISSUE_TRIGGER_EVENT, (ev) =>
|
||||||
|
issueTriggers.push((ev as CustomEvent).detail),
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.hostConnected();
|
||||||
|
|
||||||
|
expect(controller.isLive()).toBe(false);
|
||||||
|
expect(controller.getPlaceholder()).toEqual({ reason: 'entity_unavailable' });
|
||||||
|
expect(issueTriggers).toEqual([
|
||||||
|
{
|
||||||
|
key: 'media_unavailable',
|
||||||
|
targetID: 'camera.office',
|
||||||
|
reason: 'entity_unavailable',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The camera reference is removed while the entity is still watched; the
|
||||||
|
// always_error lookup must tolerate a now-null camera and leave the
|
||||||
|
// already-not-live verdict unchanged.
|
||||||
|
currentCamera = null;
|
||||||
|
callStateWatcherCallback(stateWatcher, {
|
||||||
|
entityID: 'camera.office',
|
||||||
|
newState: createStateEntity({ state: 'unavailable' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(controller.isLive()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep a frame-confirmed stream live despite an unavailable entity', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const host = createLitElement();
|
||||||
|
document.body.append(host);
|
||||||
|
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
stateWatcher.subscribe.mockReturnValue(true);
|
||||||
|
|
||||||
|
const camera = mock<Camera>();
|
||||||
|
camera.getConfig.mockReturnValue(
|
||||||
|
createCameraConfig({ camera_entity: 'camera.office' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hass = createHASS({
|
||||||
|
'camera.office': createStateEntity({ state: 'unavailable' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new StreamLivenessController(host, {
|
||||||
|
getTargetID: () => 'camera.office',
|
||||||
|
getHASS: () => hass,
|
||||||
|
getCamera: () => camera,
|
||||||
|
getStateWatcher: () => stateWatcher,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issueTriggers: unknown[] = [];
|
||||||
|
host.addEventListener(ISSUE_TRIGGER_EVENT, (ev) =>
|
||||||
|
issueTriggers.push((ev as CustomEvent).detail),
|
||||||
|
);
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
|
||||||
|
controller.hostConnected();
|
||||||
|
host.dispatchEvent(
|
||||||
|
createMediaLoadedInfoEvent({
|
||||||
|
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
// Frames confirm the stream is live, then the entity blips unavailable past
|
||||||
|
// its grace window.
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
vi.advanceTimersByTime(LIVENESS_ENTITY_UNAVAILABLE_GRACE_SECONDS * 1000);
|
||||||
|
|
||||||
|
// Direct frame evidence outranks the entity proxy: no teardown, no issue.
|
||||||
|
expect(controller.isLive()).toBe(true);
|
||||||
|
expect(controller.getPlaceholder()).toBeNull();
|
||||||
|
expect(issueTriggers).toEqual([]);
|
||||||
|
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report not live despite confirmed frames when always_error overrides', async () => {
|
||||||
|
const host = createLitElement();
|
||||||
|
document.body.append(host);
|
||||||
|
|
||||||
|
const stateWatcher = mock<StateWatcherSubscriptionInterface>();
|
||||||
|
stateWatcher.subscribe.mockReturnValue(true);
|
||||||
|
|
||||||
|
const camera = mock<Camera>();
|
||||||
|
camera.getConfig.mockReturnValue(
|
||||||
|
createCameraConfig({
|
||||||
|
camera_entity: 'camera.office',
|
||||||
|
always_error_if_entity_unavailable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const hass = createHASS({
|
||||||
|
'camera.office': createStateEntity({ state: 'unavailable' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new StreamLivenessController(host, {
|
||||||
|
getTargetID: () => 'camera.office',
|
||||||
|
getHASS: () => hass,
|
||||||
|
getCamera: () => camera,
|
||||||
|
getStateWatcher: () => stateWatcher,
|
||||||
|
});
|
||||||
|
const { player, fireMediaPlayerLiveness } = createPlayer();
|
||||||
|
|
||||||
|
controller.hostConnected();
|
||||||
|
host.dispatchEvent(
|
||||||
|
createMediaLoadedInfoEvent({
|
||||||
|
info: createMediaLoadedInfo({ mediaPlayerController: player }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
fireMediaPlayerLiveness(true);
|
||||||
|
|
||||||
|
// The always_error opt-in is authoritative: an unavailable entity overrides
|
||||||
|
// even confirmed frames.
|
||||||
|
expect(controller.isLive()).toBe(false);
|
||||||
|
expect(controller.getPlaceholder()).toEqual({ reason: 'entity_unavailable' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -60,6 +60,9 @@ describe('MediaLoadedInfoSourceController', () => {
|
|||||||
});
|
});
|
||||||
expect(ev.detail.signal).toBeInstanceOf(AbortSignal);
|
expect(ev.detail.signal).toBeInstanceOf(AbortSignal);
|
||||||
expect(ev.detail.signal.aborted).toBe(false);
|
expect(ev.detail.signal.aborted).toBe(false);
|
||||||
|
|
||||||
|
// A fresh load omits `cached` (only a replay marks it true).
|
||||||
|
expect(ev.detail.cached).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should dedup structurally-equal info', () => {
|
it('should dedup structurally-equal info', () => {
|
||||||
@@ -176,6 +179,10 @@ describe('MediaLoadedInfoSourceController', () => {
|
|||||||
expect(firstSignal).not.toBe(secondSignal);
|
expect(firstSignal).not.toBe(secondSignal);
|
||||||
expect(firstSignal.aborted).toBe(true);
|
expect(firstSignal.aborted).toBe(true);
|
||||||
expect(secondSignal.aborted).toBe(false);
|
expect(secondSignal.aborted).toBe(false);
|
||||||
|
|
||||||
|
// The fresh load omits `cached`; the reconnect replay marks it `true`.
|
||||||
|
expect((handler.mock.calls[0][0] as CustomEvent).detail.cached).toBeUndefined();
|
||||||
|
expect((handler.mock.calls[1][0] as CustomEvent).detail.cached).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be a no-op when there is nothing to re-dispatch', () => {
|
it('should be a no-op when there is nothing to re-dispatch', () => {
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FRAME_STALL_SECONDS,
|
||||||
|
FrameStallWatchdog,
|
||||||
|
type FrameStallWatchdogConfig,
|
||||||
|
} from '../../../src/components-lib/media-player/frame-stall-watchdog';
|
||||||
|
|
||||||
|
const STALL_MS = FRAME_STALL_SECONDS * 1000;
|
||||||
|
|
||||||
|
const createConfig = (
|
||||||
|
overrides?: Partial<FrameStallWatchdogConfig>,
|
||||||
|
): FrameStallWatchdogConfig => ({
|
||||||
|
isPlaybackExpected: vi.fn().mockReturnValue(true),
|
||||||
|
startSource: vi.fn().mockReturnValue(true),
|
||||||
|
stopSource: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
describe('FrameStallWatchdog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('source lifecycle', () => {
|
||||||
|
it('should start the source only on the first subscriber', () => {
|
||||||
|
const config = createConfig();
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
|
||||||
|
watchdog.subscribe(vi.fn());
|
||||||
|
watchdog.subscribe(vi.fn());
|
||||||
|
|
||||||
|
expect(config.startSource).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop the source only on the last unsubscribe', () => {
|
||||||
|
const config = createConfig();
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
|
||||||
|
const unsubscribeFirst = watchdog.subscribe(vi.fn());
|
||||||
|
const unsubscribeSecond = watchdog.subscribe(vi.fn());
|
||||||
|
|
||||||
|
unsubscribeFirst();
|
||||||
|
expect(config.stopSource).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unsubscribeSecond();
|
||||||
|
expect(config.stopSource).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to an always-available source needing no teardown', () => {
|
||||||
|
const watchdog = new FrameStallWatchdog({ isPlaybackExpected: () => true });
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
const unsubscribe = watchdog.subscribe(callback);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
unsubscribe(); // no stopSource configured -> no throw
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stall detection', () => {
|
||||||
|
it('should report stalled when no frame arrives within the window', () => {
|
||||||
|
const config = createConfig();
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
watchdog.subscribe(callback);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stay live while frames keep arriving within the window', () => {
|
||||||
|
const config = createConfig();
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
watchdog.subscribe(callback);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
vi.advanceTimersByTime(STALL_MS - 1000);
|
||||||
|
watchdog.notifyFrame();
|
||||||
|
}
|
||||||
|
vi.advanceTimersByTime(STALL_MS - 1000);
|
||||||
|
|
||||||
|
// Confirmed live on the first frame, and never stalled thereafter.
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report live again once a frame arrives after a stall', () => {
|
||||||
|
const config = createConfig();
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
watchdog.subscribe(callback);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
expect(callback).toHaveBeenNthCalledWith(1, false);
|
||||||
|
|
||||||
|
watchdog.notifyFrame();
|
||||||
|
expect(callback).toHaveBeenNthCalledWith(2, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not report stalled while the source is legitimately idle', () => {
|
||||||
|
const config = createConfig({
|
||||||
|
isPlaybackExpected: vi.fn().mockReturnValue(false),
|
||||||
|
});
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
watchdog.subscribe(callback);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should re-arm while idle so a freeze that becomes actionable later is caught', () => {
|
||||||
|
const isPlaybackExpected = vi.fn().mockReturnValue(false);
|
||||||
|
const config = createConfig({ isPlaybackExpected });
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
watchdog.subscribe(callback);
|
||||||
|
|
||||||
|
// Idle window: no stall reported, but the watchdog re-arms rather than
|
||||||
|
// stopping.
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The source becomes actionable while still frozen (holding a frame), with
|
||||||
|
// no new frame to kick the timer. The re-armed timer catches the freeze on
|
||||||
|
// the next window.
|
||||||
|
isPlaybackExpected.mockReturnValue(true);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should broadcast a stall to every subscriber', () => {
|
||||||
|
const config = createConfig();
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
|
||||||
|
watchdog.subscribe(first);
|
||||||
|
watchdog.subscribe(second);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(first).toHaveBeenCalledTimes(1);
|
||||||
|
expect(first).toHaveBeenCalledWith(false);
|
||||||
|
expect(second).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not report live merely on subscribing before any frame', () => {
|
||||||
|
const config = createConfig();
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
// Unconfirmed until a real frame arrives: subscribing alone does not
|
||||||
|
// report live.
|
||||||
|
watchdog.subscribe(callback);
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should confirm live on the first frame but not re-notify on the next', () => {
|
||||||
|
const config = createConfig();
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
watchdog.subscribe(callback);
|
||||||
|
watchdog.notifyFrame();
|
||||||
|
watchdog.notifyFrame();
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('no available source', () => {
|
||||||
|
it('should never report stalled when the source is unavailable', () => {
|
||||||
|
const config = createConfig({ startSource: vi.fn().mockReturnValue(false) });
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
watchdog.subscribe(callback);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
expect(config.isPlaybackExpected).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore notifyFrame when the source is unavailable', () => {
|
||||||
|
const config = createConfig({ startSource: vi.fn().mockReturnValue(false) });
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
watchdog.subscribe(callback);
|
||||||
|
watchdog.notifyFrame();
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not stop a source that was never started on unsubscribe', () => {
|
||||||
|
const config = createConfig({ startSource: vi.fn().mockReturnValue(false) });
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
|
||||||
|
const unsubscribe = watchdog.subscribe(vi.fn());
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(config.stopSource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('teardown', () => {
|
||||||
|
it('should not report a stall after the last subscriber leaves', () => {
|
||||||
|
const config = createConfig();
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
const unsubscribe = watchdog.subscribe(callback);
|
||||||
|
unsubscribe();
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop cleanly if the last subscriber unsubscribes during a recovery notification', () => {
|
||||||
|
const config = createConfig();
|
||||||
|
const watchdog = new FrameStallWatchdog(config);
|
||||||
|
let unsubscribe: () => void = () => {};
|
||||||
|
const callback = vi.fn((isLive: boolean) => {
|
||||||
|
if (isLive) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
unsubscribe = watchdog.subscribe(callback);
|
||||||
|
|
||||||
|
// Stall -> callback(false)
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
// Recovery -> callback(true) -> unsubscribe -> stop
|
||||||
|
watchdog.notifyFrame();
|
||||||
|
|
||||||
|
expect(config.stopSource).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// The timer armed by notifyFrame must have been stopped, so no further
|
||||||
|
// stall fires with no subscribers.
|
||||||
|
callback.mockClear();
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type JSMpeg from '@cycjimmy/jsmpeg-player';
|
import type JSMpeg from '@cycjimmy/jsmpeg-player';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import { FRAME_STALL_SECONDS } from '../../../src/components-lib/media-player/frame-stall-watchdog';
|
||||||
import { JSMPEGMediaPlayerController } from '../../../src/components-lib/media-player/jsmpeg';
|
import { JSMPEGMediaPlayerController } from '../../../src/components-lib/media-player/jsmpeg';
|
||||||
import { createLitElement } from '../../test-utils';
|
import { createLitElement } from '../../test-utils';
|
||||||
|
|
||||||
@@ -260,4 +261,64 @@ describe('JSMPEGMediaPlayerController', () => {
|
|||||||
|
|
||||||
expect(controller.getPIPElement()).toBeNull();
|
expect(controller.getPIPElement()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('subscribeLiveness', () => {
|
||||||
|
const STALL_MS = FRAME_STALL_SECONDS * 1000;
|
||||||
|
|
||||||
|
const setup = (options?: { paused?: boolean; hasPlayer?: boolean }) => {
|
||||||
|
let videoElement: JSMpeg.VideoElement | null = null;
|
||||||
|
if (options?.hasPlayer !== false) {
|
||||||
|
videoElement = mock<JSMpeg.VideoElement>();
|
||||||
|
videoElement.player = mock<JSMpeg.Player>();
|
||||||
|
videoElement.player.paused = options?.paused ?? false;
|
||||||
|
}
|
||||||
|
const controller = new JSMPEGMediaPlayerController(
|
||||||
|
createLitElement(),
|
||||||
|
() => videoElement,
|
||||||
|
() => mock<HTMLCanvasElement>(),
|
||||||
|
);
|
||||||
|
return { controller };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report a stall when frame decodes stop', () => {
|
||||||
|
const { controller } = setup();
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
controller.subscribeLiveness(callback);
|
||||||
|
controller.notifyFrameDecoded();
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
// The first decoded frame confirms live, then decodes stop -> stall.
|
||||||
|
expect(callback).toHaveBeenNthCalledWith(1, true);
|
||||||
|
expect(callback).toHaveBeenNthCalledWith(2, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not report a stall while paused', () => {
|
||||||
|
const { controller } = setup({ paused: true });
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
controller.subscribeLiveness(callback);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not report a stall without a player', () => {
|
||||||
|
const { controller } = setup({ hasPlayer: false });
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
controller.subscribeLiveness(callback);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
|
|
||||||
|
import { FRAME_STALL_SECONDS } from '../../../src/components-lib/media-player/frame-stall-watchdog';
|
||||||
import { VideoMediaPlayerController } from '../../../src/components-lib/media-player/video';
|
import { VideoMediaPlayerController } from '../../../src/components-lib/media-player/video';
|
||||||
import {
|
import {
|
||||||
hideMediaControlsTemporarily,
|
hideMediaControlsTemporarily,
|
||||||
@@ -16,6 +17,48 @@ class NotAllowedError extends Error {
|
|||||||
name = 'NotAllowedError';
|
name = 'NotAllowedError';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STALL_MS = FRAME_STALL_SECONDS * 1000;
|
||||||
|
|
||||||
|
// A real jsdom <video> (so `'requestVideoFrameCallback' in video` is genuinely
|
||||||
|
// false unless we add it) with controllable properties and a driveable frame
|
||||||
|
// callback.
|
||||||
|
const createVideo = (options?: {
|
||||||
|
readyState?: number;
|
||||||
|
paused?: boolean;
|
||||||
|
seeking?: boolean;
|
||||||
|
ended?: boolean;
|
||||||
|
rvfc?: boolean;
|
||||||
|
}): {
|
||||||
|
video: HTMLVideoElement;
|
||||||
|
deliverFrame: () => void;
|
||||||
|
cancel: ReturnType<typeof vi.fn>;
|
||||||
|
} => {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
const define = (prop: string, value: unknown): void => {
|
||||||
|
Object.defineProperty(video, prop, { value, configurable: true });
|
||||||
|
};
|
||||||
|
define('readyState', options?.readyState ?? HTMLMediaElement.HAVE_CURRENT_DATA);
|
||||||
|
define('paused', options?.paused ?? false);
|
||||||
|
define('seeking', options?.seeking ?? false);
|
||||||
|
define('ended', options?.ended ?? false);
|
||||||
|
|
||||||
|
let frameCallback: (() => void) | null = null;
|
||||||
|
const cancel = vi.fn();
|
||||||
|
if (options?.rvfc !== false) {
|
||||||
|
let handle = 0;
|
||||||
|
define(
|
||||||
|
'requestVideoFrameCallback',
|
||||||
|
vi.fn((cb: () => void) => {
|
||||||
|
frameCallback = cb;
|
||||||
|
return ++handle;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
define('cancelVideoFrameCallback', cancel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { video, deliverFrame: () => frameCallback?.(), cancel };
|
||||||
|
};
|
||||||
|
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
describe('VideoMediaPlayerController', () => {
|
describe('VideoMediaPlayerController', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -287,4 +330,145 @@ describe('VideoMediaPlayerController', () => {
|
|||||||
expect(controller.getPIPElement()).toBeNull();
|
expect(controller.getPIPElement()).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('subscribeLiveness', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report a stall when frames stop arriving', () => {
|
||||||
|
const { video, deliverFrame } = createVideo();
|
||||||
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
controller.subscribeLiveness(callback);
|
||||||
|
deliverFrame();
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
// The first frame confirms live, then no further frame arrives -> stall.
|
||||||
|
expect(callback).toHaveBeenNthCalledWith(1, true);
|
||||||
|
expect(callback).toHaveBeenNthCalledWith(2, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not report a stall while a paused video still holds a frame', () => {
|
||||||
|
// A genuine user pause: paused with a current frame is idle, never
|
||||||
|
// reported -- not even after the stall window.
|
||||||
|
const { video } = createVideo({ paused: true });
|
||||||
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
controller.subscribeLiveness(callback);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report a video stuck paused with no current frame', () => {
|
||||||
|
// The observed go2rtc failure: its internal reconnect leaves the <video>
|
||||||
|
// paused at readyState 0 (spinning). That is not a genuine pause -- there
|
||||||
|
// is no frame to pause on -- so playback is still expected and a missing
|
||||||
|
// frame is a stall.
|
||||||
|
const { video } = createVideo({
|
||||||
|
paused: true,
|
||||||
|
readyState: HTMLMediaElement.HAVE_NOTHING,
|
||||||
|
});
|
||||||
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
controller.subscribeLiveness(callback);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report no stall when requestVideoFrameCallback is unavailable', () => {
|
||||||
|
const { video } = createVideo({ rvfc: false });
|
||||||
|
expect('requestVideoFrameCallback' in video).toBe(false);
|
||||||
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
controller.subscribeLiveness(callback);
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop watching and cancel the frame callback on unsubscribe', () => {
|
||||||
|
const { video, cancel } = createVideo();
|
||||||
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
const unsubscribe = controller.subscribeLiveness(callback);
|
||||||
|
unsubscribe();
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not re-arm when the last subscriber unsubscribes during recovery', () => {
|
||||||
|
const { video, deliverFrame, cancel } = createVideo();
|
||||||
|
const controller = new VideoMediaPlayerController(createLitElement(), () => video);
|
||||||
|
let unsubscribe: () => void = () => {};
|
||||||
|
const callback = vi.fn((isLive: boolean) => {
|
||||||
|
if (isLive) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
unsubscribe = controller.subscribeLiveness(callback);
|
||||||
|
|
||||||
|
// Stall -> callback(false)
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
|
||||||
|
// Recovery -> callback(true) -> unsubscribe -> source stopped
|
||||||
|
deliverFrame();
|
||||||
|
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip re-arming and report no stall when the video is gone between frames', () => {
|
||||||
|
const { video, deliverFrame } = createVideo();
|
||||||
|
let currentVideo: HTMLVideoElement | null = video;
|
||||||
|
const controller = new VideoMediaPlayerController(
|
||||||
|
createLitElement(),
|
||||||
|
() => currentVideo,
|
||||||
|
);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
controller.subscribeLiveness(callback);
|
||||||
|
deliverFrame();
|
||||||
|
|
||||||
|
// Remove video
|
||||||
|
currentVideo = null;
|
||||||
|
|
||||||
|
// Re-arm skipped: no video
|
||||||
|
deliverFrame();
|
||||||
|
|
||||||
|
// The first frame reported live; the stall timer then fires with no
|
||||||
|
// video, so no stall is ever reported.
|
||||||
|
vi.advanceTimersByTime(STALL_MS);
|
||||||
|
expect(callback).not.toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should cancel nothing on unsubscribe when the video is already gone', () => {
|
||||||
|
const { video, cancel } = createVideo();
|
||||||
|
let currentVideo: HTMLVideoElement | null = video;
|
||||||
|
const controller = new VideoMediaPlayerController(
|
||||||
|
createLitElement(),
|
||||||
|
() => currentVideo,
|
||||||
|
);
|
||||||
|
|
||||||
|
const unsubscribe = controller.subscribeLiveness(vi.fn());
|
||||||
|
currentVideo = null;
|
||||||
|
unsubscribe();
|
||||||
|
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -220,4 +220,51 @@ describe('VisibilityObserver', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('emitInitial', () => {
|
||||||
|
it('should emit the initial visible value on the baseline callback', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const observer = new VisibilityObserver(onChange, { emitInitial: true });
|
||||||
|
observer.setRoot(createParent());
|
||||||
|
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit the initial hidden value on the baseline callback', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const observer = new VisibilityObserver(onChange, { emitInitial: true });
|
||||||
|
observer.setRoot(createParent());
|
||||||
|
|
||||||
|
await callIntersectionHandler(false);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not re-emit an unchanged state after the initial emit', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const observer = new VisibilityObserver(onChange, { emitInitial: true });
|
||||||
|
observer.setRoot(createParent());
|
||||||
|
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should still emit subsequent transitions after the initial emit', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const observer = new VisibilityObserver(onChange, { emitInitial: true });
|
||||||
|
observer.setRoot(createParent());
|
||||||
|
|
||||||
|
await callIntersectionHandler(true);
|
||||||
|
await callIntersectionHandler(false);
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(1, true);
|
||||||
|
expect(onChange).toHaveBeenNthCalledWith(2, false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,38 @@ describe('Initializer', () => {
|
|||||||
expect(initializer.isInitialized('foo')).toBeTruthy();
|
expect(initializer.isInitialized('foo')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not initialize when the initializer reports failure', async () => {
|
||||||
|
const initializer = new Initializer();
|
||||||
|
|
||||||
|
expect(await initializer.initializeIfNecessary('foo', async () => false)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||||
|
|
||||||
|
expect(await initializer.initializeIfNecessary('foo', async () => true)).toBe(true);
|
||||||
|
expect(initializer.isInitialized('foo')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report whether all of multiple aspects initialized', async () => {
|
||||||
|
const initializer = new Initializer();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await initializer.initializeMultipleIfNecessary({
|
||||||
|
foo: async () => false,
|
||||||
|
bar: async () => {},
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||||
|
expect(initializer.isInitialized('bar')).toBeTruthy();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await initializer.initializeMultipleIfNecessary({
|
||||||
|
foo: async () => true,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(initializer.isInitialized('foo')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it('should not initialize with failed initializer', async () => {
|
it('should not initialize with failed initializer', async () => {
|
||||||
const initializer = new Initializer();
|
const initializer = new Initializer();
|
||||||
|
|
||||||
@@ -89,7 +121,7 @@ describe('Initializer', () => {
|
|||||||
initializer.uninitialize('foo');
|
initializer.uninitialize('foo');
|
||||||
|
|
||||||
finishInitializer();
|
finishInitializer();
|
||||||
await initializing;
|
expect(await initializing).toBe(false);
|
||||||
|
|
||||||
expect(initializer.isInitialized('foo')).toBeFalsy();
|
expect(initializer.isInitialized('foo')).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user