289 lines
8.9 KiB
TypeScript
289 lines
8.9 KiB
TypeScript
import { MicrophoneState } from '../card-controller/types.js';
|
|
import {
|
|
AutoMuteCondition,
|
|
AutoPauseCondition,
|
|
AutoPlayCondition,
|
|
AutoUnmuteCondition,
|
|
} from '../config/schema/common/media-actions.js';
|
|
import { MediaPlayerElement } from '../types.js';
|
|
import { AdvancedCameraCardMediaLoadedEventTarget } 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[];
|
|
|
|
microphoneState?: MicrophoneState;
|
|
microphoneMuteSeconds?: number;
|
|
}
|
|
|
|
type RenderRoot = HTMLElement & AdvancedCameraCardMediaLoadedEventTarget;
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
|
|
type MediaActionsTarget = {
|
|
selected: boolean;
|
|
index: number;
|
|
};
|
|
|
|
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: MediaPlayerElement[] = [];
|
|
protected _target: MediaActionsTarget | null = null;
|
|
protected _mutationObserver = new MutationObserver(this._mutationHandler.bind(this));
|
|
protected _intersectionObserver = new IntersectionObserver(
|
|
this._intersectionHandler.bind(this),
|
|
);
|
|
|
|
constructor() {
|
|
document.addEventListener('visibilitychange', this._visibilityHandler);
|
|
}
|
|
|
|
public setOptions(options: MediaActionsControllerOptions): void {
|
|
if (this._options?.microphoneState !== options.microphoneState) {
|
|
this._microphoneStateChangeHandler(
|
|
this._options?.microphoneState,
|
|
options.microphoneState,
|
|
);
|
|
}
|
|
|
|
this._options = options;
|
|
}
|
|
|
|
public hasRoot(): boolean {
|
|
return !!this._root;
|
|
}
|
|
|
|
public destroy(): void {
|
|
this._viewportIntersecting = null;
|
|
this._microphoneMuteTimer.stop();
|
|
this._root = null;
|
|
this._removeChildHandlers();
|
|
this._children = [];
|
|
this._target = null;
|
|
this._mutationObserver.disconnect();
|
|
this._intersectionObserver.disconnect();
|
|
document.removeEventListener('visibilitychange', this._visibilityHandler);
|
|
}
|
|
|
|
public async setTarget(index: number, selected: boolean): Promise<void> {
|
|
if (this._target?.index === index && this._target?.selected === selected) {
|
|
return;
|
|
}
|
|
|
|
// If there's already a selected target, unselect it.
|
|
if (!!this._target?.selected) {
|
|
await this._pauseTargetIfConfigured('unselected');
|
|
await this._muteTargetIfConfigured('unselected');
|
|
this._microphoneMuteTimer.stop();
|
|
}
|
|
|
|
this._target = {
|
|
selected,
|
|
index,
|
|
};
|
|
|
|
if (selected) {
|
|
await this._unmuteTargetIfConfigured('selected');
|
|
await this._playTargetIfConfigured('selected');
|
|
} else {
|
|
await this._unmuteTargetIfConfigured('visible');
|
|
await this._playTargetIfConfigured('visible');
|
|
}
|
|
}
|
|
|
|
public unsetTarget(): void {
|
|
this._target = null;
|
|
}
|
|
|
|
protected async _playTargetIfConfigured(condition: AutoPlayCondition): Promise<void> {
|
|
if (
|
|
this._target !== null &&
|
|
this._options?.autoPlayConditions?.includes(condition)
|
|
) {
|
|
await this._play(this._target.index);
|
|
}
|
|
}
|
|
protected async _play(index: number): Promise<void> {
|
|
await (await this._children[index]?.getMediaPlayerController())?.play();
|
|
}
|
|
protected async _unmuteTargetIfConfigured(
|
|
condition: AutoUnmuteCondition,
|
|
): Promise<void> {
|
|
if (
|
|
this._target !== null &&
|
|
this._options?.autoUnmuteConditions?.includes(condition)
|
|
) {
|
|
await this._unmute(this._target.index);
|
|
}
|
|
}
|
|
protected async _unmute(index: number): Promise<void> {
|
|
await (await this._children[index]?.getMediaPlayerController())?.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 _pauseTargetIfConfigured(
|
|
condition: AutoPauseCondition,
|
|
): Promise<void> {
|
|
if (
|
|
this._target !== null &&
|
|
this._options?.autoPauseConditions?.includes(condition)
|
|
) {
|
|
await this._pause(this._target.index);
|
|
}
|
|
}
|
|
protected async _pause(index: number): Promise<void> {
|
|
await (await this._children[index]?.getMediaPlayerController())?.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 _muteTargetIfConfigured(condition: AutoMuteCondition): Promise<void> {
|
|
if (
|
|
this._target !== null &&
|
|
this._options?.autoMuteConditions?.includes(condition)
|
|
) {
|
|
await this._mute(this._target.index);
|
|
}
|
|
}
|
|
protected async _mute(index: number): Promise<void> {
|
|
await (await this._children[index]?.getMediaPlayerController())?.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._target?.index !== index) {
|
|
return;
|
|
}
|
|
await this._unmuteTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
|
|
await this._playTargetIfConfigured(this._target.selected ? 'selected' : 'visible');
|
|
};
|
|
|
|
protected _removeChildHandlers(): void {
|
|
for (const [child, callback] of this._eventListeners.entries()) {
|
|
child.removeEventListener('advanced-camera-card:media:loaded', callback);
|
|
}
|
|
this._eventListeners.clear();
|
|
}
|
|
|
|
public setRoot(root: RenderRoot): boolean {
|
|
if (root === this._root) {
|
|
return false;
|
|
}
|
|
|
|
this._target = null;
|
|
this._root = root;
|
|
this._initializeRoot();
|
|
|
|
this._intersectionObserver.disconnect();
|
|
this._intersectionObserver.observe(this._root);
|
|
|
|
this._mutationObserver.disconnect();
|
|
this._mutationObserver.observe(this._root, { childList: true, subtree: true });
|
|
return true;
|
|
}
|
|
|
|
protected _initializeRoot(): void {
|
|
if (!this._options || !this._root) {
|
|
return;
|
|
}
|
|
|
|
this._removeChildHandlers();
|
|
|
|
this._children = [
|
|
...this._root.querySelectorAll<MediaPlayerElement>(this._options.playerSelector),
|
|
];
|
|
|
|
for (const [index, child] of this._children.entries()) {
|
|
const eventListener = () => this._mediaLoadedHandler(index);
|
|
this._eventListeners.set(child, eventListener);
|
|
child.addEventListener('advanced-camera-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._unmuteTargetIfConfigured('visible');
|
|
await this._playTargetIfConfigured('visible');
|
|
} else {
|
|
await this._pauseAllIfConfigured('hidden');
|
|
await this._muteAllIfConfigured('hidden');
|
|
}
|
|
};
|
|
|
|
protected async _microphoneStateChangeHandler(
|
|
oldState?: MicrophoneState,
|
|
newState?: MicrophoneState,
|
|
): Promise<void> {
|
|
if (!oldState || !newState) {
|
|
return;
|
|
}
|
|
|
|
if (oldState.muted && !newState.muted) {
|
|
await this._unmuteTargetIfConfigured('microphone');
|
|
} else if (
|
|
!oldState.muted &&
|
|
newState.muted &&
|
|
this._options?.autoMuteConditions?.includes('microphone')
|
|
) {
|
|
this._microphoneMuteTimer.start(
|
|
this._options.microphoneMuteSeconds ?? 60,
|
|
async () => {
|
|
await this._muteTargetIfConfigured('microphone');
|
|
},
|
|
);
|
|
}
|
|
}
|
|
}
|