Fix zoom in Firefox and Safari.

This commit is contained in:
Dermot Duffy
2023-05-22 20:37:18 -07:00
parent c156f094f5
commit 5a67c8bc2e
10 changed files with 223 additions and 26 deletions
+4 -3
View File
@@ -2,6 +2,7 @@ import { mayHaveAudio } from '../../../utils/audio';
import { import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../../../utils/media'; } from '../../../utils/media';
import { import {
dispatchMediaLoadedEvent, dispatchMediaLoadedEvent,
@@ -271,7 +272,7 @@ export class VideoRTC extends HTMLElement {
*/ */
oninit() { oninit() {
this.video = document.createElement('video'); this.video = document.createElement('video');
this.video.controls = this.controls; setControlsOnVideo(this.video, this.controls);
this.video.playsInline = true; this.video.playsInline = true;
this.video.preload = 'auto'; this.video.preload = 'auto';
@@ -633,7 +634,7 @@ export class VideoRTC extends HTMLElement {
let receivedFirstFrame = false; let receivedFirstFrame = false;
this.ondata = (data) => { this.ondata = (data) => {
this.video.controls = false; setControlsOnVideo(this.video, false);
this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data); this.video.poster = 'data:image/jpeg;base64,' + VideoRTC.btoa(data);
if (!receivedFirstFrame) { if (!receivedFirstFrame) {
@@ -672,7 +673,7 @@ export class VideoRTC extends HTMLElement {
context.drawImage(video2, 0, 0, canvas.width, canvas.height); context.drawImage(video2, 0, 0, canvas.width, canvas.height);
this.video.controls = false; setControlsOnVideo(this.video, false);
this.video.poster = canvas.toDataURL('image/jpeg'); this.video.poster = canvas.toDataURL('image/jpeg');
}); });
+2 -1
View File
@@ -17,6 +17,7 @@ import {
MicrophoneConfig, MicrophoneConfig,
} from '../../types.js'; } from '../../types.js';
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint';
import { setControlsOnVideo } from '../../utils/media.js';
import '../image.js'; import '../image.js';
import { dispatchErrorMessageEvent } from '../message'; import { dispatchErrorMessageEvent } from '../message';
import { VideoRTC } from './go2rtc/video-rtc'; import { VideoRTC } from './go2rtc/video-rtc';
@@ -84,7 +85,7 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
public async setControls(controls?: boolean): Promise<void> { public async setControls(controls?: boolean): Promise<void> {
if (this._player?.video) { if (this._player?.video) {
this._player.video.controls = controls ?? this.controls; setControlsOnVideo(this._player.video, controls ?? this.controls);
} }
} }
+3 -2
View File
@@ -21,6 +21,7 @@ import { renderTask } from '../../utils/task.js';
import { import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../../utils/media.js'; } from '../../utils/media.js';
import { CameraEndpoints } from '../../camera-manager/types.js'; import { CameraEndpoints } from '../../camera-manager/types.js';
import { mayHaveAudio } from '../../utils/audio.js'; import { mayHaveAudio } from '../../utils/audio.js';
@@ -85,7 +86,7 @@ export class FrigateCardLiveWebRTCCard
public async setControls(controls?: boolean): Promise<void> { public async setControls(controls?: boolean): Promise<void> {
const player = this._getPlayer(); const player = this._getPlayer();
if (player) { if (player) {
player.controls = controls ?? this.controls; setControlsOnVideo(player, controls ?? this.controls);
} }
} }
@@ -191,6 +192,7 @@ export class FrigateCardLiveWebRTCCard
this.updateComplete.then(() => { this.updateComplete.then(() => {
const video = this._getPlayer(); const video = this._getPlayer();
if (video) { if (video) {
setControlsOnVideo(video, this.controls);
video.onloadeddata = () => { video.onloadeddata = () => {
if (this.controls) { if (this.controls) {
hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS); hideMediaControlsTemporarily(video, MEDIA_LOAD_CONTROLS_HIDE_SECONDS);
@@ -206,7 +208,6 @@ export class FrigateCardLiveWebRTCCard
video.onplay = () => dispatchMediaPlayEvent(this); video.onplay = () => dispatchMediaPlayEvent(this);
video.onpause = () => dispatchMediaPauseEvent(this); video.onpause = () => dispatchMediaPauseEvent(this);
video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this); video.onvolumechange = () => dispatchMediaVolumeChangeEvent(this);
video.controls = this.controls;
} }
}); });
} }
+5 -1
View File
@@ -47,6 +47,7 @@ import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
playMediaMutingIfNecessary, playMediaMutingIfNecessary,
setControlsOnVideo,
} from '../utils/media.js'; } from '../utils/media.js';
import { ViewMediaClassifier } from '../view/media-classifier'; import { ViewMediaClassifier } from '../view/media-classifier';
import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier';
@@ -612,7 +613,10 @@ export class FrigateCardViewerProvider
if (this._refFrigateCardMediaPlayer.value) { if (this._refFrigateCardMediaPlayer.value) {
return this._refFrigateCardMediaPlayer.value.setControls(controls); return this._refFrigateCardMediaPlayer.value.setControls(controls);
} else if (this._refVideoProvider.value) { } 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,
);
} }
} }
+2 -1
View File
@@ -25,6 +25,7 @@ import {
import { import {
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
setControlsOnVideo,
} from '../utils/media.js'; } from '../utils/media.js';
customElements.whenDefined('ha-hls-player').then(() => { customElements.whenDefined('ha-hls-player').then(() => {
@@ -75,7 +76,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
public async setControls(controls?: boolean): Promise<void> { public async setControls(controls?: boolean): Promise<void> {
if (this._video) { if (this._video) {
this._video.controls = controls ?? this.controls; setControlsOnVideo(this._video, controls ?? this.controls);
} }
} }
+2 -1
View File
@@ -25,6 +25,7 @@ import {
import { import {
hideMediaControlsTemporarily, hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS, MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
setControlsOnVideo,
} from '../utils/media.js'; } from '../utils/media.js';
customElements.whenDefined('ha-web-rtc-player').then(() => { customElements.whenDefined('ha-web-rtc-player').then(() => {
@@ -74,7 +75,7 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
public async setControls(controls?: boolean): Promise<void> { public async setControls(controls?: boolean): Promise<void> {
if (this._video) { if (this._video) {
this._video.controls = controls ?? this.controls; setControlsOnVideo(this._video, controls ?? this.controls);
} }
} }
+33 -14
View File
@@ -7,25 +7,42 @@ import { Timer } from './timer';
export const MEDIA_LOAD_CONTROLS_HIDE_SECONDS = 2; export const MEDIA_LOAD_CONTROLS_HIDE_SECONDS = 2;
const MEDIA_SEEK_CONTROLS_HIDE_SECONDS = 1; 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. * Temporarily hide media controls.
* @param element Any HTMLElement that has a controls property (e.g. * @param elemaent Any HTMLElement that has a controls property (e.g.
* HTMLVideoElement, FrigateCardHaHlsPlayer) * HTMLVideoElement, FrigateCardHaHlsPlayer)
* @param seconds The number of seconds to hide the controls for. * @param seconds The number of seconds to hide the controls for.
*/ */
export const hideMediaControlsTemporarily = ( export const hideMediaControlsTemporarily = (
element: HTMLElement & { video: FrigateCardHTMLVideoElement,
controls: boolean;
_controlsHideTimer?: Timer;
},
seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS, seconds = MEDIA_SEEK_CONTROLS_HIDE_SECONDS,
): void => { ): void => {
element.controls = false; const oldValue = video.controls;
setControlsOnVideo(video, false);
element._controlsHideTimer ??= new Timer(); video._controlsHideTimer ??= new Timer();
element._controlsHideTimer.start(seconds, () => { video._controlsHideTimer.start(seconds, () => {
element.controls = true; setControlsOnVideo(video, oldValue);
delete element._controlsHideTimer;
}); });
}; };
@@ -41,8 +58,10 @@ export const playMediaMutingIfNecessary = async (
// and then try again. This works around some browsers that prevent // and then try again. This works around some browsers that prevent
// auto-play unless the video is muted. // auto-play unless the video is muted.
if (video?.play) { if (video?.play) {
video.play().catch(async (ev) => { try {
if (ev.name === 'NotAllowedError' && !player.isMuted()) { await video.play();
} catch (err: unknown) {
if ((err as Error).name === 'NotAllowedError' && !player.isMuted()) {
await player.mute(); await player.mute();
try { try {
await video.play(); await video.play();
@@ -50,6 +69,6 @@ export const playMediaMutingIfNecessary = async (
// Pass. // Pass.
} }
} }
}); }
} }
}; };
+20 -3
View File
@@ -1,5 +1,4 @@
import { PanzoomObject, PanzoomEventDetail } from '@dermotduffy/panzoom'; import Panzoom, { PanzoomEventDetail, PanzoomObject } from '@dermotduffy/panzoom';
import Panzoom from '@dermotduffy/panzoom';
import round from 'lodash-es/round'; import round from 'lodash-es/round';
import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic'; import { dispatchFrigateCardEvent, isHoverableDevice } from '../basic';
@@ -11,6 +10,7 @@ export class Zoom {
protected _element: HTMLElement; protected _element: HTMLElement;
protected _panzoom?: PanzoomObject; protected _panzoom?: PanzoomObject;
protected _zoomed = false; protected _zoomed = false;
protected _allowClick = true;
protected _events = isHoverableDevice() protected _events = isHoverableDevice()
? { ? {
@@ -31,9 +31,23 @@ export class Zoom {
// If we do not prevent default here, the media carousels scroll. // If we do not prevent default here, the media carousels scroll.
ev.preventDefault(); 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) => { protected _moveHandler = (ev: Event) => {
if (this._shouldZoomOrPan(ev)) { if (this._shouldZoomOrPan(ev)) {
this._panzoom?.handleMove(ev as PointerEvent); this._panzoom?.handleMove(ev as PointerEvent);
@@ -64,7 +78,9 @@ export class Zoom {
protected _shouldZoomOrPan(ev: Event): boolean { protected _shouldZoomOrPan(ev: Event): boolean {
return ( return (
!this._isScaleNormal(this._panzoom?.getScale()) || !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) (ev instanceof WheelEvent && ev.ctrlKey)
); );
} }
@@ -94,6 +110,7 @@ export class Zoom {
registerListeners(this._events['move'], this._moveHandler, { capture: true }); registerListeners(this._events['move'], this._moveHandler, { capture: true });
registerListeners(this._events['up'], this._upHandler, { capture: true }); registerListeners(this._events['up'], this._upHandler, { capture: true });
registerListeners(['wheel'], this._wheelHandler); registerListeners(['wheel'], this._wheelHandler);
registerListeners(['click'], this._clickHandler, { capture: true });
this._element.addEventListener('panzoomzoom', (ev: Event) => { this._element.addEventListener('panzoomzoom', (ev: Event) => {
// Take care here to only dispatch the zoomed/unzoomed events when the // Take care here to only dispatch the zoomed/unzoomed events when the
+110
View File
@@ -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<FrigateCardMediaPlayer>();
const video = mock<HTMLVideoElement>();
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<FrigateCardMediaPlayer>();
player.isMuted.mockReturnValue(false);
player.mute.mockResolvedValue();
const video = mock<HTMLVideoElement>();
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<FrigateCardMediaPlayer>();
player.isMuted.mockReturnValue(true);
const video = mock<HTMLVideoElement>();
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<FrigateCardMediaPlayer>();
player.isMuted.mockReturnValue(false);
const video = mock<HTMLVideoElement>();
video.play.mockRejectedValue(new NotAllowedError());
await playMediaMutingIfNecessary(player, video);
expect(video.play).toBeCalledTimes(2);
expect(player.isMuted).toBeCalled();
expect(player.mute).toBeCalled();
});
});
+42
View File
@@ -124,6 +124,48 @@ describe('Zoom', () => {
expect(panzoom.handleUp).toBeCalledWith(ev_5); 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', () => { it('deactivate should remove event handlers', () => {
const element = document.createElement('div'); const element = document.createElement('div');