diff --git a/src/components/live/go2rtc/video-rtc.js b/src/components/live/go2rtc/video-rtc.js index 3129b396..434171b2 100644 --- a/src/components/live/go2rtc/video-rtc.js +++ b/src/components/live/go2rtc/video-rtc.js @@ -2,6 +2,7 @@ import { mayHaveAudio } from '../../../utils/audio'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + setControlsOnVideo, } from '../../../utils/media'; import { dispatchMediaLoadedEvent, @@ -271,7 +272,7 @@ export class VideoRTC extends HTMLElement { */ oninit() { this.video = document.createElement('video'); - this.video.controls = this.controls; + setControlsOnVideo(this.video, this.controls); this.video.playsInline = true; this.video.preload = 'auto'; @@ -633,7 +634,7 @@ export class VideoRTC extends HTMLElement { let receivedFirstFrame = false; this.ondata = (data) => { - this.video.controls = false; + setControlsOnVideo(this.video, false); this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data); if (!receivedFirstFrame) { @@ -672,7 +673,7 @@ export class VideoRTC extends HTMLElement { context.drawImage(video2, 0, 0, canvas.width, canvas.height); - this.video.controls = false; + setControlsOnVideo(this.video, false); this.video.poster = canvas.toDataURL('image/jpeg'); }); diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index 8b7347c2..130b2a8a 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -17,6 +17,7 @@ import { MicrophoneConfig, } from '../../types.js'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; +import { setControlsOnVideo } from '../../utils/media.js'; import '../image.js'; import { dispatchErrorMessageEvent } from '../message'; import { VideoRTC } from './go2rtc/video-rtc'; @@ -84,7 +85,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla public async setControls(controls?: boolean): Promise { if (this._player?.video) { - this._player.video.controls = controls ?? this.controls; + setControlsOnVideo(this._player.video, controls ?? this.controls); } } diff --git a/src/components/live/live-webrtc-card.ts b/src/components/live/live-webrtc-card.ts index 47d46c36..49d0f780 100644 --- a/src/components/live/live-webrtc-card.ts +++ b/src/components/live/live-webrtc-card.ts @@ -21,6 +21,7 @@ import { renderTask } from '../../utils/task.js'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + setControlsOnVideo, } from '../../utils/media.js'; import { CameraEndpoints } from '../../camera-manager/types.js'; import { mayHaveAudio } from '../../utils/audio.js'; @@ -85,7 +86,7 @@ export class FrigateCardLiveWebRTCCard public async setControls(controls?: boolean): Promise { const player = this._getPlayer(); if (player) { - player.controls = controls ?? this.controls; + setControlsOnVideo(player, controls ?? this.controls); } } @@ -191,6 +192,7 @@ export class FrigateCardLiveWebRTCCard this.updateComplete.then(() => { const video = this._getPlayer(); if (video) { + setControlsOnVideo(video, this.controls); video.onloadeddata = () => { if (this.controls) { hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); @@ -206,7 +208,6 @@ export class FrigateCardLiveWebRTCCard video.onplay = () => dispatchMediaPlayEvent(this); video.onpause = () => dispatchMediaPauseEvent(this); video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this); - video.controls = this.controls; } }); } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 699ced25..64b9e9de 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -47,6 +47,7 @@ import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, playMediaMutingIfNecessary, + setControlsOnVideo, } from '../utils/media.js'; import { ViewMediaClassifier } from '../view/media-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; @@ -612,7 +613,10 @@ export class FrigateCardViewerProvider if (this._refFrigateCardMediaPlayer.value) { return this._refFrigateCardMediaPlayer.value.setControls(controls); } else if (this._refVideoProvider.value) { - this._refVideoProvider.value.controls = controls ?? this.viewerConfig?.controls.builtin ?? true; + setControlsOnVideo( + this._refVideoProvider.value, + controls ?? this.viewerConfig?.controls.builtin ?? true, + ); } } diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index 776961b3..b0440581 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -25,6 +25,7 @@ import { import { MEDIA_LOAD_CONTROLS_HIDE_SECONDS, hideMediaControlsTemporarily, + setControlsOnVideo, } from '../utils/media.js'; customElements.whenDefined('ha-hls-player').then(() => { @@ -75,7 +76,7 @@ customElements.whenDefined('ha-hls-player').then(() => { public async setControls(controls?: boolean): Promise { if (this._video) { - this._video.controls = controls ?? this.controls; + setControlsOnVideo(this._video, controls ?? this.controls); } } diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index d3d40d5e..33d98945 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -25,6 +25,7 @@ import { import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, + setControlsOnVideo, } from '../utils/media.js'; customElements.whenDefined('ha-web-rtc-player').then(() => { @@ -74,7 +75,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { public async setControls(controls?: boolean): Promise { if (this._video) { - this._video.controls = controls ?? this.controls; + setControlsOnVideo(this._video, controls ?? this.controls); } } diff --git a/src/utils/media.ts b/src/utils/media.ts index 9e130908..7f3704ed 100644 --- a/src/utils/media.ts +++ b/src/utils/media.ts @@ -7,6 +7,27 @@ import { Timer } from './timer'; export const MEDIA_LOAD_CONTROLS_HIDE_SECONDS = 2; const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1; +export type FrigateCardHTMLVideoElement = HTMLVideoElement & { + _controlsHideTimer?: Timer; +}; + +/** + * Sets the controls on a video and removes a timer that may have been added by + * hideMediaControlsTemporarily. + * @param video + * @param value + */ +export const setControlsOnVideo = ( + video: FrigateCardHTMLVideoElement, + value: boolean, +): void => { + if (video._controlsHideTimer) { + video._controlsHideTimer.stop(); + delete video._controlsHideTimer; + } + video.controls = value; +}; + /** * Temporarily hide media controls. * @param element Any HTMLElement that has a controls property (e.g. @@ -14,18 +35,14 @@ const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1; * @param seconds The number of seconds to hide the controls for. */ export const hideMediaControlsTemporarily = ( - element: HTMLElement & { - controls: boolean; - _controlsHideTimer?: Timer; - }, + video: FrigateCardHTMLVideoElement, seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS, ): void => { - element.controls = false; - - element._controlsHideTimer ??= new Timer(); - element._controlsHideTimer.start(seconds, () => { - element.controls = true; - delete element._controlsHideTimer; + const oldValue = video.controls; + setControlsOnVideo(video, false); + video._controlsHideTimer ??= new Timer(); + video._controlsHideTimer.start(seconds, () => { + setControlsOnVideo(video, oldValue); }); }; @@ -41,8 +58,10 @@ export const playMediaMutingIfNecessary = async ( // and then try again. This works around some browsers that prevent // auto-play unless the video is muted. if (video?.play) { - video.play().catch(async (ev) => { - if (ev.name === 'NotAllowedError' && !player.isMuted()) { + try { + await video.play(); + } catch (err: unknown) { + if ((err as Error).name === 'NotAllowedError' && !player.isMuted()) { await player.mute(); try { await video.play(); @@ -50,6 +69,6 @@ export const playMediaMutingIfNecessary = async ( // Pass. } } - }); + } } }; diff --git a/src/utils/zoom/zoom.ts b/src/utils/zoom/zoom.ts index 8c0f68b9..7756cda6 100644 --- a/src/utils/zoom/zoom.ts +++ b/src/utils/zoom/zoom.ts @@ -1,5 +1,4 @@ -import { PanzoomObject, PanzoomEventDetail } from '@dermotduffy/panzoom'; -import Panzoom from '@dermotduffy/panzoom'; +import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom'; import round from 'lodash-es/round'; import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic'; @@ -11,6 +10,7 @@ export class Zoom { protected _element: HTMLElement; protected _panzoom?: PanzoomObject; protected _zoomed = false; + protected _allowClick = true; protected _events = isHoverableDevice() ? { @@ -31,9 +31,23 @@ export class Zoom { // If we do not prevent default here, the media carousels scroll. ev.preventDefault(); + this._allowClick = false; + } else { + this._allowClick = true; } }; + protected _clickHandler = (ev: Event) => { + // When mouse clicking is used to pan, need to avoid that causing a click + // handler elsewhere in the card being called. Example: Viewing a snapshot, + // and panning within it should not cause a related clip to play (the click + // handler in the viewer). + if (!this._allowClick) { + ev.stopPropagation(); + } + this._allowClick = true; + }; + protected _moveHandler = (ev: Event) => { if (this._shouldZoomOrPan(ev)) { this._panzoom?.handleMove(ev as PointerEvent); @@ -64,7 +78,9 @@ export class Zoom { protected _shouldZoomOrPan(ev: Event): boolean { return ( !this._isScaleNormal(this._panzoom?.getScale()) || - (ev instanceof TouchEvent && ev.touches.length > 1) || + // TouchEvent does not exist on Firefox on non-touch events. + // See: https://github.com/dermotduffy/frigate-hass-card/issues/1174 + (window.TouchEvent && ev instanceof TouchEvent && ev.touches.length > 1) || (ev instanceof WheelEvent && ev.ctrlKey) ); } @@ -94,6 +110,7 @@ export class Zoom { registerListeners(this._events['move'], this._moveHandler, { capture: true }); registerListeners(this._events['up'], this._upHandler, { capture: true }); registerListeners(['wheel'], this._wheelHandler); + registerListeners(['click'], this._clickHandler, { capture: true }); this._element.addEventListener('panzoomzoom', (ev: Event) => { // Take care here to only dispatch the zoomed/unzoomed events when the diff --git a/tests/utils/media.test.ts b/tests/utils/media.test.ts new file mode 100644 index 00000000..3a9d6cd3 --- /dev/null +++ b/tests/utils/media.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { FrigateCardMediaPlayer } from '../../src/types.js'; +import { + FrigateCardHTMLVideoElement, + hideMediaControlsTemporarily, + playMediaMutingIfNecessary, + setControlsOnVideo, +} from '../../src/utils/media.js'; + +// @vitest-environment jsdom +describe('setControlsOnVideo', () => { + it('should set controls', () => { + const video = document.createElement('video'); + + setControlsOnVideo(video, false); + expect(video.controls).toBeFalsy(); + }); + + it('should stop timer', () => { + const video: FrigateCardHTMLVideoElement = document.createElement('video'); + hideMediaControlsTemporarily(video); + + expect(video._controlsHideTimer).toBeTruthy(); + expect(video._controlsHideTimer?.isRunning()).toBeTruthy(); + + setControlsOnVideo(video, false); + expect(video.controls).toBeFalsy(); + expect(video._controlsHideTimer).toBeFalsy(); + }); +}); + +describe('hideMediaControlsTemporarily', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should set controls', () => { + const video: FrigateCardHTMLVideoElement = document.createElement('video'); + video.controls = true; + hideMediaControlsTemporarily(video); + + expect(video.controls).toBeFalsy(); + vi.runOnlyPendingTimers(); + + expect(video.controls).toBeTruthy(); + expect(video._controlsHideTimer).toBeFalsy(); + }); +}); + +class NotAllowedError extends Error { + name = 'NotAllowedError'; +} + +describe('playMediaMutingIfNecessary', () => { + it('should play', async () => { + const player = mock(); + const video = mock(); + video.play.mockResolvedValue(); + await playMediaMutingIfNecessary(player, video); + expect(video.play).toBeCalled(); + }); + + it('should mute if not allowed to play and unmuted', async () => { + const player = mock(); + player.isMuted.mockReturnValue(false); + player.mute.mockResolvedValue(); + + const video = mock(); + video.play.mockRejectedValueOnce(new NotAllowedError()).mockResolvedValueOnce(); + + await playMediaMutingIfNecessary(player, video); + + expect(video.play).toBeCalledTimes(2); + expect(player.isMuted).toBeCalled(); + expect(player.mute).toBeCalled(); + }); + + it('should not mute if not allowed to play and already unmuted', async () => { + const player = mock(); + player.isMuted.mockReturnValue(true); + + const video = mock(); + video.play.mockRejectedValueOnce(new NotAllowedError()); + + await playMediaMutingIfNecessary(player, video); + + expect(video.play).toBeCalledTimes(1); + expect(player.isMuted).toBeCalled(); + expect(player.mute).not.toBeCalled(); + }); + + it('should ignore exception if subsequent play call throws', async () => { + const player = mock(); + player.isMuted.mockReturnValue(false); + + const video = mock(); + video.play.mockRejectedValue(new NotAllowedError()); + + await playMediaMutingIfNecessary(player, video); + + expect(video.play).toBeCalledTimes(2); + expect(player.isMuted).toBeCalled(); + expect(player.mute).toBeCalled(); + }); +}); diff --git a/tests/utils/zoom.test.ts b/tests/utils/zoom.test.ts index 4d9daf10..16a595af 100644 --- a/tests/utils/zoom.test.ts +++ b/tests/utils/zoom.test.ts @@ -124,6 +124,48 @@ describe('Zoom', () => { expect(panzoom.handleUp).toBeCalledWith(ev_5); }); + it('should ignore click after pointerdown', () => { + const outer = document.createElement('div'); + const inner = document.createElement('div'); + outer.appendChild(inner); + const clickHandler = vi.fn(); + outer.addEventListener('click', clickHandler); + + const panzoom = createMockPanZoom(); + vi.mocked(Panzoom).mockReturnValueOnce(panzoom); + + createAndRegisterZoom(inner); + + // Simulate being zoomed in. + panzoom.getScale = vi.fn().mockReturnValue(1.2); + + // A click on its own will be fine. + const click_1 = new MouseEvent('click', { bubbles: true }); + inner.dispatchEvent(click_1); + expect(clickHandler).toBeCalledTimes(1); + + // A click after a pointerdown will be ignored. + const pointerdown_1 = new PointerEvent('pointerdown'); + inner.dispatchEvent(pointerdown_1); + + const click_2 = new MouseEvent('click', { bubbles: true }); + inner.dispatchEvent(click_2); + + // Click will have been ignored. + expect(clickHandler).toBeCalledTimes(1); + + // Simulate being zoomed out. + panzoom.getScale = vi.fn().mockReturnValue(1.0); + const pointerdown_2 = new PointerEvent('pointerdown'); + inner.dispatchEvent(pointerdown_2); + + const click_3 = new MouseEvent('click', { bubbles: true }); + inner.dispatchEvent(click_3); + + // Click will have been processed. + expect(clickHandler).toBeCalledTimes(2); + }); + it('deactivate should remove event handlers', () => { const element = document.createElement('div');