220 lines
7.3 KiB
TypeScript
220 lines
7.3 KiB
TypeScript
import type { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
|
|
import type {
|
|
IssueResolveEventData,
|
|
IssueTriggerEventData,
|
|
} from '../card-controller/issues/types';
|
|
import type { MediaLoadedInfoEventDetail } from '../types';
|
|
import { onAbort } from '../utils/abort-signal';
|
|
import { Generation } from '../utils/concurrency/generation';
|
|
import { fireAdvancedCameraCardEvent } from '../utils/fire-advanced-camera-card-event';
|
|
import { Timer } from '../utils/timer';
|
|
|
|
const MEDIA_LOADED_EVENT = 'advanced-camera-card:media:loaded';
|
|
|
|
export const MEDIA_LOADING_TIMEOUT_SECONDS = 10;
|
|
|
|
interface MediaLoadWatchdogConfig {
|
|
getTargetID: () => string | null;
|
|
|
|
// Whether the host is currently trying to load media. False when loading is
|
|
// held back (e.g. lazy loading), or when the host is showing a failure of its
|
|
// own rather than waiting for media.
|
|
isLoadExpected: () => boolean;
|
|
|
|
// Changes when the media underneath is rebuilt without the host itself being
|
|
// rebuilt (e.g. a retry re-keying a player below it), so the wait starts
|
|
// again. Omitted by a host that a retry replaces outright.
|
|
getAttemptID?: () => unknown;
|
|
}
|
|
|
|
/**
|
|
* Watches a media host's load: when the host expects media but none arrives
|
|
* within the window, raises a `media_unavailable` issue with reason
|
|
* `not_loading`, and clears it again once media does arrive.
|
|
*
|
|
* Loads are observed from the host's own bubble path, so a `media:loaded`
|
|
* dispatched by any descendant player ends the wait, and that media going away
|
|
* (its event's abort signal) restarts it if a load is still expected.
|
|
*
|
|
* A host may be reused for a succession of targets (a viewer slide swiped from
|
|
* one media item to the next), so everything learned is scoped to the target it
|
|
* was learned about. A target change starts a fresh wait and resolves any
|
|
* failure reported for the target left behind.
|
|
*/
|
|
export class MediaLoadWatchdogController implements ReactiveController {
|
|
private _host: ReactiveControllerHost & HTMLElement;
|
|
private _config: MediaLoadWatchdogConfig;
|
|
|
|
private _timer = new Timer();
|
|
|
|
// What the state below describes: the target, and which attempt at loading it
|
|
// (see `getAttemptID`). A change in either means everything known is about
|
|
// media the host has moved on from.
|
|
private _targetID: string | null = null;
|
|
private _attemptID: unknown = undefined;
|
|
|
|
private _mediaLoaded = false;
|
|
|
|
// Identifies which load `_mediaLoaded` describes. One player can replace
|
|
// another for the same target before the first has gone away, and the first
|
|
// going away must not be read as the current media being lost.
|
|
private _loadGeneration = new Generation();
|
|
|
|
// Whether the timeout has fired for the current attempt, so a hung load is
|
|
// reported only once. A retry is a new attempt and is waited on afresh.
|
|
private _fired = false;
|
|
|
|
// The target this watchdog has an outstanding failure reported for, so that
|
|
// failure can be resolved if the host moves on to another target. It
|
|
// outlives the attempt that reported it, which keeps the failure on screen
|
|
// while a retry runs underneath.
|
|
private _reportedTargetID: string | null = null;
|
|
|
|
// Media loads, and the media going away, are observed through listeners and
|
|
// abort signals that can outlive a disconnect, and a detached host must not be
|
|
// waited on.
|
|
private _connected = false;
|
|
|
|
constructor(
|
|
host: ReactiveControllerHost & HTMLElement,
|
|
config: MediaLoadWatchdogConfig,
|
|
) {
|
|
this._host = host;
|
|
this._config = config;
|
|
host.addController(this);
|
|
}
|
|
|
|
public hostConnected(): void {
|
|
this._connected = true;
|
|
this._host.addEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
|
|
this._evaluate();
|
|
}
|
|
|
|
public hostDisconnected(): void {
|
|
this._connected = false;
|
|
this._host.removeEventListener(MEDIA_LOADED_EVENT, this._onMediaLoaded);
|
|
this._timer.stop();
|
|
}
|
|
|
|
public hostUpdated(): void {
|
|
this._evaluate();
|
|
}
|
|
|
|
private _forgetLoad(): void {
|
|
this._mediaLoaded = false;
|
|
this._fired = false;
|
|
this._loadGeneration.invalidate();
|
|
this._timer.stop();
|
|
}
|
|
|
|
private _onMediaLoaded = (ev: CustomEvent<MediaLoadedInfoEventDetail>): void => {
|
|
const targetID = ev.detail.info.targetID;
|
|
|
|
// The host's own target is read fresh so a load arriving as the host
|
|
// switches is attributed to whichever target it actually describes.
|
|
if (!targetID || targetID !== this._config.getTargetID()) {
|
|
return;
|
|
}
|
|
|
|
// This load replaces whatever was being followed, which may be a different
|
|
// target the host has just moved off.
|
|
this._syncAttempt();
|
|
this._forgetLoad();
|
|
|
|
this._mediaLoaded = true;
|
|
const generation = this._loadGeneration.next();
|
|
|
|
// Media arriving disproves a not-loading failure whoever reported it.
|
|
this._resolveFailure(targetID);
|
|
|
|
// Media going away is not itself a failure, but a load that is still
|
|
// expected afterwards needs a fresh wait. This is recorded whether or not
|
|
// the host is attached, since a detached host has no media either.
|
|
onAbort(ev.detail.signal, () => {
|
|
if (targetID !== this._targetID || !this._loadGeneration.isCurrent(generation)) {
|
|
return;
|
|
}
|
|
|
|
this._mediaLoaded = false;
|
|
this._evaluate();
|
|
});
|
|
};
|
|
|
|
// Clear a target's not-loading failure, whichever component reported it.
|
|
// Naming the reason leaves a failure reported for any other reason alone.
|
|
private _resolveFailure(targetID: string): void {
|
|
if (this._reportedTargetID === targetID) {
|
|
this._reportedTargetID = null;
|
|
}
|
|
|
|
fireAdvancedCameraCardEvent<IssueResolveEventData>(this._host, 'issue:resolve', {
|
|
key: 'media_unavailable',
|
|
targetID,
|
|
reason: 'not_loading',
|
|
});
|
|
}
|
|
|
|
// Discard everything learned about a previous target, or a previous attempt
|
|
// at the same one.
|
|
private _syncAttempt(): void {
|
|
const targetID = this._config.getTargetID();
|
|
const attemptID = this._config.getAttemptID?.();
|
|
if (targetID === this._targetID && attemptID === this._attemptID) {
|
|
return;
|
|
}
|
|
|
|
// If an abandoned target was previously reported, resolve it so it's not
|
|
// stuck forever
|
|
if (this._reportedTargetID && this._reportedTargetID !== targetID) {
|
|
this._resolveFailure(this._reportedTargetID);
|
|
}
|
|
|
|
this._targetID = targetID;
|
|
this._attemptID = attemptID;
|
|
this._forgetLoad();
|
|
}
|
|
|
|
private _evaluate(): void {
|
|
if (!this._connected) {
|
|
return;
|
|
}
|
|
|
|
this._syncAttempt();
|
|
|
|
if (!this._targetID || this._mediaLoaded || !this._config.isLoadExpected()) {
|
|
this._fired = false;
|
|
this._timer.stop();
|
|
return;
|
|
}
|
|
|
|
if (!this._fired && !this._timer.isRunning()) {
|
|
this._timer.start(MEDIA_LOADING_TIMEOUT_SECONDS, () => this._onTimeout());
|
|
}
|
|
}
|
|
|
|
private _onTimeout(): void {
|
|
const targetID = this._targetID;
|
|
|
|
// Conditions may have changed while the timer matured.
|
|
if (
|
|
!this._connected ||
|
|
!targetID ||
|
|
targetID !== this._config.getTargetID() ||
|
|
this._attemptID !== this._config.getAttemptID?.() ||
|
|
this._mediaLoaded ||
|
|
!this._config.isLoadExpected()
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this._fired = true;
|
|
this._reportedTargetID = targetID;
|
|
fireAdvancedCameraCardEvent<IssueTriggerEventData>(this._host, 'issue:trigger', {
|
|
key: 'media_unavailable',
|
|
targetID,
|
|
reason: 'not_loading',
|
|
});
|
|
}
|
|
}
|