refactor: Refactor media loading manager for improved robustness (#2464)
This commit is contained in:
committed by
dermotduffy
parent
47bcce93d3
commit
bc366626f1
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user