Move automatic media actions out of carousels
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
import {
|
||||
MicrophoneManagerListenerChange,
|
||||
ReadonlyMicrophoneManager,
|
||||
} from '../card-controller/microphone-manager.js';
|
||||
import {
|
||||
AutoMuteCondition,
|
||||
AutoPauseCondition,
|
||||
AutoPlayCondition,
|
||||
AutoUnmuteCondition,
|
||||
} from '../config/types.js';
|
||||
import { FrigateCardMediaPlayer } from '../types.js';
|
||||
import { FrigateCardMediaLoadedEventTarget } from '../utils/media-info.js';
|
||||
import { Timer } from '../utils/timer.js';
|
||||
|
||||
export interface MediaActionsControllerOptions {
|
||||
playerSelector: string;
|
||||
|
||||
autoPlayConditions?: readonly AutoPlayCondition[];
|
||||
autoUnmuteConditions?: readonly AutoUnmuteCondition[];
|
||||
autoPauseConditions?: readonly AutoPauseCondition[];
|
||||
autoMuteConditions?: readonly AutoMuteCondition[];
|
||||
|
||||
microphoneManager?: ReadonlyMicrophoneManager;
|
||||
microphoneMuteSeconds?: number;
|
||||
}
|
||||
|
||||
type RenderRoot = HTMLElement & FrigateCardMediaLoadedEventTarget;
|
||||
type PlayerElement = HTMLElement & FrigateCardMediaPlayer;
|
||||
|
||||
/**
|
||||
* General note: Always unmute before playing, since Chrome may pause a piece of
|
||||
* media if the page hasn't been interacted with first, after unmute. By unmuting
|
||||
* first, even if the unmute call fails a subsequent call to play will still
|
||||
* start the video.
|
||||
*/
|
||||
|
||||
export class MediaActionsController {
|
||||
protected _options: MediaActionsControllerOptions | null = null;
|
||||
protected _viewportIntersecting: boolean | null = null;
|
||||
protected _microphoneMuteTimer = new Timer();
|
||||
protected _root: RenderRoot | null = null;
|
||||
|
||||
protected _eventListeners = new Map<HTMLElement, () => void>();
|
||||
protected _children: PlayerElement[] = [];
|
||||
protected _selected: number | null = null;
|
||||
protected _mutationObserver = new MutationObserver(this._mutationHandler.bind(this));
|
||||
protected _intersectionObserver = new IntersectionObserver(
|
||||
this._intersectionHandler.bind(this),
|
||||
);
|
||||
|
||||
public setOptions(options: MediaActionsControllerOptions): void {
|
||||
this._options = options;
|
||||
|
||||
if (this._options?.microphoneManager) {
|
||||
this._options.microphoneManager.removeListener(this._microphoneChangeHandler);
|
||||
this._options.microphoneManager.addListener(this._microphoneChangeHandler);
|
||||
}
|
||||
}
|
||||
|
||||
public hasRoot(): boolean {
|
||||
return !!this._root;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._viewportIntersecting = null;
|
||||
this._microphoneMuteTimer.stop();
|
||||
this._root = null;
|
||||
this._removeChildHandlers();
|
||||
this._children = [];
|
||||
this._selected = null;
|
||||
this._mutationObserver.disconnect();
|
||||
this._intersectionObserver.disconnect();
|
||||
this._options?.microphoneManager?.removeListener(this._microphoneChangeHandler);
|
||||
document.removeEventListener('visibilitychange', this._visibilityHandler);
|
||||
}
|
||||
|
||||
public async select(index: number): Promise<void> {
|
||||
if (this._selected === index) {
|
||||
return;
|
||||
}
|
||||
if (this._selected !== null) {
|
||||
await this.unselect();
|
||||
}
|
||||
this._selected = index;
|
||||
await this._unmuteSelectedIfConfigured('selected');
|
||||
await this._playSelectedIfConfigured('selected');
|
||||
}
|
||||
|
||||
public async unselect(): Promise<void> {
|
||||
await this._pauseSelectedIfConfigured('unselected');
|
||||
await this._muteSelectedIfConfigured('unselected');
|
||||
this._microphoneMuteTimer.stop();
|
||||
this._selected = null;
|
||||
}
|
||||
|
||||
public async unselectAll(): Promise<void> {
|
||||
this._selected = null;
|
||||
await this._pauseAllIfConfigured('unselected');
|
||||
await this._muteAllIfConfigured('unselected');
|
||||
}
|
||||
|
||||
protected async _playSelectedIfConfigured(
|
||||
condition: AutoPlayCondition,
|
||||
): Promise<void> {
|
||||
if (
|
||||
this._selected !== null &&
|
||||
this._options?.autoPlayConditions?.includes(condition)
|
||||
) {
|
||||
await this._play(this._selected);
|
||||
}
|
||||
}
|
||||
protected async _play(index: number): Promise<void> {
|
||||
await this._children[index]?.play();
|
||||
}
|
||||
protected async _unmuteSelectedIfConfigured(
|
||||
condition: AutoUnmuteCondition,
|
||||
): Promise<void> {
|
||||
if (
|
||||
this._selected !== null &&
|
||||
this._options?.autoUnmuteConditions?.includes(condition)
|
||||
) {
|
||||
await this._unmute(this._selected);
|
||||
}
|
||||
}
|
||||
protected async _unmute(index: number): Promise<void> {
|
||||
await this._children[index]?.unmute();
|
||||
}
|
||||
|
||||
protected async _pauseAllIfConfigured(condition: AutoPauseCondition): Promise<void> {
|
||||
if (this._options?.autoPauseConditions?.includes(condition)) {
|
||||
for (const index of this._children.keys()) {
|
||||
await this._pause(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
protected async _pauseSelectedIfConfigured(
|
||||
condition: AutoPauseCondition,
|
||||
): Promise<void> {
|
||||
if (
|
||||
this._selected !== null &&
|
||||
this._options?.autoPauseConditions?.includes(condition)
|
||||
) {
|
||||
await this._pause(this._selected);
|
||||
}
|
||||
}
|
||||
protected async _pause(index: number): Promise<void> {
|
||||
await this._children[index]?.pause();
|
||||
}
|
||||
|
||||
protected async _muteAllIfConfigured(condition: AutoMuteCondition): Promise<void> {
|
||||
if (this._options?.autoMuteConditions?.includes(condition)) {
|
||||
for (const index of this._children.keys()) {
|
||||
await this._mute(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
protected async _muteSelectedIfConfigured(
|
||||
condition: AutoMuteCondition,
|
||||
): Promise<void> {
|
||||
if (
|
||||
this._selected !== null &&
|
||||
this._options?.autoMuteConditions?.includes(condition)
|
||||
) {
|
||||
await this._mute(this._selected);
|
||||
}
|
||||
}
|
||||
protected async _mute(index: number): Promise<void> {
|
||||
await this._children[index]?.mute();
|
||||
}
|
||||
|
||||
protected _mutationHandler(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_mutations: MutationRecord[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_observer: MutationObserver,
|
||||
): void {
|
||||
this._initializeRoot();
|
||||
}
|
||||
|
||||
protected _mediaLoadedHandler = async (index: number): Promise<void> => {
|
||||
if (this._selected !== index) {
|
||||
return;
|
||||
}
|
||||
await this._unmuteSelectedIfConfigured('selected');
|
||||
await this._playSelectedIfConfigured('selected');
|
||||
};
|
||||
|
||||
protected _removeChildHandlers(): void {
|
||||
for (const [child, callback] of this._eventListeners.entries()) {
|
||||
child.removeEventListener('frigate-card:media:loaded', callback);
|
||||
}
|
||||
this._eventListeners.clear();
|
||||
}
|
||||
|
||||
public initialize(root: RenderRoot): void {
|
||||
this._root = root;
|
||||
this._initializeRoot();
|
||||
|
||||
document.addEventListener('visibilitychange', this._visibilityHandler);
|
||||
|
||||
this._intersectionObserver.disconnect();
|
||||
this._intersectionObserver.observe(root);
|
||||
|
||||
this._mutationObserver.disconnect();
|
||||
this._mutationObserver.observe(this._root, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
protected _initializeRoot(): void {
|
||||
if (!this._options || !this._root) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._removeChildHandlers();
|
||||
|
||||
this._children = [
|
||||
...this._root.querySelectorAll<PlayerElement>(this._options.playerSelector),
|
||||
];
|
||||
|
||||
for (const [index, child] of this._children.entries()) {
|
||||
const eventListener = () => this._mediaLoadedHandler(index);
|
||||
this._eventListeners.set(child, eventListener);
|
||||
child.addEventListener('frigate-card:media:loaded', eventListener);
|
||||
}
|
||||
}
|
||||
protected async _intersectionHandler(
|
||||
entries: IntersectionObserverEntry[],
|
||||
): Promise<void> {
|
||||
const wasIntersecting = this._viewportIntersecting;
|
||||
this._viewportIntersecting = entries.some((entry) => entry.isIntersecting);
|
||||
|
||||
if (wasIntersecting !== null && wasIntersecting !== this._viewportIntersecting) {
|
||||
// If the live view is preloaded (i.e. in the background) we may need to
|
||||
// take media actions, e.g. muting a live stream that is now running in
|
||||
// the background, so we act even if the new state is hidden.
|
||||
await this._changeVisibility(this._viewportIntersecting);
|
||||
}
|
||||
}
|
||||
|
||||
protected _visibilityHandler = async (): Promise<void> => {
|
||||
await this._changeVisibility(document.visibilityState === 'visible');
|
||||
};
|
||||
|
||||
protected _changeVisibility = async (visible: boolean): Promise<void> => {
|
||||
if (visible) {
|
||||
await this._unmuteSelectedIfConfigured('visible');
|
||||
await this._playSelectedIfConfigured('visible');
|
||||
} else {
|
||||
await this._pauseAllIfConfigured('hidden');
|
||||
await this._muteAllIfConfigured('hidden');
|
||||
}
|
||||
};
|
||||
protected _microphoneChangeHandler = async (
|
||||
change: MicrophoneManagerListenerChange,
|
||||
): Promise<void> => {
|
||||
if (change === 'unmuted') {
|
||||
await this._unmuteSelectedIfConfigured('microphone');
|
||||
} else if (
|
||||
change === 'muted' &&
|
||||
this._options?.autoMuteConditions?.includes('microphone')
|
||||
) {
|
||||
this._microphoneMuteTimer.start(
|
||||
this._options.microphoneMuteSeconds ?? 60,
|
||||
async () => {
|
||||
await this._muteSelectedIfConfigured('microphone');
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
+58
-21
@@ -47,7 +47,6 @@ import { stopEventFromActivatingCardWideActions } from '../../utils/action.js';
|
||||
import { aspectRatioToString, contentsChanged } from '../../utils/basic.js';
|
||||
import { CarouselSelected } from '../../utils/embla/carousel-controller.js';
|
||||
import { AutoLazyLoad } from '../../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
|
||||
import { AutoMediaActions } from '../../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
|
||||
import AutoMediaLoadedInfo from '../../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
|
||||
import AutoSize from '../../utils/embla/plugins/auto-size/auto-size.js';
|
||||
import { getStateObjOrDispatchError } from '../../utils/get-state-obj.js';
|
||||
@@ -62,6 +61,7 @@ import '../next-prev-control.js';
|
||||
import '../ptz.js';
|
||||
import { FrigateCardPTZ } from '../ptz.js';
|
||||
import '../surround.js';
|
||||
import { MediaActionsController } from '../../components-lib/media-actions-controller.js';
|
||||
|
||||
const FRIGATE_CARD_LIVE_PROVIDER = 'frigate-card-live-provider';
|
||||
|
||||
@@ -290,10 +290,33 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
// Index between camera name and slide number.
|
||||
protected _cameraToSlide: Record<string, number> = {};
|
||||
protected _refPTZControl: Ref<FrigateCardPTZ> = createRef();
|
||||
protected _refCarousel: Ref<HTMLElement> = createRef();
|
||||
|
||||
protected _mediaActionsController = new MediaActionsController();
|
||||
|
||||
@state()
|
||||
protected _mediaHasLoaded = false;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
// Request update in order to reinitialize the media action controller.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._mediaActionsController.destroy();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (!this._mediaActionsController.hasRoot() && this._refCarousel.value) {
|
||||
this._mediaActionsController.initialize(this._refCarousel.value);
|
||||
}
|
||||
}
|
||||
|
||||
protected _getTransitionEffect(): TransitionEffect {
|
||||
return (
|
||||
this.overriddenLiveConfig?.transition_effect ??
|
||||
@@ -312,19 +335,12 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
return Math.max(0, Array.from(cameraIDs).indexOf(view.camera));
|
||||
}
|
||||
|
||||
protected _getPlugins(): EmblaCarouselPlugins {
|
||||
return [
|
||||
AutoLazyLoad({
|
||||
...(this.overriddenLiveConfig?.lazy_load && {
|
||||
lazyLoadCallback: (index, slide) =>
|
||||
this._lazyloadOrUnloadSlide('load', index, slide),
|
||||
}),
|
||||
lazyUnloadConditions: this.overriddenLiveConfig?.lazy_unload,
|
||||
lazyUnloadCallback: (index, slide) =>
|
||||
this._lazyloadOrUnloadSlide('unload', index, slide),
|
||||
}),
|
||||
AutoMediaLoadedInfo(),
|
||||
AutoMediaActions({
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (
|
||||
changedProps.has('microphoneManager') ||
|
||||
changedProps.has('overriddenLiveConfig')
|
||||
) {
|
||||
this._mediaActionsController.setOptions({
|
||||
playerSelector: FRIGATE_CARD_LIVE_PROVIDER,
|
||||
...(this.overriddenLiveConfig?.auto_play && {
|
||||
autoPlayConditions: this.overriddenLiveConfig.auto_play,
|
||||
@@ -344,7 +360,33 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
microphoneMuteSeconds:
|
||||
this.overriddenLiveConfig.microphone.mute_after_microphone_mute_seconds,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (changedProps.has('viewManagerEpoch')) {
|
||||
if (
|
||||
this.viewFilterCameraID &&
|
||||
this.viewManagerEpoch?.manager.getView()?.camera !== this.viewFilterCameraID
|
||||
) {
|
||||
this._mediaActionsController.unselectAll();
|
||||
} else {
|
||||
this._mediaActionsController.select(this._getSelectedCameraIndex());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected _getPlugins(): EmblaCarouselPlugins {
|
||||
return [
|
||||
AutoLazyLoad({
|
||||
...(this.overriddenLiveConfig?.lazy_load && {
|
||||
lazyLoadCallback: (index, slide) =>
|
||||
this._lazyloadOrUnloadSlide('load', index, slide),
|
||||
}),
|
||||
lazyUnloadConditions: this.overriddenLiveConfig?.lazy_unload,
|
||||
lazyUnloadCallback: (index, slide) =>
|
||||
this._lazyloadOrUnloadSlide('unload', index, slide),
|
||||
}),
|
||||
AutoMediaLoadedInfo(),
|
||||
AutoSize(),
|
||||
];
|
||||
}
|
||||
@@ -541,15 +583,10 @@ export class FrigateCardLiveCarousel extends LitElement {
|
||||
// Notes on the below:
|
||||
// - guard() is used to avoid reseting the carousel unless the
|
||||
// options/plugins actually change.
|
||||
// - the 'carousel:settle' event is listened for (instead of
|
||||
// 'carousel:select') to only trigger the view change (which subsequently
|
||||
// fetches thumbnails) after the carousel has stopped moving. This gives a
|
||||
// much smoother carousel experience since network fetches are not at the
|
||||
// same time as carousel movement (at a cost of fetching thumbnails a
|
||||
// little later).
|
||||
|
||||
return html`
|
||||
<frigate-card-carousel
|
||||
${ref(this._refCarousel)}
|
||||
.loop=${hasMultipleCameras}
|
||||
.dragEnabled=${hasMultipleCameras && this.overriddenLiveConfig?.draggable}
|
||||
.plugins=${guard(
|
||||
@@ -748,7 +785,7 @@ export class FrigateCardLiveProvider
|
||||
);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
public disconnectedCallback(): void {
|
||||
this._isVideoMediaLoaded = false;
|
||||
}
|
||||
|
||||
|
||||
+44
-20
@@ -13,6 +13,7 @@ import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { RemoveContextPropertyViewModifier } from '../card-controller/view/modifiers/remove-context-property.js';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||
import { MediaActionsController } from '../components-lib/media-actions-controller.js';
|
||||
import { MediaGridSelected } from '../components-lib/media-grid-controller.js';
|
||||
import { ZoomSettingsObserved } from '../components-lib/zoom/types.js';
|
||||
import { handleZoomSettingsObservedEvent } from '../components-lib/zoom/zoom-view-context.js';
|
||||
@@ -47,7 +48,6 @@ import {
|
||||
} from '../utils/basic.js';
|
||||
import { CarouselSelected } from '../utils/embla/carousel-controller.js';
|
||||
import { AutoLazyLoad } from '../utils/embla/plugins/auto-lazy-load/auto-lazy-load.js';
|
||||
import { AutoMediaActions } from '../utils/embla/plugins/auto-media-actions/auto-media-actions.js';
|
||||
import AutoMediaLoadedInfo from '../utils/embla/plugins/auto-media-loaded-info/auto-media-loaded-info.js';
|
||||
import AutoSize from '../utils/embla/plugins/auto-size/auto-size.js';
|
||||
import { canonicalizeHAURL } from '../utils/ha/index.js';
|
||||
@@ -189,12 +189,10 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
protected _selected = 0;
|
||||
|
||||
protected _media: ViewMedia[] | null = null;
|
||||
protected _mediaActionsController = new MediaActionsController();
|
||||
protected _player: FrigateCardMediaPlayer | null = null;
|
||||
protected _refCarousel: Ref<HTMLElement> = createRef();
|
||||
|
||||
/**
|
||||
* The updated lifecycle callback for this element.
|
||||
* @param changedProperties The properties that were changed in this render.
|
||||
*/
|
||||
updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
|
||||
@@ -209,6 +207,22 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
this._seekHandler();
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._mediaActionsController.hasRoot() && this._refCarousel.value) {
|
||||
this._mediaActionsController.initialize(this._refCarousel.value);
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
// Request update in order to reinitialize the media action controller.
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
this._mediaActionsController.destroy();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,21 +248,6 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
}),
|
||||
}),
|
||||
AutoMediaLoadedInfo(),
|
||||
AutoMediaActions({
|
||||
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
|
||||
...(this.viewerConfig?.auto_play && {
|
||||
autoPlayConditions: this.viewerConfig.auto_play,
|
||||
}),
|
||||
...(this.viewerConfig?.auto_pause && {
|
||||
autoPauseConditions: this.viewerConfig.auto_pause,
|
||||
}),
|
||||
...(this.viewerConfig?.auto_mute && {
|
||||
autoMuteConditions: this.viewerConfig.auto_mute,
|
||||
}),
|
||||
...(this.viewerConfig?.auto_unmute && {
|
||||
autoUnmuteConditions: this.viewerConfig.auto_unmute,
|
||||
}),
|
||||
}),
|
||||
AutoSize(),
|
||||
];
|
||||
}
|
||||
@@ -356,6 +355,24 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (changedProps.has('viewerConfig')) {
|
||||
this._mediaActionsController.setOptions({
|
||||
playerSelector: FRIGATE_CARD_VIEWER_PROVIDER,
|
||||
...(this.viewerConfig?.auto_play && {
|
||||
autoPlayConditions: this.viewerConfig.auto_play,
|
||||
}),
|
||||
...(this.viewerConfig?.auto_pause && {
|
||||
autoPauseConditions: this.viewerConfig.auto_pause,
|
||||
}),
|
||||
...(this.viewerConfig?.auto_mute && {
|
||||
autoMuteConditions: this.viewerConfig.auto_mute,
|
||||
}),
|
||||
...(this.viewerConfig?.auto_unmute && {
|
||||
autoUnmuteConditions: this.viewerConfig.auto_unmute,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (changedProps.has('viewManagerEpoch')) {
|
||||
const view = this.viewManagerEpoch?.manager.getView();
|
||||
const newMedia = view?.queryResults?.getResults(this.viewFilterCameraID) ?? null;
|
||||
@@ -368,6 +385,12 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
this._media = newMedia;
|
||||
this._selected = newSelected;
|
||||
}
|
||||
|
||||
if (!newMedia) {
|
||||
this._mediaActionsController.unselectAll();
|
||||
} else {
|
||||
this._mediaActionsController.select(newSelected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +428,7 @@ export class FrigateCardViewerCarousel extends LitElement {
|
||||
|
||||
return html`
|
||||
<frigate-card-carousel
|
||||
${ref(this._refCarousel)}
|
||||
.dragEnabled=${this.viewerConfig?.draggable ?? true}
|
||||
.plugins=${guard([this.viewerConfig, this._media], this._getPlugins.bind(this))}
|
||||
.selected=${this._selected}
|
||||
|
||||
+3
-3
@@ -62,15 +62,15 @@ export const MEDIA_ACTION_NEGATIVE_CONDITIONS = ['unselected', 'hidden'] as cons
|
||||
export type LazyUnloadCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
|
||||
export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number];
|
||||
|
||||
export const MEDIA_ACTION_POSITIVE_CONDITIONS = ['selected', 'visible'] as const;
|
||||
const MEDIA_ACTION_POSITIVE_CONDITIONS = ['selected', 'visible'] as const;
|
||||
export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
|
||||
export const MEDIA_UNMUTE_CONDITIONS = [
|
||||
const MEDIA_UNMUTE_CONDITIONS = [
|
||||
...MEDIA_ACTION_POSITIVE_CONDITIONS,
|
||||
'microphone',
|
||||
] as const;
|
||||
export type AutoUnmuteCondition = (typeof MEDIA_UNMUTE_CONDITIONS)[number];
|
||||
|
||||
export const MEDIA_MUTE_CONDITIONS = [
|
||||
const MEDIA_MUTE_CONDITIONS = [
|
||||
...MEDIA_ACTION_NEGATIVE_CONDITIONS,
|
||||
'microphone',
|
||||
] as const;
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/*******************************************************
|
||||
* Non-hover styles should not interact with the pointer
|
||||
*******************************************************/
|
||||
|
||||
:host(:not([data-style*='hover'])) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/***********************************
|
||||
* Aligned divs: matching & opposing
|
||||
***********************************/
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
import { EmblaCarouselType } from 'embla-carousel';
|
||||
import { CreateOptionsType } from 'embla-carousel/components/Options.js';
|
||||
import { OptionsHandlerType } from 'embla-carousel/components/OptionsHandler.js';
|
||||
import { CreatePluginType, LoosePluginType } from 'embla-carousel/components/Plugins.js';
|
||||
import {
|
||||
MicrophoneManagerListenerChange,
|
||||
ReadonlyMicrophoneManager,
|
||||
} from '../../../../card-controller/microphone-manager.js';
|
||||
import {
|
||||
AutoMuteCondition,
|
||||
AutoPauseCondition,
|
||||
AutoPlayCondition,
|
||||
AutoUnmuteCondition,
|
||||
} from '../../../../config/types.js';
|
||||
import { FrigateCardMediaPlayer } from '../../../../types.js';
|
||||
import { Timer } from '../../../timer.js';
|
||||
|
||||
declare module 'embla-carousel/components/Plugins' {
|
||||
interface EmblaPluginsType {
|
||||
autoMediaActions?: AutoMediaActionsType;
|
||||
}
|
||||
}
|
||||
|
||||
type OptionsType = CreateOptionsType<{
|
||||
playerSelector?: string;
|
||||
|
||||
autoPlayConditions?: readonly AutoPlayCondition[];
|
||||
autoUnmuteConditions?: readonly AutoUnmuteCondition[];
|
||||
autoPauseConditions?: readonly AutoPauseCondition[];
|
||||
autoMuteConditions?: readonly AutoMuteCondition[];
|
||||
|
||||
microphoneManager?: ReadonlyMicrophoneManager;
|
||||
microphoneMuteSeconds?: number;
|
||||
}>;
|
||||
export type AutoMediaActionsOptionsType = Partial<OptionsType>;
|
||||
|
||||
const defaultOptions: OptionsType = {
|
||||
active: true,
|
||||
breakpoints: {},
|
||||
};
|
||||
|
||||
export type AutoMediaActionsType = CreatePluginType<
|
||||
LoosePluginType,
|
||||
AutoMediaActionsOptionsType
|
||||
>;
|
||||
|
||||
export function AutoMediaActions(
|
||||
userOptions: AutoMediaActionsOptionsType = {},
|
||||
): AutoMediaActionsType {
|
||||
let options: OptionsType;
|
||||
let emblaApi: EmblaCarouselType;
|
||||
let slides: HTMLElement[];
|
||||
let viewportIntersecting: boolean | null = null;
|
||||
const microphoneMuteTimer = new Timer();
|
||||
|
||||
const intersectionObserver: IntersectionObserver = new IntersectionObserver(
|
||||
intersectionHandler,
|
||||
);
|
||||
|
||||
function init(
|
||||
emblaApiInstance: EmblaCarouselType,
|
||||
optionsHandler: OptionsHandlerType,
|
||||
): void {
|
||||
emblaApi = emblaApiInstance;
|
||||
|
||||
const { mergeOptions, optionsAtMedia } = optionsHandler;
|
||||
options = optionsAtMedia(mergeOptions(defaultOptions, userOptions));
|
||||
|
||||
slides = emblaApi.slideNodes();
|
||||
|
||||
if (options.autoPlayConditions?.includes('selected')) {
|
||||
// Auto play when the media loads not necessarily when the slide is
|
||||
// selected (to allow for lazyloading).
|
||||
emblaApi.containerNode().addEventListener('frigate-card:media:loaded', play);
|
||||
}
|
||||
|
||||
if (options.autoUnmuteConditions?.includes('selected')) {
|
||||
// Auto unmute when the media loads not necessarily when the slide is
|
||||
// selected (to allow for lazyloading).
|
||||
emblaApi.containerNode().addEventListener('frigate-card:media:loaded', unmute);
|
||||
}
|
||||
|
||||
if (options.autoPauseConditions?.includes('unselected')) {
|
||||
emblaApi.on('select', pausePrevious);
|
||||
}
|
||||
|
||||
if (options.autoMuteConditions?.includes('unselected')) {
|
||||
emblaApi.on('select', mutePrevious);
|
||||
}
|
||||
|
||||
emblaApi.on('destroy', pause);
|
||||
emblaApi.on('destroy', mute);
|
||||
|
||||
document.addEventListener('visibilitychange', visibilityHandler);
|
||||
intersectionObserver.observe(emblaApi.rootNode());
|
||||
|
||||
if (
|
||||
options.autoUnmuteConditions?.includes('microphone') ||
|
||||
options.autoMuteConditions?.includes('microphone')
|
||||
) {
|
||||
// For some reason mergeOptions() appears to break mock objects passed in,
|
||||
// so unittesting doesn't work when using options (vs userOptions where it
|
||||
// does).
|
||||
userOptions.microphoneManager?.addListener(microphoneChangeHandler);
|
||||
|
||||
// Stop the microphone mute timer if the media changes.
|
||||
emblaApi
|
||||
.containerNode()
|
||||
.addEventListener('frigate-card:media:loaded', stopMicrophoneTimer);
|
||||
}
|
||||
}
|
||||
|
||||
function stopMicrophoneTimer(): void {
|
||||
microphoneMuteTimer.stop();
|
||||
}
|
||||
|
||||
function microphoneChangeHandler(change: MicrophoneManagerListenerChange): void {
|
||||
if (change === 'unmuted' && options.autoUnmuteConditions?.includes('microphone')) {
|
||||
unmute();
|
||||
} else if (
|
||||
change === 'muted' &&
|
||||
options.autoMuteConditions?.includes('microphone')
|
||||
) {
|
||||
microphoneMuteTimer.start(options.microphoneMuteSeconds ?? 60, () => {
|
||||
mute();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
if (options.autoPlayConditions?.includes('selected')) {
|
||||
emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', play);
|
||||
}
|
||||
|
||||
if (options.autoUnmuteConditions?.includes('selected')) {
|
||||
emblaApi.containerNode().removeEventListener('frigate-card:media:loaded', unmute);
|
||||
}
|
||||
|
||||
if (options.autoPauseConditions?.includes('unselected')) {
|
||||
emblaApi.off('select', pausePrevious);
|
||||
}
|
||||
|
||||
if (options.autoMuteConditions?.includes('unselected')) {
|
||||
emblaApi.off('select', mutePrevious);
|
||||
}
|
||||
|
||||
emblaApi.off('destroy', pause);
|
||||
emblaApi.off('destroy', mute);
|
||||
|
||||
document.removeEventListener('visibilitychange', visibilityHandler);
|
||||
intersectionObserver.disconnect();
|
||||
|
||||
if (
|
||||
options.autoUnmuteConditions?.includes('microphone') ||
|
||||
options.autoMuteConditions?.includes('microphone')
|
||||
) {
|
||||
userOptions.microphoneManager?.removeListener(microphoneChangeHandler);
|
||||
emblaApi
|
||||
.containerNode()
|
||||
.removeEventListener('frigate-card:media:loaded', stopMicrophoneTimer);
|
||||
}
|
||||
}
|
||||
|
||||
function actOnVisibilityChange(visible: boolean): void {
|
||||
if (visible) {
|
||||
if (options.autoPlayConditions?.includes('visible')) {
|
||||
play();
|
||||
}
|
||||
if (options.autoUnmuteConditions?.includes('visible')) {
|
||||
unmute();
|
||||
}
|
||||
} else {
|
||||
if (options.autoPauseConditions?.includes('hidden')) {
|
||||
pauseAll();
|
||||
}
|
||||
if (options.autoMuteConditions?.includes('hidden')) {
|
||||
muteAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function visibilityHandler(): void {
|
||||
actOnVisibilityChange(document.visibilityState === 'visible');
|
||||
}
|
||||
|
||||
function intersectionHandler(entries: IntersectionObserverEntry[]): void {
|
||||
const wasIntersecting = viewportIntersecting;
|
||||
viewportIntersecting = entries.some((entry) => entry.isIntersecting);
|
||||
|
||||
if (wasIntersecting !== null && wasIntersecting !== viewportIntersecting) {
|
||||
// If the live view is preloaded (i.e. in the background) we may need to
|
||||
// take media actions, e.g. muting a live stream that is now running in
|
||||
// the background, so we act even if the new state is hidden.
|
||||
actOnVisibilityChange(viewportIntersecting);
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayer(slide: HTMLElement | undefined): FrigateCardMediaPlayer | null {
|
||||
return options.playerSelector
|
||||
? (slide?.querySelector(options.playerSelector) as FrigateCardMediaPlayer | null)
|
||||
: null;
|
||||
}
|
||||
|
||||
function play(): void {
|
||||
getPlayer(slides[emblaApi.selectedScrollSnap()])?.play();
|
||||
}
|
||||
|
||||
function pause(): void {
|
||||
getPlayer(slides[emblaApi.selectedScrollSnap()])?.pause();
|
||||
}
|
||||
|
||||
function pausePrevious(): void {
|
||||
getPlayer(slides[emblaApi.previousScrollSnap()])?.pause();
|
||||
}
|
||||
|
||||
function pauseAll(): void {
|
||||
for (const slide of slides) {
|
||||
getPlayer(slide)?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function unmute(): void {
|
||||
getPlayer(slides[emblaApi.selectedScrollSnap()])?.unmute();
|
||||
}
|
||||
|
||||
function mute(): void {
|
||||
getPlayer(slides[emblaApi.selectedScrollSnap()])?.mute();
|
||||
}
|
||||
|
||||
function mutePrevious(): void {
|
||||
getPlayer(slides[emblaApi.previousScrollSnap()])?.mute();
|
||||
}
|
||||
|
||||
function muteAll(): void {
|
||||
for (const slide of slides) {
|
||||
getPlayer(slide)?.mute();
|
||||
}
|
||||
}
|
||||
|
||||
const self: AutoMediaActionsType = {
|
||||
name: 'autoMediaActions',
|
||||
options: userOptions,
|
||||
init,
|
||||
destroy,
|
||||
};
|
||||
return self;
|
||||
}
|
||||
@@ -46,7 +46,9 @@ function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType {
|
||||
function mediaLoadedInfoHandler(ev: CustomEvent<MediaLoadedInfo>): void {
|
||||
const eventPath = ev.composedPath();
|
||||
|
||||
for (const [index, slide] of slides.entries()) {
|
||||
// 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()) {
|
||||
@@ -76,7 +78,8 @@ function AutoMediaLoadedInfo(): AutoMediaLoadedInfoType {
|
||||
const savedMediaLoadedInfo: MediaLoadedInfo | undefined = mediaLoadedInfo[index];
|
||||
if (savedMediaLoadedInfo) {
|
||||
dispatchExistingMediaLoadedInfoAsEvent(
|
||||
emblaApi.containerNode(),
|
||||
// Event is redispatched from source element.
|
||||
slides[index],
|
||||
savedMediaLoadedInfo,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user