diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index d0323dd5..0dd34d12 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -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 { diff --git a/src/cached-value-controller.ts b/src/cached-value-controller.ts index 504b31f2..f0ce59dd 100644 --- a/src/cached-value-controller.ts +++ b/src/cached-value-controller.ts @@ -1,4 +1,5 @@ import { ReactiveController, ReactiveControllerHost } from 'lit'; +import { Timer } from './utils/timer'; export class CachedValueController implements ReactiveController { protected _value?: T; @@ -7,7 +8,7 @@ export class CachedValueController 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 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 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(); } /** diff --git a/src/card.ts b/src/card.ts index 2be087c1..27c73164 100644 --- a/src/card.ts +++ b/src/card.ts @@ -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 = createRef(); protected _refViews: Ref = 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 = 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()) ); } diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts index c0f9b830..8898b173 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/live-jsmpeg.ts @@ -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 { 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(), + ); } /** diff --git a/src/components/media-carousel.ts b/src/components/media-carousel.ts index f22806a1..5e9df428 100644 --- a/src/components/media-carousel.ts +++ b/src/components/media-carousel.ts @@ -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 = createRef(); protected _previousControlRef: Ref = createRef(); protected _titleControlRef: Ref = 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); } /** diff --git a/src/utils/media.ts b/src/utils/media.ts index 9306695f..9e130908 100644 --- a/src/utils/media.ts +++ b/src/utils/media.ts @@ -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; + }); }; /** diff --git a/src/utils/microphone.ts b/src/utils/microphone.ts index 772b45f1..97d5f436 100644 --- a/src/utils/microphone.ts +++ b/src/utils/microphone.ts @@ -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); + }); } } } diff --git a/src/utils/timer.ts b/src/utils/timer.ts new file mode 100644 index 00000000..96a3aaaa --- /dev/null +++ b/src/utils/timer.ts @@ -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); + } +} diff --git a/tests/utils/timer.test.ts b/tests/utils/timer.test.ts new file mode 100644 index 00000000..4bfe0983 --- /dev/null +++ b/tests/utils/timer.test.ts @@ -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(); + }); +});