Merge pull request #1170 from dermotduffy/timers

Refactor timers into a simple tested object
This commit is contained in:
Dermot Duffy
2023-05-20 10:24:16 -07:00
committed by GitHub
9 changed files with 154 additions and 105 deletions
+13 -19
View File
@@ -11,6 +11,7 @@ import {
DirectiveParameters,
} from 'lit/directive.js';
import { stopEventFromActivatingCardWideActions } from './utils/action.js';
import { Timer } from './utils/timer.js';
interface ActionHandler extends HTMLElement {
holdTime: number;
@@ -25,14 +26,13 @@ interface FrigateCardActionHandlerOptions extends ActionHandlerOptions {
}
class ActionHandler extends HTMLElement implements ActionHandler {
public holdTime = 400;
public holdTime = 0.4;
protected timer?: number;
protected holdTimer = new Timer();
protected doubleClickTimer = new Timer();
protected held = false;
private dblClickTimeout?: number;
public connectedCallback(): void {
[
'touchcancel',
@@ -46,10 +46,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
document.addEventListener(
ev,
() => {
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
}
this.holdTimer.stop();
},
{ passive: true },
);
@@ -79,9 +76,9 @@ class ActionHandler extends HTMLElement implements ActionHandler {
const start = (): void => {
this.held = false;
this.timer = window.setTimeout(() => {
this.holdTimer.start(this.holdTime, () => {
this.held = true;
}, this.holdTime);
});
fireEvent(element, 'action', { action: 'start_tap' });
};
@@ -103,8 +100,7 @@ class ActionHandler extends HTMLElement implements ActionHandler {
return;
}
clearTimeout(this.timer);
this.timer = undefined;
this.holdTimer.stop();
fireEvent(element, 'action', { action: 'end_tap' });
@@ -113,15 +109,13 @@ class ActionHandler extends HTMLElement implements ActionHandler {
} else if (options?.hasDoubleClick) {
if (
(ev.type === 'click' && (ev as MouseEvent).detail < 2) ||
!this.dblClickTimeout
!this.doubleClickTimer.isRunning()
) {
this.dblClickTimeout = window.setTimeout(() => {
this.dblClickTimeout = undefined;
fireEvent(element, 'action', { action: 'tap' });
}, 250);
this.doubleClickTimer.start(0.25, () =>
fireEvent(element, 'action', { action: 'tap' }),
);
} else {
clearTimeout(this.dblClickTimeout);
this.dblClickTimeout = undefined;
this.doubleClickTimer.stop();
fireEvent(element, 'action', { action: 'double_tap' });
}
} else {
+7 -7
View File
@@ -1,4 +1,5 @@
import { ReactiveController, ReactiveControllerHost } from 'lit';
import { Timer } from './utils/timer';
export class CachedValueController<T> implements ReactiveController {
protected _value?: T;
@@ -7,7 +8,7 @@ export class CachedValueController<T> implements ReactiveController {
protected _callback: () => T;
protected _timerStartCallback?: () => void;
protected _timerStopCallback?: () => void;
protected _timerID?: number;
protected _timer = new Timer();
constructor(
host: ReactiveControllerHost,
@@ -56,11 +57,10 @@ export class CachedValueController<T> implements ReactiveController {
* Disable the timer.
*/
public stopTimer(): void {
if (this._timerID !== undefined) {
window.clearInterval(this._timerID);
if (this._timer.isRunning()) {
this._timer.stop();
this._timerStopCallback?.();
}
this._timerID = undefined;
}
/**
@@ -71,15 +71,15 @@ export class CachedValueController<T> implements ReactiveController {
if (this._timerSeconds > 0) {
this._timerStartCallback?.();
this._timerID = window.setInterval(() => {
this._timer.startRepeated(this._timerSeconds, () => {
this.updateValue();
this._host.requestUpdate();
}, this._timerSeconds * 1000);
});
}
}
public hasTimer(): boolean {
return !!this._timerID;
return this._timer.isRunning();
}
/**
+16 -45
View File
@@ -99,6 +99,7 @@ import {
hasSubstream,
} from './utils/substream';
import { View } from './view/view.js';
import { Timer } from './utils/timer';
/** A note on media callbacks:
*
@@ -196,12 +197,9 @@ class FrigateCard extends LitElement {
protected _refElements: Ref<FrigateCardElements> = createRef();
protected _refViews: Ref<FrigateCardViews> = createRef();
// user interaction timer ("screensaver" functionality, return to default
// view after user interaction).
protected _interactionTimerID: number | null = null;
// Automated refreshes of the default view.
protected _updateTimerID: number | null = null;
protected _interactionTimer = new Timer();
protected _updateTimer = new Timer();
protected _untriggerTimer = new Timer();
// Information about loaded media items.
protected _currentMediaLoadedInfo: MediaLoadedInfo | null = null;
@@ -227,7 +225,6 @@ class FrigateCard extends LitElement {
protected _boundFullscreenHandler = this._fullscreenHandler.bind(this);
protected _triggers: Map<string, Date> = new Map();
protected _untriggerTimerID: number | null = null;
protected _mediaPlayers?: string[];
@@ -1006,7 +1003,7 @@ class FrigateCard extends LitElement {
const needDarkMode =
this._getConfig().view.dark_mode === 'on' ||
(this._getConfig().view.dark_mode === 'auto' &&
(!this._interactionTimerID || this._hass?.themes.darkMode));
(!this._interactionTimer.isRunning() || this._hass?.themes.darkMode));
if (needDarkMode) {
this.setAttribute('dark', '');
@@ -1144,7 +1141,7 @@ class FrigateCard extends LitElement {
* @returns
*/
protected _isTriggered(): boolean {
return !!this._triggers.size || !!this._untriggerTimerID;
return !!this._triggers.size || this._untriggerTimer.isRunning();
}
/**
@@ -1153,7 +1150,7 @@ class FrigateCard extends LitElement {
protected _untrigger(): void {
const wasTriggered = this._isTriggered();
this._triggers.clear();
this._clearUntriggerTimer();
this._untriggerTimer.stop();
if (wasTriggered) {
this.requestUpdate();
@@ -1164,9 +1161,7 @@ class FrigateCard extends LitElement {
* Start the untrigger timer.
*/
protected _startUntriggerTimer(): void {
this._clearUntriggerTimer();
this._untriggerTimerID = window.setTimeout(() => {
this._untriggerTimer.start(this._getConfig().view.scan.untrigger_seconds, () => {
this._untrigger();
if (
this._isAutomatedViewUpdateAllowed() &&
@@ -1174,17 +1169,7 @@ class FrigateCard extends LitElement {
) {
this._changeView();
}
}, this._getConfig().view.scan.untrigger_seconds * 1000);
}
/**
* Clear the user interaction ('screensaver') timer.
*/
protected _clearUntriggerTimer(): void {
if (this._untriggerTimerID) {
window.clearTimeout(this._untriggerTimerID);
this._untriggerTimerID = null;
}
});
}
protected _handleThrownError(error: unknown) {
@@ -1811,34 +1796,23 @@ class FrigateCard extends LitElement {
this._startInteractionTimer();
}
/**
* Clear the user interaction ('screensaver') timer.
*/
protected _clearInteractionTimer(): void {
if (this._interactionTimerID) {
window.clearTimeout(this._interactionTimerID);
this._interactionTimerID = null;
}
}
/**
* Start the user interaction ('screensaver') timer to reset the view to
* default `view.timeout_seconds` after user interaction.
*/
protected _startInteractionTimer(): void {
this._clearInteractionTimer();
this._interactionTimer.stop();
// Interactions reset the trigger state.
this._untrigger();
if (this._getConfig().view.timeout_seconds) {
this._interactionTimerID = window.setTimeout(() => {
this._clearInteractionTimer();
this._interactionTimer.start(this._getConfig().view.timeout_seconds, () => {
if (this._isAutomatedViewUpdateAllowed()) {
this._changeView();
this._setLightOrDarkMode();
}
}, this._getConfig().view.timeout_seconds * 1000);
});
}
this._setLightOrDarkMode();
}
@@ -1848,12 +1822,9 @@ class FrigateCard extends LitElement {
* `view.update_seconds`.
*/
protected _startUpdateTimer(): void {
if (this._updateTimerID) {
window.clearTimeout(this._updateTimerID);
this._updateTimerID = null;
}
this._updateTimer.stop();
if (this._getConfig().view.update_seconds) {
this._updateTimerID = window.setTimeout(() => {
this._updateTimer.start(this._getConfig().view.update_seconds, () => {
if (this._isAutomatedViewUpdateAllowed()) {
this._changeView();
} else {
@@ -1861,7 +1832,7 @@ class FrigateCard extends LitElement {
// interval.
this._startUpdateTimer();
}
}, this._getConfig().view.update_seconds * 1000);
});
}
}
@@ -1872,7 +1843,7 @@ class FrigateCard extends LitElement {
protected _isAutomatedViewUpdateAllowed(ignoreTriggers?: boolean): boolean {
return (
(ignoreTriggers || !this._isTriggered()) &&
(this._getConfig().view.update_force || !this._interactionTimerID)
(this._getConfig().view.update_force || !this._interactionTimer.isRunning())
);
}
+7 -8
View File
@@ -19,6 +19,7 @@ import {
dispatchMediaPlayEvent,
} from '../../utils/media-info.js';
import { dispatchErrorMessageEvent } from '../message.js';
import { Timer } from '../../utils/timer.js';
// Number of seconds a signed URL is valid for.
const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60;
@@ -41,7 +42,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
protected _jsmpegCanvasElement?: HTMLCanvasElement;
protected _jsmpegVideoPlayer?: JSMpeg.VideoElement;
protected _refreshPlayerTimerID?: number;
protected _refreshPlayerTimer = new Timer();
public async play(): Promise<void> {
return this._jsmpegVideoPlayer?.play();
@@ -145,10 +146,7 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
* Reset / destroy the player.
*/
protected _resetPlayer(): void {
if (this._refreshPlayerTimerID) {
window.clearTimeout(this._refreshPlayerTimerID);
this._refreshPlayerTimerID = undefined;
}
this._refreshPlayerTimer.stop();
if (this._jsmpegVideoPlayer) {
try {
this._jsmpegVideoPlayer.destroy();
@@ -213,9 +211,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
}
await this._createJSMPEGPlayer(address);
this._refreshPlayerTimerID = window.setTimeout(() => {
this.requestUpdate();
}, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000);
this._refreshPlayerTimer.start(
JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS,
() => this.requestUpdate(),
);
}
/**
+4 -6
View File
@@ -22,6 +22,7 @@ import './carousel.js';
import { FrigateCardNextPreviousControl } from './next-prev-control.js';
import { FrigateCardTitleControl } from './title-control.js';
import debounce from 'lodash-es/debounce';
import { Timer } from '../utils/timer';
interface CarouselMediaLoadedInfo {
slide: number;
@@ -126,7 +127,7 @@ export class FrigateCardMediaCarousel extends LitElement {
protected _nextControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
protected _previousControlRef: Ref<FrigateCardNextPreviousControl> = createRef();
protected _titleControlRef: Ref<FrigateCardTitleControl> = createRef();
protected _titleTimerID: number | null = null;
protected _titleTimer = new Timer();
protected _boundAutoPlayHandler = this.autoPlay.bind(this);
protected _boundAutoUnmuteHandler = this.autoUnmute.bind(this);
@@ -231,13 +232,10 @@ export class FrigateCardMediaCarousel extends LitElement {
*/
protected _titleHandler(): void {
const show = () => {
this._titleTimerID = null;
this._titleTimer.stop();
this._titleControlRef.value?.show();
};
if (this._titleTimerID) {
window.clearTimeout(this._titleTimerID);
}
if (this._titleControlRef.value?.isVisible()) {
// If it's already visible, update it immediately (but also update it
// after the timer expires to ensure it re-positions if necessary, see
@@ -248,7 +246,7 @@ export class FrigateCardMediaCarousel extends LitElement {
// Allow a brief pause after the media loads, but before the title is
// displayed. This allows for a pleasant appearance/disappear of the title,
// and allows for the browser to finish rendering the carousel.
this._titleTimerID = window.setTimeout(show, 0.5 * 1000);
this._titleTimer.start(0.5, show);
}
/**
+6 -7
View File
@@ -1,4 +1,5 @@
import { FrigateCardMediaPlayer } from '../types';
import { Timer } from './timer';
// The number of seconds to hide the video controls for after loading (in order
// to give a cleaner UI appearance, see:
@@ -15,19 +16,17 @@ const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1;
export const hideMediaControlsTemporarily = (
element: HTMLElement & {
controls: boolean;
_controlsHideTimeoutID?: number;
_controlsHideTimer?: Timer;
},
seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS,
): void => {
element.controls = false;
if (element._controlsHideTimeoutID) {
window.clearTimeout(element._controlsHideTimeoutID);
}
element._controlsHideTimeoutID = window.setTimeout(() => {
element._controlsHideTimer ??= new Timer();
element._controlsHideTimer.start(seconds, () => {
element.controls = true;
delete element._controlsHideTimeoutID;
}, seconds * 1000);
delete element._controlsHideTimer;
});
};
/**
+5 -13
View File
@@ -1,8 +1,9 @@
import { errorToConsole } from "./basic";
import { errorToConsole } from './basic';
import { Timer } from './timer';
export class MicrophoneController {
protected _stream?: MediaStream | null;
protected _timerID: number | null = null;
protected _timer = new Timer();
// We keep mute state separate from the stream state so that mute/unmute can
// be expressed before the stream is created -- and when it's create it will
@@ -68,20 +69,11 @@ export class MicrophoneController {
return !this._stream || this._stream.getTracks().every((track) => !track.enabled);
}
protected _clearTimer(): void {
if (this._timerID) {
window.clearTimeout(this._timerID);
this._timerID = null;
}
}
protected _startTimer(): void {
if (this._disconnectSeconds) {
this._clearTimer();
this._timerID = window.setTimeout(() => {
this._clearTimer();
this._timer.start(this._disconnectSeconds, () => {
this.disconnect();
}, this._disconnectSeconds * 1000);
});
}
}
}
+29
View File
@@ -0,0 +1,29 @@
export class Timer {
protected _timer: number | null = null;
public stop(): void {
if (this._timer) {
window.clearTimeout(this._timer);
this._timer = null;
}
}
public isRunning(): boolean {
return this._timer !== null;
}
public start(seconds: number, func: () => void): void {
this.stop();
this._timer = window.setTimeout(() => {
this._timer = null;
func();
}, seconds * 1000);
}
public startRepeated(seconds: number, func: () => void): void {
this.stop();
this._timer = window.setInterval(() => {
func();
}, seconds * 1000);
}
}
+67
View File
@@ -0,0 +1,67 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Timer } from '../../src/utils/timer';
// @vitest-environment jsdom
describe('Timer', () => {
beforeAll(() => {
vi.useFakeTimers();
});
afterAll(() => {
vi.useRealTimers();
});
it('should not be running on construct', () => {
const timer = new Timer();
expect(timer.isRunning()).toBeFalsy();
});
it('should fire when started', () => {
const timer = new Timer();
const handler = vi.fn();
timer.start(10, handler);
expect(timer.isRunning()).toBeTruthy();
expect(handler).not.toBeCalled();
vi.runOnlyPendingTimers();
expect(timer.isRunning()).toBeFalsy();
expect(handler).toBeCalled();
});
it('should fire repeatedly when started', () => {
const timer = new Timer();
const handler = vi.fn();
timer.startRepeated(10, handler);
expect(timer.isRunning()).toBeTruthy();
expect(handler).not.toBeCalled();
vi.runOnlyPendingTimers();
expect(timer.isRunning()).toBeTruthy();
expect(handler).toBeCalledTimes(1);
vi.runOnlyPendingTimers();
expect(timer.isRunning()).toBeTruthy();
expect(handler).toBeCalledTimes(2);
});
it('should not fire when stopped', () => {
const timer = new Timer();
const handler = vi.fn();
timer.start(10, handler);
expect(timer.isRunning()).toBeTruthy();
expect(handler).not.toBeCalled();
timer.stop();
vi.runOnlyPendingTimers();
expect(timer.isRunning()).toBeFalsy();
expect(handler).not.toBeCalled();
});
});