Refactor timers into a simple tested object.

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