Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move automatic media actions out of carousels #1512

Merged
merged 2 commits into from
Sep 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const plugins = [
typescript({
sourceMap: dev,
inlineSources: dev,
exclude: ['tests/**/*.test.ts'],
exclude: ['dist/**', 'tests/**/*.test.ts'],
}),
json({ exclude: 'package.json' }),
replace({
Expand Down
269 changes: 269 additions & 0 deletions src/components-lib/media-actions-controller.ts
Original file line number Diff line number Diff line change
@@ -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');
},
);
}
};
}
Loading
Loading