diff --git a/src/card.ts b/src/card.ts index 31ea7ddd..2cb095d5 100644 --- a/src/card.ts +++ b/src/card.ts @@ -68,7 +68,7 @@ import { } from './utils/action.js'; import { errorToConsole } from './utils/basic.js'; import { log } from './utils/debug.js'; -import { downloadMedia } from './utils/download.js'; +import { downloadMedia, downloadURL } from './utils/download.js'; import { getHassDifferences, isCardInPanel, @@ -83,10 +83,12 @@ import { Entity } from './utils/ha/entity-registry/types.js'; import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; import { supportsFeature } from './utils/ha/update.js'; import { FrigateCardInitializer } from './utils/initializer.js'; +import { MediaLoadedInfoController } from './utils/media-info-controller'; import { isValidMediaLoadedInfo } from './utils/media-info.js'; import { MenuButtonController } from './utils/menu-controller'; import { MicrophoneController } from './utils/microphone'; import { getActionsFromQueryString } from './utils/querystring.js'; +import { generateScreenshotTitle } from './utils/screenshot'; import { createViewWithNextStream, createViewWithoutSubstream, @@ -95,7 +97,6 @@ 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: * @@ -1159,6 +1160,16 @@ class FrigateCard extends LitElement { case 'pause': this._mediaLoadedInfoController.get()?.player?.pause(); break; + case 'screenshot': + this._mediaLoadedInfoController + .get() + ?.player?.getScreenshotURL() + .then((url: string | null) => { + if (url) { + downloadURL(url, generateScreenshotTitle(this._view)); + } + }); + break; default: console.warn(`Frigate card received unknown card action: ${action}`); } diff --git a/src/components/image.ts b/src/components/image.ts index 880a55f5..215dadad 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -95,6 +95,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay return !this._cachedValueController?.hasTimer() ?? true; } + public async getScreenshotURL(): Promise { + return this._cachedValueController?.value ?? null; + } + /** * Get the camera entity for the current camera configuration. * @returns The entity or undefined if no camera entity is available. diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index 130b2a8a..ce425716 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -18,6 +18,7 @@ import { } from '../../types.js'; import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; import { setControlsOnVideo } from '../../utils/media.js'; +import { screenshotMedia } from '../../utils/screenshot.js'; import '../image.js'; import { dispatchErrorMessageEvent } from '../message'; import { VideoRTC } from './go2rtc/video-rtc'; @@ -93,6 +94,10 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla return this._player?.video.paused ?? true; } + public async getScreenshotURL(): Promise { + return this._player ? screenshotMedia(this._player.video) : null; + } + disconnectedCallback(): void { this._player = undefined; } diff --git a/src/components/live/live-ha.ts b/src/components/live/live-ha.ts index 6ad1017a..8fd36b2e 100644 --- a/src/components/live/live-ha.ts +++ b/src/components/live/live-ha.ts @@ -54,6 +54,10 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla return this._playerRef.value?.isPaused() ?? true; } + public async getScreenshotURL(): Promise { + return await this._playerRef.value?.getScreenshotURL() ?? null; + } + protected render(): TemplateResult | void { if (!this.hass) { return; diff --git a/src/components/live/live-image.ts b/src/components/live/live-image.ts index 8f872d49..58e4a03d 100644 --- a/src/components/live/live-image.ts +++ b/src/components/live/live-image.ts @@ -49,6 +49,10 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia return this._refImage.value?.isPaused() ?? true; } + public async getScreenshotURL(): Promise { + return await this._refImage.value?.getScreenshotURL() ?? null; + } + protected render(): TemplateResult | void { if (!this.hass || !this.cameraConfig) { return; diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts index 8898b173..16e260dc 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/live-jsmpeg.ts @@ -84,6 +84,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi return this._jsmpegVideoPlayer?.player?.paused ?? true; } + public async getScreenshotURL(): Promise { + return this._jsmpegCanvasElement?.toDataURL('image/jpeg') ?? null; + } + /** * Create a JSMPEG player. * @param url The URL for the player to connect to. @@ -107,6 +111,9 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi audio: false, videoBufferSize: 1024 * 1024 * 4, + // Necessary for screenshots. + preserveDrawingBuffer: true, + // Override with user-specified options. ...this.cameraConfig?.jsmpeg?.options, diff --git a/src/components/live/live-webrtc-card.ts b/src/components/live/live-webrtc-card.ts index 49d0f780..9ed4d42b 100644 --- a/src/components/live/live-webrtc-card.ts +++ b/src/components/live/live-webrtc-card.ts @@ -2,6 +2,7 @@ import { Task } from '@lit-labs/task'; import { HomeAssistant } from 'custom-card-helpers'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import { CameraEndpoints } from '../../camera-manager/types.js'; import { localize } from '../../localize/localize.js'; import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss'; import { @@ -10,21 +11,21 @@ import { FrigateCardError, FrigateCardMediaPlayer, } from '../../types.js'; +import { mayHaveAudio } from '../../utils/audio.js'; import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, dispatchMediaVolumeChangeEvent, } from '../../utils/media-info.js'; -import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js'; -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'; +import { screenshotMedia } from '../../utils/screenshot.js'; +import { renderTask } from '../../utils/task.js'; +import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js'; // Create a wrapper for AlexxIT's WebRTC card // - https://github.com/AlexxIT/WebRTC @@ -94,6 +95,11 @@ export class FrigateCardLiveWebRTCCard return this._getPlayer()?.paused ?? true; } + public async getScreenshotURL(): Promise { + const video = this._getPlayer(); + return video ? screenshotMedia(video) : null; + } + connectedCallback(): void { super.connectedCallback(); diff --git a/src/components/live/live.ts b/src/components/live/live.ts index 46f05e6a..23e34688 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -756,19 +756,19 @@ export class FrigateCardLiveProvider public async pause(): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; - this._refProvider.value?.pause(); + await this._refProvider.value?.pause(); } public async mute(): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; - this._refProvider.value?.mute(); + await this._refProvider.value?.mute(); } public async unmute(): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; - this._refProvider.value?.unmute(); + await this._refProvider.value?.unmute(); } public isMuted(): boolean { @@ -778,19 +778,25 @@ export class FrigateCardLiveProvider public async seek(seconds: number): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; - this._refProvider.value?.seek(seconds); + await this._refProvider.value?.seek(seconds); } public async setControls(controls?: boolean): Promise { await this.updateComplete; await this._refProvider.value?.updateComplete; - this._refProvider.value?.setControls(controls); + await this._refProvider.value?.setControls(controls); } public isPaused(): boolean { return this._refProvider.value?.isPaused() ?? true; } + public async getScreenshotURL(): Promise { + await this.updateComplete; + await this._refProvider.value?.updateComplete; + return await this._refProvider.value?.getScreenshotURL() ?? null; + } + /** * Get the fully resolved live provider. * @returns A live provider (that is not 'auto'). diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 64b9e9de..37980d93 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -49,6 +49,7 @@ import { playMediaMutingIfNecessary, setControlsOnVideo, } from '../utils/media.js'; +import { screenshotMedia } from '../utils/screenshot.js'; import { ViewMediaClassifier } from '../view/media-classifier'; import { MediaQueriesClassifier } from '../view/media-queries-classifier'; import { MediaQueriesResults } from '../view/media-queries-results.js'; @@ -563,6 +564,7 @@ export class FrigateCardViewerProvider protected _refFrigateCardMediaPlayer: Ref = createRef(); protected _refVideoProvider: Ref = createRef(); + protected _refImageProvider: Ref = createRef(); public async play(): Promise { await playMediaMutingIfNecessary( @@ -629,6 +631,17 @@ export class FrigateCardViewerProvider return true; } + public async getScreenshotURL(): Promise { + if (this._refFrigateCardMediaPlayer.value) { + return await this._refFrigateCardMediaPlayer.value.getScreenshotURL(); + } else if (this._refVideoProvider.value) { + return screenshotMedia(this._refVideoProvider.value); + } else if (this._refImageProvider.value) { + return this._refImageProvider.value.src; + } + return null; + } + /** * Dispatch a clip view that matches the current (snapshot) query. */ @@ -735,6 +748,8 @@ export class FrigateCardViewerProvider }); } + // Note: crossorigin="anonymous" is required on ` : html` ` : ''} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 59b48fdb..a5b5b780 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -282,6 +282,7 @@ "play": "Play / Pause", "priority": "Priority", "recordings": "Recordings", + "screenshot": "Screenshot", "snapshots": "Snapshots", "substreams": "Substream(s)", "timeline": "Timeline", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index bc9653f7..2643abd5 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -279,6 +279,7 @@ "mute": "", "play": "", "priority": "Priorità", + "screenshot": "", "snapshots": "Istantanee", "substreams": "Flusso/i secondario/i", "timeline": "Timeline", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index eb978cce..c873ceb9 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -281,6 +281,7 @@ "play": "", "priority": "Prioridade", "recordings": "Gravações", + "screenshot": "", "snapshots": "Instantâneos", "substreams": "Substream(s)", "timeline": "Linha do tempo", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index ccb210ea..6f4cd0ef 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -272,6 +272,7 @@ "mute": "", "play": "", "priority": "Prioridade", + "screenshot": "", "snapshots": "Instantâneos", "substreams": "substreams", "timeline": "Linha do tempo", diff --git a/src/patches/ha-camera-stream.ts b/src/patches/ha-camera-stream.ts index 97e2ca15..e1e742a1 100644 --- a/src/patches/ha-camera-stream.ts +++ b/src/patches/ha-camera-stream.ts @@ -81,6 +81,10 @@ customElements.whenDefined('ha-camera-stream').then(() => { return this._player?.isPaused() ?? true; } + public async getScreenshotURL(): Promise { + return this._player ? await this._player.getScreenshotURL() : null; + } + /** * Master render method. * @returns A rendered template. diff --git a/src/patches/ha-hls-player.ts b/src/patches/ha-hls-player.ts index b0440581..56f12e7f 100644 --- a/src/patches/ha-hls-player.ts +++ b/src/patches/ha-hls-player.ts @@ -9,9 +9,10 @@ // available as compilation time. // ==================================================================== -import { CSSResultGroup, TemplateResult, css, html, unsafeCSS } from 'lit'; +import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; +import { screenshotMedia } from '../utils/screenshot.js'; import { dispatchErrorMessageEvent } from '../components/message.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import { FrigateCardMediaPlayer } from '../types.js'; @@ -20,12 +21,10 @@ import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, - dispatchMediaVolumeChangeEvent, + dispatchMediaVolumeChangeEvent } from '../utils/media-info.js'; import { - MEDIA_LOAD_CONTROLS_HIDE_SECONDS, - hideMediaControlsTemporarily, - setControlsOnVideo, + hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, setControlsOnVideo } from '../utils/media.js'; customElements.whenDefined('ha-hls-player').then(() => { @@ -84,6 +83,10 @@ customElements.whenDefined('ha-hls-player').then(() => { return this._video?.paused ?? true; } + public async getScreenshotURL(): Promise { + return this._video ? screenshotMedia(this._video) : null; + } + // ===================================================================================== // Minor modifications from: // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts diff --git a/src/patches/ha-web-rtc-player.ts b/src/patches/ha-web-rtc-player.ts index 33d98945..c9ceb764 100644 --- a/src/patches/ha-web-rtc-player.ts +++ b/src/patches/ha-web-rtc-player.ts @@ -12,6 +12,7 @@ import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit'; import { customElement } from 'lit/decorators.js'; import { query } from 'lit/decorators/query.js'; +import { screenshotMedia } from '../utils/screenshot.js'; import { dispatchErrorMessageEvent } from '../components/message.js'; import liveHAComponentsStyle from '../scss/live-ha-components.scss'; import { FrigateCardMediaPlayer } from '../types.js'; @@ -20,12 +21,12 @@ import { dispatchMediaLoadedEvent, dispatchMediaPauseEvent, dispatchMediaPlayEvent, - dispatchMediaVolumeChangeEvent, + dispatchMediaVolumeChangeEvent } from '../utils/media-info.js'; import { hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, - setControlsOnVideo, + setControlsOnVideo } from '../utils/media.js'; customElements.whenDefined('ha-web-rtc-player').then(() => { @@ -83,6 +84,10 @@ customElements.whenDefined('ha-web-rtc-player').then(() => { return this._video?.paused ?? true; } + public async getScreenshotURL(): Promise { + return this._video ? screenshotMedia(this._video) : null; + } + // ===================================================================================== // Minor modifications from: // - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts diff --git a/src/types.ts b/src/types.ts index dbb5628d..892991b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -228,6 +228,7 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'pause', 'recording', 'recordings', + 'screenshot', 'snapshot', 'snapshots', 'timeline', @@ -1078,6 +1079,7 @@ const menuConfigDefault = { mute: hiddenButtonDefault, play: hiddenButtonDefault, recordings: hiddenButtonDefault, + screenshot: hiddenButtonDefault, }, button_size: 40, }; @@ -1123,6 +1125,7 @@ const menuConfigSchema = z recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute), play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), + screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot), }) .default(menuConfigDefault.buttons), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), @@ -1530,6 +1533,7 @@ export interface FrigateCardMediaPlayer { unmute(): Promise; isMuted(): boolean; seek(seconds: number): Promise; + getScreenshotURL(): Promise; // If no value for controls if specified, the player should use the default. setControls(controls?: boolean): Promise; isPaused(): boolean; diff --git a/src/utils/download.ts b/src/utils/download.ts index 574a574e..fd213771 100644 --- a/src/utils/download.ts +++ b/src/utils/download.ts @@ -5,6 +5,35 @@ import { ViewMedia } from '../view/media'; import { errorToConsole } from './basic'; import { homeAssistantSignPath } from './ha'; +export const downloadURL = (url: string, filename = 'download'): void => { + // The download attribute only works on the same origin. + // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes + const isSameOrigin = new URL(url).origin === window.location.origin; + const dataURL = url.startsWith('data:'); + + if ( + navigator.userAgent.startsWith('Home Assistant/') || + navigator.userAgent.startsWith('HomeAssistant/') || + (!isSameOrigin && !dataURL) + ) { + // Home Assistant companion apps cannot download files without opening a + // new browser window. + // + // User-agents are specified here: + // - Android: https://github.com/home-assistant/android/blob/b285c9525dd4837a82db931c1b2321c0511494e6/common/src/main/java/io/homeassistant/companion/android/common/data/HomeAssistantApis.kt#L23 + // - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75 + window.open(url, '_blank'); + } else { + // Use the HTML5 download attribute to prevent a new window from + // temporarily opening. + const link = document.createElement('a'); + link.setAttribute('download', filename); + link.href = url; + link.click(); + link.remove(); + } +}; + export const downloadMedia = async ( hass: ExtendedHomeAssistant, cameraManager: CameraManager, @@ -30,29 +59,5 @@ export const downloadMedia = async ( finalURL = response; } - // The download attribute only works on the same origin. - // See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes - const isSameOrigin = new URL(finalURL).origin === window.location.origin; - - if ( - !isSameOrigin || - navigator.userAgent.startsWith('Home Assistant/') || - navigator.userAgent.startsWith('HomeAssistant/') - ) { - // Home Assistant companion apps cannot download files without opening a - // new browser window. - // - // User-agents are specified here: - // - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107 - // - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75 - window.open(finalURL, '_blank'); - } else { - // Use the HTML5 download attribute to prevent a new window from - // temporarily opening. - const link = document.createElement('a'); - link.setAttribute('download', 'download'); - link.href = finalURL; - link.click(); - link.remove(); - } + downloadURL(finalURL); }; diff --git a/src/utils/menu-controller.ts b/src/utils/menu-controller.ts index ba7fcaca..18d9a841 100644 --- a/src/utils/menu-controller.ts +++ b/src/utils/menu-controller.ts @@ -382,6 +382,16 @@ export class MenuButtonController { } } + if (options?.currentMediaLoadedInfo && options.currentMediaLoadedInfo.player) { + buttons.push({ + icon: 'mdi:monitor-screenshot', + ...config.menu.buttons.screenshot, + type: 'custom:frigate-card-menu-icon', + title: localize('config.menu.buttons.screenshot'), + tap_action: createFrigateCardCustomAction('screenshot') as FrigateCardCustomAction, + }); + } + const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({ style: this._getStyleFromActions(config, view, button), ...button, diff --git a/src/utils/screenshot.ts b/src/utils/screenshot.ts new file mode 100644 index 00000000..ce19b830 --- /dev/null +++ b/src/utils/screenshot.ts @@ -0,0 +1,29 @@ +import format from 'date-fns/format'; +import { View } from '../view/view'; + +export const screenshotMedia = (video: HTMLVideoElement): string | null => { + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return null; + } + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + return canvas.toDataURL('image/jpeg'); +}; + +export const generateScreenshotTitle = (view?: View): string => { + if (view?.is('live') || view?.is('image')) { + return `${view.view}-${view.camera}-${format( + new Date(), + `yyyy-MM-dd-HH-mm-ss`, + )}.jpg`; + } else if (view?.isViewerView()) { + const media = view.queryResults?.getSelectedResult(); + const id = media?.getID() ?? null; + return `${view.view}-${view.camera}${id ? `-${id}` : ''}.jpg`; + } + return 'screenshot.jpg'; +}; diff --git a/tests/camera-manager/utils.test.ts b/tests/camera-manager/utils.test.ts index 96ba0731..0a870cc4 100644 --- a/tests/camera-manager/utils.test.ts +++ b/tests/camera-manager/utils.test.ts @@ -5,8 +5,9 @@ import { getCameraEntityFromConfig, sortMedia, } from '../../src/camera-manager/util.js'; -import { ViewMedia, ViewMediaType } from '../../src/view/media.js'; import { CameraConfig, cameraConfigSchema } from '../../src/types.js'; +import { ViewMedia, ViewMediaType } from '../../src/view/media.js'; +import { TestViewMedia } from '../test-utils.js'; describe('convertRangeToCacheFriendlyTimes', () => { it('should return cache friendly within hour range', () => { @@ -74,30 +75,6 @@ describe('capEndDate', () => { }); }); -// ViewMedia itself has no native way to set startTime and ID that aren't linked -// to an engine. -class TestViewMedia extends ViewMedia { - protected _ID: string | null; - protected _startTime: Date; - - constructor( - ID: string | null, - startTime: Date, - mediaType: ViewMediaType, - cameraID: string, - ) { - super(mediaType, cameraID); - this._ID = ID; - this._startTime = startTime; - } - public getID(): string | null { - return this._ID; - } - public getStartTime(): Date | null { - return this._startTime; - } -} - describe('sortMedia', () => { const media_1 = new TestViewMedia( 'id-1', diff --git a/tests/test-utils.ts b/tests/test-utils.ts index a8bd84c3..e7bda43d 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -1,4 +1,3 @@ -import { HomeAssistant } from 'custom-card-helpers'; import { HassEntities, HassEntity } from 'home-assistant-js-websocket'; import { vi } from 'vitest'; import { mock } from 'vitest-mock-extended'; @@ -13,6 +12,7 @@ import { } from '../src/camera-manager/types'; import { CameraConfig, + ExtendedHomeAssistant, FrigateCardCondition, FrigateCardConfig, MediaLoadedInfo, @@ -22,6 +22,7 @@ import { frigateCardConfigSchema, } from '../src/types'; import { Entity } from '../src/utils/ha/entity-registry/types'; +import { ViewMedia, ViewMediaType } from '../src/view/media'; import { View, ViewParameters } from '../src/view/view'; export const createCameraConfig = (config?: unknown): CameraConfig => { @@ -42,8 +43,8 @@ export const createConfig = (config?: RawFrigateCardConfig): FrigateCardConfig = }); }; -export const createHASS = (states?: HassEntities): HomeAssistant => { - const hass = mock(); +export const createHASS = (states?: HassEntities): ExtendedHomeAssistant => { + const hass = mock(); if (states) { hass.states = states; } @@ -167,3 +168,27 @@ export const createMediaLoadedInfo = ( ...options, }; }; + +// ViewMedia itself has no native way to set startTime and ID that aren't linked +// to an engine. +export class TestViewMedia extends ViewMedia { + protected _id: string | null; + protected _startTime: Date; + + constructor( + id: string | null, + startTime: Date, + mediaType: ViewMediaType, + cameraID: string, + ) { + super(mediaType, cameraID); + this._id = id; + this._startTime = startTime; + } + public getID(): string | null { + return this._id; + } + public getStartTime(): Date | null { + return this._startTime; + } +} diff --git a/tests/utils/debug.test.ts b/tests/utils/debug.test.ts index 52f678a3..2354fd02 100644 --- a/tests/utils/debug.test.ts +++ b/tests/utils/debug.test.ts @@ -4,7 +4,7 @@ import { log } from '../../src/utils/debug.js'; describe('log', () => { const spy = vi.spyOn(global.console, 'debug').mockReturnValue(undefined); afterAll(() => { - vi.resetAllMocks(); + vi.restoreAllMocks(); }); it('should do nothing without debug logging set', () => { log({}, 'foo'); diff --git a/tests/utils/download.test.ts b/tests/utils/download.test.ts new file mode 100644 index 00000000..850528f3 --- /dev/null +++ b/tests/utils/download.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CameraManager } from '../../src/camera-manager/manager.js'; +import { downloadMedia, downloadURL } from '../../src/utils/download'; +import { homeAssistantSignPath } from '../../src/utils/ha'; +import { ViewMedia } from '../../src/view/media'; +import { createCameraManager, createHASS } from '../test-utils'; + +vi.mock('../../src/camera-manager/manager.js'); +vi.mock('../../src/utils/ha'); + +const media = new ViewMedia('clip', 'camera-1'); + +// @vitest-environment jsdom +describe('downloadMedia', () => { + afterEach(() => { + vi.resetAllMocks(); + global.window.location = mock(); + }); + + it('should throw error when no media', async () => { + const cameraManager = createCameraManager(); + mock(cameraManager).getMediaDownloadPath.mockResolvedValue(null); + + expect(downloadMedia(createHASS(), cameraManager, media)).rejects.toThrow( + /No media to download/, + ); + }); + + it('should throw error when signing fails', () => { + vi.spyOn(global.console, 'warn').mockReturnValue(undefined); + + const cameraManager = createCameraManager(); + mock(cameraManager).getMediaDownloadPath.mockResolvedValue({ + sign: true, + endpoint: 'foo', + }); + const signError = new Error('sign-error'); + vi.mocked(homeAssistantSignPath).mockRejectedValue(signError); + + expect(downloadMedia(createHASS(), cameraManager, media)).rejects.toThrow( + /Could not sign media URL for download/, + ); + }); + + it('should download media', async () => { + const cameraManager = createCameraManager(); + mock(cameraManager).getMediaDownloadPath.mockResolvedValue({ + sign: true, + endpoint: 'foo', + }); + vi.mocked(homeAssistantSignPath).mockResolvedValue('http://foo/signed-url'); + const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + await downloadMedia(createHASS(), cameraManager, media); + expect(windowSpy).toBeCalledWith('http://foo/signed-url', '_blank'); + }); +}); + +describe('downloadURL', () => { + afterEach(() => { + vi.resetAllMocks(); + global.window.location = mock(); + }); + + it('should download same origin via link', async () => { + const location: Location & { origin: string } = mock(); + location.origin = 'http://foo'; + global.window.location = location; + + const link = document.createElement('a'); + link.click = vi.fn(); + link.setAttribute = vi.fn(); + vi.spyOn(document, 'createElement').mockReturnValue(link); + + downloadURL('http://foo/url.mp4'); + + expect(link.href).toBe('http://foo/url.mp4'); + expect(link.setAttribute).toBeCalledWith('download', 'download'); + expect(link.click).toBeCalled(); + }); + + it('should download in apps via window.open', async () => { + // Set the origin to the same. + const location: Location & { origin: string } = mock(); + location.origin = 'http://foo'; + global.window.location = location; + + vi.stubGlobal('navigator', { + userAgent: 'Home Assistant/2023.3.0-3260 (Android 13; Pixel 7 Pro)', + }); + + const windowSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + downloadURL('http://foo/url.mp4'); + expect(windowSpy).toBeCalledWith('http://foo/url.mp4', '_blank'); + }); +}); diff --git a/tests/utils/menu-controller.test.ts b/tests/utils/menu-controller.test.ts index e8e35657..ffff040a 100644 --- a/tests/utils/menu-controller.test.ts +++ b/tests/utils/menu-controller.test.ts @@ -1067,6 +1067,23 @@ describe('MenuButtonController', () => { }); }); + it('should have screenshot button', () => { + const buttons = calculateButtons(controller, { + currentMediaLoadedInfo: createMediaLoadedInfo({ + player: mock(), + }), + }); + + expect(buttons).toContainEqual({ + icon: 'mdi:monitor-screenshot', + enabled: false, + priority: 50, + type: 'custom:frigate-card-menu-icon', + title: 'Screenshot', + tap_action: { action: 'fire-dom-event', frigate_card_action: 'screenshot' }, + }); + }); + it('should handle dynamic buttons', () => { const button: MenuButton = { ...dynamicButton, diff --git a/tests/utils/screenshot.test.ts b/tests/utils/screenshot.test.ts new file mode 100644 index 00000000..f6fcdd37 --- /dev/null +++ b/tests/utils/screenshot.test.ts @@ -0,0 +1,95 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { generateScreenshotTitle, screenshotMedia } from '../../src/utils/screenshot'; +import { MediaQueriesResults } from '../../src/view/media-queries-results'; +import { View } from '../../src/view/view'; +import { TestViewMedia, createView } from '../test-utils'; + +// @vitest-environment jsdom +describe('screenshotMedia', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should not screenshot without context', () => { + const video = document.createElement('video'); + + const canvas = document.createElement('canvas'); + const getContext = vi.fn().mockReturnValue(null); + canvas.getContext = getContext; + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + + expect(screenshotMedia(video)).toBeNull(); + }); + + it('should screenshot', () => { + const video = document.createElement('video'); + + const canvas = document.createElement('canvas'); + const getContext = vi.fn().mockReturnValue(mock()); + canvas.getContext = getContext; + canvas.toDataURL = vi.fn().mockReturnValue('data:image/jpeg;base64'); + vi.spyOn(document, 'createElement').mockReturnValue(canvas); + + expect(screenshotMedia(video)).toBe('data:image/jpeg;base64'); + }); +}); + +describe('generateScreenshotTitle', () => { + beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2023-06-13T21:54:01')); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should get title without view', () => { + expect(generateScreenshotTitle()).toBe('screenshot.jpg'); + }); + + it('should get title for live view', () => { + expect(generateScreenshotTitle(new View({ view: 'live', camera: 'camera-1' }))).toBe( + 'live-camera-1-2023-06-13-21-54-01.jpg', + ); + }); + + it('should get title for image view', () => { + expect( + generateScreenshotTitle(new View({ view: 'image', camera: 'camera-1' })), + ).toBe('image-camera-1-2023-06-13-21-54-01.jpg'); + }); + + it('should get title for media viewer view with id', () => { + const media = new TestViewMedia( + 'id1', + new Date('2023-06-16T18:52'), + 'clip', + 'camera-1', + ); + const view = createView({ + view: 'media', + camera: 'camera-1', + queryResults: new MediaQueriesResults([media], 0), + }); + + expect(generateScreenshotTitle(view)).toBe('media-camera-1-id1.jpg'); + }); + + it('should get title for media viewer view without id', () => { + const media = new TestViewMedia( + null, + new Date('2023-06-16T18:52'), + 'clip', + 'camera-1', + ); + const view = createView({ + view: 'media', + camera: 'camera-1', + queryResults: new MediaQueriesResults([media], 0), + }); + + expect(generateScreenshotTitle(view)).toBe('media-camera-1.jpg'); + }); +});