refactor: Refactor media loading manager for improved robustness (#2464)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:12 -07:00
committed by dermotduffy
parent 47bcce93d3
commit bc366626f1
51 changed files with 1897 additions and 1128 deletions
+12
View File
@@ -0,0 +1,12 @@
/**
* Register a cleanup callback to fire when an `AbortSignal` aborts. Unlike
* `signal.addEventListener('abort', cb)` directly, this fires the callback
* immediately if the signal is already aborted.
*/
export const onAbort = (signal: AbortSignal, callback: () => void): void => {
if (signal.aborted) {
callback();
} else {
signal.addEventListener('abort', callback, { once: true });
}
};
-20
View File
@@ -99,26 +99,6 @@ export class CarouselController {
return;
}
this._carousel.scrollTo(index, this._transitionEffect === 'none');
// This event exists to allow the caller to know the difference between
// programatically force slide selections and user-driven slide selections
// (e.g. carousel drags). See the note in auto-media-loaded-info.ts on how
// this is used.
const newSlide = this.getSlide(index);
/* istanbul ignore if: defensive guard for getSlide returning null which can
only happen with an index out of bounds, which is guarded against above --
@preserve */
if (newSlide) {
fireAdvancedCameraCardEvent<CarouselSelected>(
this._parent,
'carousel:force-select',
{
index: index,
element: newSlide,
},
);
}
}
private _refreshCarouselContents = (): void => {
@@ -1,143 +0,0 @@
import { EmblaCarouselType } from 'embla-carousel';
import { LooseOptionsType } from 'embla-carousel/components/Options';
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins';
import { MediaLoadedInfo } from '../../../../types';
import {
AdvancedCameraCardMediaLoadedEventTarget,
dispatchExistingMediaLoadedInfoAsEvent,
} from '../../../media-info';
declare module 'embla-carousel/components/Plugins' {
interface EmblaPluginsType {
autoMediaLoadedInfo?: AutoMediaLoadedInfoType;
}
}
/**
* On the relationship between carousel:select and carousel:force-select:
*
* There is a complex interplay here. `carousel:force-select` is an event
* dispatched by the carousel when it is forced to select a particular slide
* (i.e. the view has changed). `carousel:select` is dispatched for any
* selection -- forced or human (e.g. the user dragging the carousel).
*
* The media info should only be dispatched _after_ the view object has been
* updated (since the view will clear the loaded media info). The setting of the
* view (trigged by `carousel:select`) may require async fetches and may take a
* while -- and so if the card dispatched media on `carousel:selecte` then the
* media info may be dispatched before the view is set (which could result in
* the dispatched media immediately being cleared by the view).
*
* It is fine to have media info dispatched from the `carousel:init` event,
* since the carousel will be initialized based on a particular view object. In
* practice, the carousel will be initialized before the media is loaded, so
* there may not be anything to dispatch at that point.
*
* When media is loaded, that media loaded info will always be allowed to
* propogate upwards as long as it is selected.
*/
type AutoMediaLoadedInfoType = CreatePluginType<LoosePluginType, LooseOptionsType>;
function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType {
let emblaApi: EmblaCarouselType;
let slides: (HTMLElement & AdvancedCameraCardMediaLoadedEventTarget)[] = [];
const mediaLoadedInfo: MediaLoadedInfo[] = [];
function init(emblaApiInstance: EmblaCarouselType): void {
emblaApi = emblaApiInstance;
slides = emblaApi.slideNodes();
for (const slide of slides) {
slide.addEventListener(
'advanced-camera-card:media:loaded',
mediaLoadedInfoHandler,
);
slide.addEventListener(
'advanced-camera-card:media:unloaded',
mediaUnloadedInfoHandler,
);
}
emblaApi.on('init', slideSelectHandler);
emblaApi
.containerNode()
.addEventListener(
'advanced-camera-card:carousel:force-select',
slideSelectHandler,
);
}
function destroy(): void {
for (const slide of slides) {
slide.removeEventListener(
'advanced-camera-card:media:loaded',
mediaLoadedInfoHandler,
);
slide.removeEventListener(
'advanced-camera-card:media:unloaded',
mediaUnloadedInfoHandler,
);
}
emblaApi.off('init', slideSelectHandler);
emblaApi
.containerNode()
.removeEventListener(
'advanced-camera-card:carousel:force-select',
slideSelectHandler,
);
}
function mediaLoadedInfoHandler(ev: CustomEvent<MediaLoadedInfo>): void {
const eventPath = ev.composedPath();
// As an optimization, the most recent slide is the one at the end. That's
// where most users are spending time, so start the search there.
for (const [index, slide] of [...slides.entries()].reverse()) {
if (eventPath.includes(slide)) {
mediaLoadedInfo[index] = ev.detail;
if (index !== emblaApi.selectedScrollSnap()) {
ev.stopPropagation();
}
break;
}
}
}
function mediaUnloadedInfoHandler(ev: CustomEvent): void {
const eventPath = ev.composedPath();
for (const [index, slide] of slides.entries()) {
if (eventPath.includes(slide)) {
delete mediaLoadedInfo[index];
if (index !== emblaApi.selectedScrollSnap()) {
ev.stopPropagation();
}
break;
}
}
}
function slideSelectHandler(): void {
const index = emblaApi.selectedScrollSnap();
const savedMediaLoadedInfo: MediaLoadedInfo | undefined = mediaLoadedInfo[index];
if (savedMediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(
// Event is redispatched from source element.
slides[index],
savedMediaLoadedInfo,
);
}
}
const self: AutoMediaLoadedInfoType = {
name: 'autoMediaLoadedInfo',
options: {},
init,
destroy,
};
return self;
}
export default AutoMediaLoadedInfo;
+7 -83
View File
@@ -3,6 +3,7 @@ import {
MediaLoadedInfo,
MediaPlayerController,
MediaTechnology,
UntargetedMediaLoadedInfo,
} from '../types.js';
import { fireAdvancedCameraCardEvent } from './fire-advanced-camera-card-event.js';
@@ -10,9 +11,12 @@ const MEDIA_INFO_HEIGHT_CUTOFF = 50;
const MEDIA_INFO_WIDTH_CUTOFF = MEDIA_INFO_HEIGHT_CUTOFF;
/**
* Create a MediaLoadedInfo object.
* Create a MediaLoadedInfo object. `targetID` is intentionally NOT an option
* — it's owned by the source controller (`MediaLoadedInfoSourceController`)
* and injected at dispatch time, so leaves don't have to (and can't) plumb
* it through info construction.
* @param source An event or HTMLElement that should be used as a source.
* @returns A new MediaLoadedInfo object or null if one could not be created.
* @returns A new info or null if one could not be created.
*/
export function createMediaLoadedInfo(
source: Event | HTMLElement,
@@ -21,7 +25,7 @@ export function createMediaLoadedInfo(
capabilities?: MediaLoadedCapabilities;
technology?: MediaTechnology[];
},
): MediaLoadedInfo | null {
): UntargetedMediaLoadedInfo | null {
let target: HTMLElement | EventTarget;
if (source instanceof Event) {
target = source.composedPath()[0];
@@ -52,46 +56,6 @@ export function createMediaLoadedInfo(
return null;
}
/**
* Dispatch an Advanced Camera Card media loaded event.
* @param element The element to send the event.
* @param source An event or HTMLElement that should be used as a source.
*/
export function dispatchMediaLoadedEvent(
target: HTMLElement,
source: Event | HTMLElement,
options?: {
mediaPlayerController?: MediaPlayerController;
capabilities?: MediaLoadedCapabilities;
technology?: MediaTechnology[];
},
): void {
const mediaLoadedInfo = createMediaLoadedInfo(source, options);
if (mediaLoadedInfo) {
dispatchExistingMediaLoadedInfoAsEvent(target, mediaLoadedInfo);
}
}
/**
* Dispatch a pre-existing MediaLoadedInfo object as an event.
* @param element The element to send the event.
* @param mediaLoadedInfo The MediaLoadedInfo object to send.
*/
export function dispatchExistingMediaLoadedInfoAsEvent(
target: EventTarget,
mediaLoadedInfo: MediaLoadedInfo,
): void {
fireAdvancedCameraCardEvent<MediaLoadedInfo>(target, 'media:loaded', mediaLoadedInfo);
}
/**
* Dispatch a media unloaded event.
* @param element The element to send the event.
*/
export function dispatchMediaUnloadedEvent(element: HTMLElement): void {
fireAdvancedCameraCardEvent(element, 'media:unloaded');
}
export function dispatchMediaVolumeChangeEvent(target: HTMLElement): void {
fireAdvancedCameraCardEvent(target, 'media:volumechange');
}
@@ -114,43 +78,3 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean {
info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF
);
}
// Facilitates correct typing of event handlers.
export interface AdvancedCameraCardMediaLoadedEventTarget extends EventTarget {
addEventListener(
event: 'advanced-camera-card:media:loaded',
listener: (
this: AdvancedCameraCardMediaLoadedEventTarget,
ev: CustomEvent<MediaLoadedInfo>,
) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
event: 'advanced-camera-card:media:unloaded',
listener: (this: AdvancedCameraCardMediaLoadedEventTarget, ev: CustomEvent) => void,
options?: AddEventListenerOptions | boolean,
): void;
addEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: AddEventListenerOptions | boolean,
): void;
removeEventListener(
event: 'advanced-camera-card:media:loaded',
listener: (
this: AdvancedCameraCardMediaLoadedEventTarget,
ev: CustomEvent<MediaLoadedInfo>,
) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
event: 'advanced-camera-card:media:unloaded',
listener: (this: AdvancedCameraCardMediaLoadedEventTarget, ev: CustomEvent) => void,
options?: boolean | EventListenerOptions,
): void;
removeEventListener(
type: string,
callback: EventListenerOrEventListenerObject,
options?: boolean | EventListenerOptions,
): void;
}
+8 -3
View File
@@ -1,8 +1,9 @@
import { CameraManager } from '../camera-manager/manager';
import { PTZAction } from '../config/schema/actions/custom/ptz';
import { PTZCapabilities } from '../types';
import { View } from '../view/view';
import { getViewTargetID } from '../view/target-id';
import { View } from '../view/view';
import { getStreamCameraID } from './substream';
export type PTZType = 'digital' | 'ptz';
interface PTZTarget {
@@ -17,7 +18,12 @@ export const getPTZTarget = (
cameraManager?: CameraManager;
},
): PTZTarget | null => {
const targetID = getViewTargetID(view);
// PTZ is a playback-layer concern: for live, commands target the *actual*
// streaming camera (substream-aware), and capability checks must consult
// the substream too (a base camera with no native PTZ may still expose
// PTZ via its substream). For viewer/image, the logical view target is
// already correct.
const targetID = view.is('live') ? getStreamCameraID(view) : getViewTargetID(view);
if (!targetID) {
return null;
}
@@ -30,7 +36,6 @@ export const getPTZTarget = (
}
if (view.is('live')) {
let type: PTZType = 'digital';
if (options?.type !== 'digital' && options?.cameraManager) {
if (hasCameraTruePTZ(options.cameraManager, targetID)) {
type = 'ptz';