From 8658cc500f7e853a0374abdcdee566f3d60a3129 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 21 May 2023 09:29:13 -0700 Subject: [PATCH 1/2] Refactor media info handling into a controller. --- src/card.ts | 42 ++--- src/utils/media-info-controller.ts | 27 +++ src/utils/media-info.ts | 40 ++--- tests/utils/media-info-controller.test.ts | 26 +++ tests/utils/media-info.test.ts | 202 ++++++++++++++++++++++ 5 files changed, 296 insertions(+), 41 deletions(-) create mode 100644 src/utils/media-info-controller.ts create mode 100644 tests/utils/media-info-controller.test.ts create mode 100644 tests/utils/media-info.test.ts diff --git a/src/card.ts b/src/card.ts index 5c940fe6..931dba27 100644 --- a/src/card.ts +++ b/src/card.ts @@ -95,6 +95,7 @@ import { import { Timer } from './utils/timer'; import { getParseErrorPaths } from './utils/zod.js'; import { View } from './view/view.js'; +import { MediaLoadedInfoController } from './utils/media-info-controller'; /** A note on media callbacks: * @@ -187,6 +188,7 @@ class FrigateCard extends LitElement { protected _conditionController?: ConditionController; protected _automationsController?: AutomationsController; protected _menuButtonController = new MenuButtonController(); + protected _mediaLoadedInfoController = new MediaLoadedInfoController(); protected _refMenu: Ref = createRef(); protected _refMain: Ref = createRef(); @@ -197,10 +199,6 @@ class FrigateCard extends LitElement { protected _updateTimer = new Timer(); protected _untriggerTimer = new Timer(); - // Information about loaded media items. - protected _currentMediaLoadedInfo: MediaLoadedInfo | null = null; - protected _lastValidMediaLoadedInfo: MediaLoadedInfo | null = null; - // Error/info message to render. protected _message: Message | null = null; @@ -341,7 +339,6 @@ class FrigateCard extends LitElement { return this._cameraManager.getStore().getCameraConfig(this._view.camera); } - /** * Set the card configuration. * @param inputConfig The card configuration. @@ -414,7 +411,7 @@ class FrigateCard extends LitElement { this._conditionController?.hasHAStateConditions && { state: this._hass.states, }), - media_loaded: !!this._currentMediaLoadedInfo, + media_loaded: this._mediaLoadedInfoController.has(), }); } @@ -446,7 +443,7 @@ class FrigateCard extends LitElement { log(this._cardWideConfig, `Frigate Card view change: `, args?.view ?? '[default]'); const changeView = (view: View): void => { if (View.isMajorMediaChange(this._view, view)) { - this._currentMediaLoadedInfo = null; + this._mediaLoadedInfoController.clear(); } if (this._view?.view !== view.view) { this._resetMainScroll(); @@ -1151,16 +1148,16 @@ class FrigateCard extends LitElement { } break; case 'mute': - this._currentMediaLoadedInfo?.player?.mute(); + this._mediaLoadedInfoController.get()?.player?.mute(); break; case 'unmute': - this._currentMediaLoadedInfo?.player?.unmute(); + this._mediaLoadedInfoController.get()?.player?.unmute(); break; case 'play': - this._currentMediaLoadedInfo?.player?.play(); + this._mediaLoadedInfoController.get()?.player?.play(); break; case 'pause': - this._currentMediaLoadedInfo?.player?.pause(); + this._mediaLoadedInfoController.get()?.player?.pause(); break; default: console.warn(`Frigate card received unknown card action: ${action}`); @@ -1371,7 +1368,7 @@ class FrigateCard extends LitElement { this._view, this._expand, { - currentMediaLoadedInfo: this._currentMediaLoadedInfo, + currentMediaLoadedInfo: this._mediaLoadedInfoController.get(), mediaPlayers: this._mediaPlayers, cameraURL: this._getCameraURLFromContext(), microphoneController: this._microphoneController, @@ -1438,12 +1435,12 @@ class FrigateCard extends LitElement { log(this._cardWideConfig, `Frigate Card media load: `, mediaLoadedInfo); - this._lastValidMediaLoadedInfo = this._currentMediaLoadedInfo = mediaLoadedInfo; + this._mediaLoadedInfoController.set(mediaLoadedInfo); this._setPropertiesForExpandedMode(); this._conditionController?.setState({ - media_loaded: !!this._currentMediaLoadedInfo, + media_loaded: this._mediaLoadedInfoController.has(), }); this.requestUpdate(); @@ -1453,10 +1450,11 @@ class FrigateCard extends LitElement { // When a new media loads, set the aspect ratio for when the card is // expanded/popped-up. This is based exclusively on last media content, // as dimension configuration does not apply in fullscreen or expanded mode. + const lastKnown = this._mediaLoadedInfoController.getLastKnown(); this.style.setProperty( '--frigate-card-expand-aspect-ratio', - this._view?.isAnyMediaView() && this._lastValidMediaLoadedInfo - ? `${this._lastValidMediaLoadedInfo.width} / ${this._lastValidMediaLoadedInfo.height}` + this._view?.isAnyMediaView() && lastKnown + ? `${lastKnown.width} / ${lastKnown.height}` : 'unset', ); // Non-media mays have no intrinsic dimensions and so we need to explicit @@ -1475,7 +1473,7 @@ class FrigateCard extends LitElement { * Unload a media item. */ protected _mediaUnloadedHandler(): void { - this._currentMediaLoadedInfo = null; + this._mediaLoadedInfoController.clear(); this._conditionController?.setState({ media_loaded: false }); } @@ -1542,8 +1540,9 @@ class FrigateCard extends LitElement { const aspectRatioMode = this._getConfig().dimensions.aspect_ratio_mode; - if (this._lastValidMediaLoadedInfo && aspectRatioMode === 'dynamic') { - return `${this._lastValidMediaLoadedInfo.width} / ${this._lastValidMediaLoadedInfo.height}`; + const lastKnown = this._mediaLoadedInfoController.getLastKnown(); + if (lastKnown && aspectRatioMode === 'dynamic') { + return `${lastKnown.width} / ${lastKnown.height}`; } const defaultAspectRatio = this._getConfig().dimensions.aspect_ratio; @@ -1756,8 +1755,9 @@ class FrigateCard extends LitElement { * @returns The Lovelace card size in units of 50px. */ public getCardSize(): number { - if (this._lastValidMediaLoadedInfo) { - return this._lastValidMediaLoadedInfo.height / 50; + const lastKnown = this._mediaLoadedInfoController.getLastKnown(); + if (lastKnown) { + return lastKnown.height / 50; } return 6; } diff --git a/src/utils/media-info-controller.ts b/src/utils/media-info-controller.ts new file mode 100644 index 00000000..f35bc525 --- /dev/null +++ b/src/utils/media-info-controller.ts @@ -0,0 +1,27 @@ +import { MediaLoadedInfo } from '../types'; + +export class MediaLoadedInfoController { + protected _current: MediaLoadedInfo | null = null; + protected _lastKnown: MediaLoadedInfo | null = null; + + public set(current: MediaLoadedInfo): void { + this._current = current; + this._lastKnown = current; + } + + public get(): MediaLoadedInfo | null { + return this._current; + } + + public getLastKnown(): MediaLoadedInfo | null { + return this._lastKnown; + } + + public clear(): void { + this._current = null; + } + + public has(): boolean { + return !!this._current; + } +} diff --git a/src/utils/media-info.ts b/src/utils/media-info.ts index f6e2b0ad..22637d7d 100644 --- a/src/utils/media-info.ts +++ b/src/utils/media-info.ts @@ -69,18 +69,6 @@ export function dispatchMediaLoadedEvent( } } -export function dispatchMediaVolumeChangeEvent(target: HTMLElement): void { - dispatchFrigateCardEvent(target, 'media:volumechange'); -} - -export function dispatchMediaPlayEvent(target: HTMLElement): void { - dispatchFrigateCardEvent(target, 'media:play'); -} - -export function dispatchMediaPauseEvent(target: HTMLElement): void { - dispatchFrigateCardEvent(target, 'media:pause'); -} - /** * Dispatch a pre-existing MediaLoadedInfo object as an event. * @param element The element to send the event. @@ -93,6 +81,26 @@ export function dispatchExistingMediaLoadedInfoAsEvent( dispatchFrigateCardEvent(target, 'media:loaded', MediaLoadedInfo); } +/** + * Dispatch a media unloaded event. + * @param element The element to send the event. + */ +export function dispatchMediaUnloadedEvent(element: HTMLElement): void { + dispatchFrigateCardEvent(element, 'media:unloaded'); +} + +export function dispatchMediaVolumeChangeEvent(target: HTMLElement): void { + dispatchFrigateCardEvent(target, 'media:volumechange'); +} + +export function dispatchMediaPlayEvent(target: HTMLElement): void { + dispatchFrigateCardEvent(target, 'media:play'); +} + +export function dispatchMediaPauseEvent(target: HTMLElement): void { + dispatchFrigateCardEvent(target, 'media:pause'); +} + /** * Determine if a MediaLoadedInfo object is valid/acceptable. * @param info The MediaLoadedInfo object. @@ -103,11 +111,3 @@ export function isValidMediaLoadedInfo(info: MediaLoadedInfo): boolean { info.height >= MEDIA_INFO_HEIGHT_CUTOFF && info.width >= MEDIA_INFO_WIDTH_CUTOFF ); } - -/** - * Dispatch a media unloaded event. - * @param element The element to send the event. - */ -export function dispatchMediaUnloadedEvent(element: HTMLElement): void { - dispatchFrigateCardEvent(element, 'media:unloaded'); -} diff --git a/tests/utils/media-info-controller.test.ts b/tests/utils/media-info-controller.test.ts new file mode 100644 index 00000000..95a0a65a --- /dev/null +++ b/tests/utils/media-info-controller.test.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { MediaLoadedInfoController } from '../../src/utils/media-info-controller'; +import { createMediaLoadedInfo } from '../test-utils.js'; + +describe('MediaLoadedInfoController', () => { + let controller: MediaLoadedInfoController; + beforeEach(() => { + controller = new MediaLoadedInfoController(); + }) + + it('should set', () => { + const info = createMediaLoadedInfo() + controller.set(info); + expect(controller.has()); + expect(controller.get()).toBe(info) + }); + + it('should get last known', () => { + const info = createMediaLoadedInfo() + controller.set(info); + expect(controller.has()).toBeTruthy(); + controller.clear(); + expect(controller.has()).toBeFalsy(); + expect(controller.getLastKnown()).toBe(info); + }); +}); \ No newline at end of file diff --git a/tests/utils/media-info.test.ts b/tests/utils/media-info.test.ts new file mode 100644 index 00000000..59e11787 --- /dev/null +++ b/tests/utils/media-info.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { FrigateCardMediaPlayer, MediaLoadedCapabilities } from '../../src/types'; +import { + createMediaLoadedInfo, + dispatchExistingMediaLoadedInfoAsEvent, + dispatchMediaLoadedEvent, + dispatchMediaPauseEvent, + dispatchMediaPlayEvent, + dispatchMediaUnloadedEvent, + dispatchMediaVolumeChangeEvent, + isValidMediaLoadedInfo, +} from '../../src/utils/media-info'; +import { createMediaLoadedInfo as createTestMediaLoadedInfo } from '../test-utils.js'; + +const options = { + player: mock(), + capabilities: mock(), +}; + +// @vitest-environment jsdom +describe('createMediaLoadedInfo', () => { + it('should create from image', () => { + const img = document.createElement('img'); + + // Need to write readonly properties. + Object.defineProperty(img, 'naturalWidth', { value: 10 }); + Object.defineProperty(img, 'naturalHeight', { value: 20 }); + + expect(createMediaLoadedInfo(img, options)).toEqual({ + width: 10, + height: 20, + ...options, + }); + }); + + it('should create from video', () => { + const video = document.createElement('video'); + + // Need to write readonly properties. + Object.defineProperty(video, 'videoWidth', { value: 30 }); + Object.defineProperty(video, 'videoHeight', { value: 40 }); + + expect(createMediaLoadedInfo(video, options)).toEqual({ + width: 30, + height: 40, + ...options, + }); + }); + + it('should create from canvas', () => { + const canvas = document.createElement('canvas'); + canvas.width = 50; + canvas.height = 60; + + expect(createMediaLoadedInfo(canvas, options)).toEqual({ + width: 50, + height: 60, + ...options, + }); + }); + + it('should not create from unknown', () => { + const div = document.createElement('div'); + expect(createMediaLoadedInfo(div, options)).toBeNull(); + }); + + it('should create from event', () => { + const img = document.createElement('img'); + + // Need to write readonly properties. + Object.defineProperty(img, 'naturalWidth', { value: 70 }); + Object.defineProperty(img, 'naturalHeight', { value: 80 }); + + const event = new Event('foo'); + Object.defineProperty(event, 'composedPath', { value: () => [img] }); + + expect(createMediaLoadedInfo(event, options)).toEqual({ + width: 70, + height: 80, + ...options, + }); + }); +}); + +// @vitest-environment jsdom +describe('dispatchMediaLoadedEvent', () => { + const options = { + player: mock(), + capabilities: mock(), + }; + + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:loaded', handler); + + // Need to write readonly properties. + const img = document.createElement('img'); + Object.defineProperty(img, 'naturalWidth', { value: 10 }); + Object.defineProperty(img, 'naturalHeight', { value: 20 }); + + dispatchMediaLoadedEvent(div, img, options); + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: { + width: 10, + height: 20, + ...options, + }, + }), + ); + }); + + it('should not dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:loaded', handler); + + dispatchMediaLoadedEvent(div, div, options); + expect(handler).not.toBeCalled(); + }); +}); + +// @vitest-environment jsdom +describe('dispatchExistingMediaLoadedInfoAsEvent', () => { + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:loaded', handler); + const info = createTestMediaLoadedInfo(); + + dispatchExistingMediaLoadedInfoAsEvent(div, info); + expect(handler).toBeCalledWith( + expect.objectContaining({ + detail: info, + }), + ); + }); +}); + +// @vitest-environment jsdom +describe('dispatchMediaUnloadedEvent', () => { + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:unloaded', handler); + + dispatchMediaUnloadedEvent(div); + expect(handler).toBeCalled(); + }); +}); + +// @vitest-environment jsdom +describe('dispatchMediaVolumeChangeEvent', () => { + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:volumechange', handler); + + dispatchMediaVolumeChangeEvent(div); + expect(handler).toBeCalled(); + }); +}); + +// @vitest-environment jsdom +describe('dispatchMediaPlayEvent', () => { + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:play', handler); + + dispatchMediaPlayEvent(div); + expect(handler).toBeCalled(); + }); +}); + +// @vitest-environment jsdom +describe('dispatchMediaPauseEvent', () => { + it('should dispatch', () => { + const handler = vi.fn(); + const div = document.createElement('div'); + div.addEventListener('frigate-card:media:pause', handler); + + dispatchMediaPauseEvent(div); + expect(handler).toBeCalled(); + }); +}); + +// @vitest-environment jsdom +describe('isValidMediaLoadedInfo', () => { + it('should be valid with correct dimensions', () => { + expect( + isValidMediaLoadedInfo(createTestMediaLoadedInfo({ width: 100, height: 100 })), + ).toBeTruthy(); + }); + it('should be invalid with unlikely dimensions', () => { + expect( + isValidMediaLoadedInfo(createTestMediaLoadedInfo({ width: 0, height: 0 })), + ).toBeFalsy(); + }); +}); From e8e99b2e208901025ad316b26805c66e85ee5533 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sun, 21 May 2023 09:31:39 -0700 Subject: [PATCH 2/2] Apply formatting. --- tests/utils/media-info-controller.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/utils/media-info-controller.test.ts b/tests/utils/media-info-controller.test.ts index 95a0a65a..2815efe7 100644 --- a/tests/utils/media-info-controller.test.ts +++ b/tests/utils/media-info-controller.test.ts @@ -6,21 +6,21 @@ describe('MediaLoadedInfoController', () => { let controller: MediaLoadedInfoController; beforeEach(() => { controller = new MediaLoadedInfoController(); - }) + }); it('should set', () => { - const info = createMediaLoadedInfo() + const info = createMediaLoadedInfo(); controller.set(info); expect(controller.has()); - expect(controller.get()).toBe(info) + expect(controller.get()).toBe(info); }); it('should get last known', () => { - const info = createMediaLoadedInfo() + const info = createMediaLoadedInfo(); controller.set(info); expect(controller.has()).toBeTruthy(); controller.clear(); expect(controller.has()).toBeFalsy(); expect(controller.getLastKnown()).toBe(info); }); -}); \ No newline at end of file +});