Add screenshot support.
This commit is contained in:
+13
-2
@@ -68,7 +68,7 @@ import {
|
|||||||
} from './utils/action.js';
|
} from './utils/action.js';
|
||||||
import { errorToConsole } from './utils/basic.js';
|
import { errorToConsole } from './utils/basic.js';
|
||||||
import { log } from './utils/debug.js';
|
import { log } from './utils/debug.js';
|
||||||
import { downloadMedia } from './utils/download.js';
|
import { downloadMedia, downloadURL } from './utils/download.js';
|
||||||
import {
|
import {
|
||||||
getHassDifferences,
|
getHassDifferences,
|
||||||
isCardInPanel,
|
isCardInPanel,
|
||||||
@@ -83,10 +83,12 @@ import { Entity } from './utils/ha/entity-registry/types.js';
|
|||||||
import { ResolvedMediaCache } from './utils/ha/resolved-media.js';
|
import { ResolvedMediaCache } from './utils/ha/resolved-media.js';
|
||||||
import { supportsFeature } from './utils/ha/update.js';
|
import { supportsFeature } from './utils/ha/update.js';
|
||||||
import { FrigateCardInitializer } from './utils/initializer.js';
|
import { FrigateCardInitializer } from './utils/initializer.js';
|
||||||
|
import { MediaLoadedInfoController } from './utils/media-info-controller';
|
||||||
import { isValidMediaLoadedInfo } from './utils/media-info.js';
|
import { isValidMediaLoadedInfo } from './utils/media-info.js';
|
||||||
import { MenuButtonController } from './utils/menu-controller';
|
import { MenuButtonController } from './utils/menu-controller';
|
||||||
import { MicrophoneController } from './utils/microphone';
|
import { MicrophoneController } from './utils/microphone';
|
||||||
import { getActionsFromQueryString } from './utils/querystring.js';
|
import { getActionsFromQueryString } from './utils/querystring.js';
|
||||||
|
import { generateScreenshotTitle } from './utils/screenshot';
|
||||||
import {
|
import {
|
||||||
createViewWithNextStream,
|
createViewWithNextStream,
|
||||||
createViewWithoutSubstream,
|
createViewWithoutSubstream,
|
||||||
@@ -95,7 +97,6 @@ import {
|
|||||||
import { Timer } from './utils/timer';
|
import { Timer } from './utils/timer';
|
||||||
import { getParseErrorPaths } from './utils/zod.js';
|
import { getParseErrorPaths } from './utils/zod.js';
|
||||||
import { View } from './view/view.js';
|
import { View } from './view/view.js';
|
||||||
import { MediaLoadedInfoController } from './utils/media-info-controller';
|
|
||||||
|
|
||||||
/** A note on media callbacks:
|
/** A note on media callbacks:
|
||||||
*
|
*
|
||||||
@@ -1159,6 +1160,16 @@ class FrigateCard extends LitElement {
|
|||||||
case 'pause':
|
case 'pause':
|
||||||
this._mediaLoadedInfoController.get()?.player?.pause();
|
this._mediaLoadedInfoController.get()?.player?.pause();
|
||||||
break;
|
break;
|
||||||
|
case 'screenshot':
|
||||||
|
this._mediaLoadedInfoController
|
||||||
|
.get()
|
||||||
|
?.player?.getScreenshotURL()
|
||||||
|
.then((url: string | null) => {
|
||||||
|
if (url) {
|
||||||
|
downloadURL(url, generateScreenshotTitle(this._view));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn(`Frigate card received unknown card action: ${action}`);
|
console.warn(`Frigate card received unknown card action: ${action}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
|
|||||||
return !this._cachedValueController?.hasTimer() ?? true;
|
return !this._cachedValueController?.hasTimer() ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
return this._cachedValueController?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the camera entity for the current camera configuration.
|
* Get the camera entity for the current camera configuration.
|
||||||
* @returns The entity or undefined if no camera entity is available.
|
* @returns The entity or undefined if no camera entity is available.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint';
|
import { getEndpointAddressOrDispatchError } from '../../utils/endpoint';
|
||||||
import { setControlsOnVideo } from '../../utils/media.js';
|
import { setControlsOnVideo } from '../../utils/media.js';
|
||||||
|
import { screenshotMedia } from '../../utils/screenshot.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';
|
||||||
@@ -93,6 +94,10 @@ export class FrigateCardGo2RTC extends LitElement implements FrigateCardMediaPla
|
|||||||
return this._player?.video.paused ?? true;
|
return this._player?.video.paused ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
return this._player ? screenshotMedia(this._player.video) : null;
|
||||||
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
this._player = undefined;
|
this._player = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
|
|||||||
return this._playerRef.value?.isPaused() ?? true;
|
return this._playerRef.value?.isPaused() ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
return await this._playerRef.value?.getScreenshotURL() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass) {
|
if (!this.hass) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
|
|||||||
return this._refImage.value?.isPaused() ?? true;
|
return this._refImage.value?.isPaused() ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
return await this._refImage.value?.getScreenshotURL() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
protected render(): TemplateResult | void {
|
protected render(): TemplateResult | void {
|
||||||
if (!this.hass || !this.cameraConfig) {
|
if (!this.hass || !this.cameraConfig) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
|||||||
return this._jsmpegVideoPlayer?.player?.paused ?? true;
|
return this._jsmpegVideoPlayer?.player?.paused ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
return this._jsmpegCanvasElement?.toDataURL('image/jpeg') ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a JSMPEG player.
|
* Create a JSMPEG player.
|
||||||
* @param url The URL for the player to connect to.
|
* @param url The URL for the player to connect to.
|
||||||
@@ -107,6 +111,9 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
|
|||||||
audio: false,
|
audio: false,
|
||||||
videoBufferSize: 1024 * 1024 * 4,
|
videoBufferSize: 1024 * 1024 * 4,
|
||||||
|
|
||||||
|
// Necessary for screenshots.
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
|
||||||
// Override with user-specified options.
|
// Override with user-specified options.
|
||||||
...this.cameraConfig?.jsmpeg?.options,
|
...this.cameraConfig?.jsmpeg?.options,
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Task } from '@lit-labs/task';
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
import { HomeAssistant } from 'custom-card-helpers';
|
||||||
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { CameraEndpoints } from '../../camera-manager/types.js';
|
||||||
import { localize } from '../../localize/localize.js';
|
import { localize } from '../../localize/localize.js';
|
||||||
import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss';
|
import liveWebRTCCardStyle from '../../scss/live-webrtc-card.scss';
|
||||||
import {
|
import {
|
||||||
@@ -10,21 +11,21 @@ import {
|
|||||||
FrigateCardError,
|
FrigateCardError,
|
||||||
FrigateCardMediaPlayer,
|
FrigateCardMediaPlayer,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
|
import { mayHaveAudio } from '../../utils/audio.js';
|
||||||
import {
|
import {
|
||||||
dispatchMediaLoadedEvent,
|
dispatchMediaLoadedEvent,
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
dispatchMediaPlayEvent,
|
dispatchMediaPlayEvent,
|
||||||
dispatchMediaVolumeChangeEvent,
|
dispatchMediaVolumeChangeEvent,
|
||||||
} from '../../utils/media-info.js';
|
} from '../../utils/media-info.js';
|
||||||
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
|
|
||||||
import { renderTask } from '../../utils/task.js';
|
|
||||||
import {
|
import {
|
||||||
hideMediaControlsTemporarily,
|
hideMediaControlsTemporarily,
|
||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
setControlsOnVideo,
|
setControlsOnVideo,
|
||||||
} from '../../utils/media.js';
|
} from '../../utils/media.js';
|
||||||
import { CameraEndpoints } from '../../camera-manager/types.js';
|
import { screenshotMedia } from '../../utils/screenshot.js';
|
||||||
import { mayHaveAudio } from '../../utils/audio.js';
|
import { renderTask } from '../../utils/task.js';
|
||||||
|
import { dispatchErrorMessageEvent, renderProgressIndicator } from '../message.js';
|
||||||
|
|
||||||
// Create a wrapper for AlexxIT's WebRTC card
|
// Create a wrapper for AlexxIT's WebRTC card
|
||||||
// - https://github.com/AlexxIT/WebRTC
|
// - https://github.com/AlexxIT/WebRTC
|
||||||
@@ -94,6 +95,11 @@ export class FrigateCardLiveWebRTCCard
|
|||||||
return this._getPlayer()?.paused ?? true;
|
return this._getPlayer()?.paused ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
const video = this._getPlayer();
|
||||||
|
return video ? screenshotMedia(video) : null;
|
||||||
|
}
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
|
|||||||
@@ -756,19 +756,19 @@ export class FrigateCardLiveProvider
|
|||||||
public async pause(): Promise<void> {
|
public async pause(): Promise<void> {
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
await this._refProvider.value?.updateComplete;
|
await this._refProvider.value?.updateComplete;
|
||||||
this._refProvider.value?.pause();
|
await this._refProvider.value?.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async mute(): Promise<void> {
|
public async mute(): Promise<void> {
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
await this._refProvider.value?.updateComplete;
|
await this._refProvider.value?.updateComplete;
|
||||||
this._refProvider.value?.mute();
|
await this._refProvider.value?.mute();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async unmute(): Promise<void> {
|
public async unmute(): Promise<void> {
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
await this._refProvider.value?.updateComplete;
|
await this._refProvider.value?.updateComplete;
|
||||||
this._refProvider.value?.unmute();
|
await this._refProvider.value?.unmute();
|
||||||
}
|
}
|
||||||
|
|
||||||
public isMuted(): boolean {
|
public isMuted(): boolean {
|
||||||
@@ -778,19 +778,25 @@ export class FrigateCardLiveProvider
|
|||||||
public async seek(seconds: number): Promise<void> {
|
public async seek(seconds: number): Promise<void> {
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
await this._refProvider.value?.updateComplete;
|
await this._refProvider.value?.updateComplete;
|
||||||
this._refProvider.value?.seek(seconds);
|
await this._refProvider.value?.seek(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setControls(controls?: boolean): Promise<void> {
|
public async setControls(controls?: boolean): Promise<void> {
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
await this._refProvider.value?.updateComplete;
|
await this._refProvider.value?.updateComplete;
|
||||||
this._refProvider.value?.setControls(controls);
|
await this._refProvider.value?.setControls(controls);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isPaused(): boolean {
|
public isPaused(): boolean {
|
||||||
return this._refProvider.value?.isPaused() ?? true;
|
return this._refProvider.value?.isPaused() ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
await this.updateComplete;
|
||||||
|
await this._refProvider.value?.updateComplete;
|
||||||
|
return await this._refProvider.value?.getScreenshotURL() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the fully resolved live provider.
|
* Get the fully resolved live provider.
|
||||||
* @returns A live provider (that is not 'auto').
|
* @returns A live provider (that is not 'auto').
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
playMediaMutingIfNecessary,
|
playMediaMutingIfNecessary,
|
||||||
setControlsOnVideo,
|
setControlsOnVideo,
|
||||||
} from '../utils/media.js';
|
} from '../utils/media.js';
|
||||||
|
import { screenshotMedia } from '../utils/screenshot.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';
|
||||||
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
import { MediaQueriesResults } from '../view/media-queries-results.js';
|
||||||
@@ -563,6 +564,7 @@ export class FrigateCardViewerProvider
|
|||||||
protected _refFrigateCardMediaPlayer: Ref<Element & FrigateCardMediaPlayer> =
|
protected _refFrigateCardMediaPlayer: Ref<Element & FrigateCardMediaPlayer> =
|
||||||
createRef();
|
createRef();
|
||||||
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
|
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
|
||||||
|
protected _refImageProvider: Ref<HTMLImageElement> = createRef();
|
||||||
|
|
||||||
public async play(): Promise<void> {
|
public async play(): Promise<void> {
|
||||||
await playMediaMutingIfNecessary(
|
await playMediaMutingIfNecessary(
|
||||||
@@ -629,6 +631,17 @@ export class FrigateCardViewerProvider
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
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.
|
* Dispatch a clip view that matches the current (snapshot) query.
|
||||||
*/
|
*/
|
||||||
@@ -735,6 +748,8 @@ export class FrigateCardViewerProvider
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: crossorigin="anonymous" is required on <video> below in order to
|
||||||
|
// allow screenshot of motionEye videos which currently go cross-origin.
|
||||||
return this._useZoomIfRequired(html`
|
return this._useZoomIfRequired(html`
|
||||||
${ViewMediaClassifier.isVideo(this.media)
|
${ViewMediaClassifier.isVideo(this.media)
|
||||||
? this.media.getVideoContentType() === VideoContentType.HLS
|
? this.media.getVideoContentType() === VideoContentType.HLS
|
||||||
@@ -759,6 +774,7 @@ export class FrigateCardViewerProvider
|
|||||||
title="${this.media.getTitle() ?? ''}"
|
title="${this.media.getTitle() ?? ''}"
|
||||||
muted
|
muted
|
||||||
playsinline
|
playsinline
|
||||||
|
crossorigin="anonymous"
|
||||||
?autoplay=${false}
|
?autoplay=${false}
|
||||||
?controls=${this.viewerConfig.controls.builtin}
|
?controls=${this.viewerConfig.controls.builtin}
|
||||||
@loadedmetadata=${(ev: Event) => {
|
@loadedmetadata=${(ev: Event) => {
|
||||||
@@ -789,6 +805,7 @@ export class FrigateCardViewerProvider
|
|||||||
</video>
|
</video>
|
||||||
`
|
`
|
||||||
: html`<img
|
: html`<img
|
||||||
|
${ref(this._refImageProvider)}
|
||||||
aria-label="${this.media.getTitle() ?? ''}"
|
aria-label="${this.media.getTitle() ?? ''}"
|
||||||
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
|
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
|
||||||
title="${this.media.getTitle() ?? ''}"
|
title="${this.media.getTitle() ?? ''}"
|
||||||
|
|||||||
@@ -1786,6 +1786,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
|||||||
)}
|
)}
|
||||||
${this._renderMenuButton('play') /* */}
|
${this._renderMenuButton('play') /* */}
|
||||||
${this._renderMenuButton('mute')}
|
${this._renderMenuButton('mute')}
|
||||||
|
${this._renderMenuButton('screenshot')}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
@@ -282,6 +282,7 @@
|
|||||||
"play": "Play / Pause",
|
"play": "Play / Pause",
|
||||||
"priority": "Priority",
|
"priority": "Priority",
|
||||||
"recordings": "Recordings",
|
"recordings": "Recordings",
|
||||||
|
"screenshot": "Screenshot",
|
||||||
"snapshots": "Snapshots",
|
"snapshots": "Snapshots",
|
||||||
"substreams": "Substream(s)",
|
"substreams": "Substream(s)",
|
||||||
"timeline": "Timeline",
|
"timeline": "Timeline",
|
||||||
|
|||||||
@@ -279,6 +279,7 @@
|
|||||||
"mute": "",
|
"mute": "",
|
||||||
"play": "",
|
"play": "",
|
||||||
"priority": "Priorità",
|
"priority": "Priorità",
|
||||||
|
"screenshot": "",
|
||||||
"snapshots": "Istantanee",
|
"snapshots": "Istantanee",
|
||||||
"substreams": "Flusso/i secondario/i",
|
"substreams": "Flusso/i secondario/i",
|
||||||
"timeline": "Timeline",
|
"timeline": "Timeline",
|
||||||
|
|||||||
@@ -281,6 +281,7 @@
|
|||||||
"play": "",
|
"play": "",
|
||||||
"priority": "Prioridade",
|
"priority": "Prioridade",
|
||||||
"recordings": "Gravações",
|
"recordings": "Gravações",
|
||||||
|
"screenshot": "",
|
||||||
"snapshots": "Instantâneos",
|
"snapshots": "Instantâneos",
|
||||||
"substreams": "Substream(s)",
|
"substreams": "Substream(s)",
|
||||||
"timeline": "Linha do tempo",
|
"timeline": "Linha do tempo",
|
||||||
|
|||||||
@@ -272,6 +272,7 @@
|
|||||||
"mute": "",
|
"mute": "",
|
||||||
"play": "",
|
"play": "",
|
||||||
"priority": "Prioridade",
|
"priority": "Prioridade",
|
||||||
|
"screenshot": "",
|
||||||
"snapshots": "Instantâneos",
|
"snapshots": "Instantâneos",
|
||||||
"substreams": "substreams",
|
"substreams": "substreams",
|
||||||
"timeline": "Linha do tempo",
|
"timeline": "Linha do tempo",
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
|
|||||||
return this._player?.isPaused() ?? true;
|
return this._player?.isPaused() ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
return this._player ? await this._player.getScreenshotURL() : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Master render method.
|
* Master render method.
|
||||||
* @returns A rendered template.
|
* @returns A rendered template.
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
// available as compilation time.
|
// 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 { customElement } from 'lit/decorators.js';
|
||||||
import { query } from 'lit/decorators/query.js';
|
import { query } from 'lit/decorators/query.js';
|
||||||
|
import { screenshotMedia } from '../utils/screenshot.js';
|
||||||
import { dispatchErrorMessageEvent } from '../components/message.js';
|
import { dispatchErrorMessageEvent } from '../components/message.js';
|
||||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||||
import { FrigateCardMediaPlayer } from '../types.js';
|
import { FrigateCardMediaPlayer } from '../types.js';
|
||||||
@@ -20,12 +21,10 @@ import {
|
|||||||
dispatchMediaLoadedEvent,
|
dispatchMediaLoadedEvent,
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
dispatchMediaPlayEvent,
|
dispatchMediaPlayEvent,
|
||||||
dispatchMediaVolumeChangeEvent,
|
dispatchMediaVolumeChangeEvent
|
||||||
} from '../utils/media-info.js';
|
} from '../utils/media-info.js';
|
||||||
import {
|
import {
|
||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
hideMediaControlsTemporarily, MEDIA_LOAD_CONTROLS_HIDE_SECONDS, setControlsOnVideo
|
||||||
hideMediaControlsTemporarily,
|
|
||||||
setControlsOnVideo,
|
|
||||||
} from '../utils/media.js';
|
} from '../utils/media.js';
|
||||||
|
|
||||||
customElements.whenDefined('ha-hls-player').then(() => {
|
customElements.whenDefined('ha-hls-player').then(() => {
|
||||||
@@ -84,6 +83,10 @@ customElements.whenDefined('ha-hls-player').then(() => {
|
|||||||
return this._video?.paused ?? true;
|
return this._video?.paused ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
return this._video ? screenshotMedia(this._video) : null;
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================================
|
// =====================================================================================
|
||||||
// Minor modifications from:
|
// Minor modifications from:
|
||||||
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
|
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
|
import { css, CSSResultGroup, html, TemplateResult, unsafeCSS } from 'lit';
|
||||||
import { customElement } from 'lit/decorators.js';
|
import { customElement } from 'lit/decorators.js';
|
||||||
import { query } from 'lit/decorators/query.js';
|
import { query } from 'lit/decorators/query.js';
|
||||||
|
import { screenshotMedia } from '../utils/screenshot.js';
|
||||||
import { dispatchErrorMessageEvent } from '../components/message.js';
|
import { dispatchErrorMessageEvent } from '../components/message.js';
|
||||||
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
import liveHAComponentsStyle from '../scss/live-ha-components.scss';
|
||||||
import { FrigateCardMediaPlayer } from '../types.js';
|
import { FrigateCardMediaPlayer } from '../types.js';
|
||||||
@@ -20,12 +21,12 @@ import {
|
|||||||
dispatchMediaLoadedEvent,
|
dispatchMediaLoadedEvent,
|
||||||
dispatchMediaPauseEvent,
|
dispatchMediaPauseEvent,
|
||||||
dispatchMediaPlayEvent,
|
dispatchMediaPlayEvent,
|
||||||
dispatchMediaVolumeChangeEvent,
|
dispatchMediaVolumeChangeEvent
|
||||||
} from '../utils/media-info.js';
|
} from '../utils/media-info.js';
|
||||||
import {
|
import {
|
||||||
hideMediaControlsTemporarily,
|
hideMediaControlsTemporarily,
|
||||||
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
|
||||||
setControlsOnVideo,
|
setControlsOnVideo
|
||||||
} from '../utils/media.js';
|
} from '../utils/media.js';
|
||||||
|
|
||||||
customElements.whenDefined('ha-web-rtc-player').then(() => {
|
customElements.whenDefined('ha-web-rtc-player').then(() => {
|
||||||
@@ -83,6 +84,10 @@ customElements.whenDefined('ha-web-rtc-player').then(() => {
|
|||||||
return this._video?.paused ?? true;
|
return this._video?.paused ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getScreenshotURL(): Promise<string | null> {
|
||||||
|
return this._video ? screenshotMedia(this._video) : null;
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================================
|
// =====================================================================================
|
||||||
// Minor modifications from:
|
// Minor modifications from:
|
||||||
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
|
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-web-rtc-player.ts
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
|
|||||||
'pause',
|
'pause',
|
||||||
'recording',
|
'recording',
|
||||||
'recordings',
|
'recordings',
|
||||||
|
'screenshot',
|
||||||
'snapshot',
|
'snapshot',
|
||||||
'snapshots',
|
'snapshots',
|
||||||
'timeline',
|
'timeline',
|
||||||
@@ -1078,6 +1079,7 @@ const menuConfigDefault = {
|
|||||||
mute: hiddenButtonDefault,
|
mute: hiddenButtonDefault,
|
||||||
play: hiddenButtonDefault,
|
play: hiddenButtonDefault,
|
||||||
recordings: hiddenButtonDefault,
|
recordings: hiddenButtonDefault,
|
||||||
|
screenshot: hiddenButtonDefault,
|
||||||
},
|
},
|
||||||
button_size: 40,
|
button_size: 40,
|
||||||
};
|
};
|
||||||
@@ -1123,6 +1125,7 @@ const menuConfigSchema = z
|
|||||||
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings),
|
||||||
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute),
|
||||||
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
play: hiddenButtonSchema.default(menuConfigDefault.buttons.play),
|
||||||
|
screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot),
|
||||||
})
|
})
|
||||||
.default(menuConfigDefault.buttons),
|
.default(menuConfigDefault.buttons),
|
||||||
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size),
|
||||||
@@ -1530,6 +1533,7 @@ export interface FrigateCardMediaPlayer {
|
|||||||
unmute(): Promise<void>;
|
unmute(): Promise<void>;
|
||||||
isMuted(): boolean;
|
isMuted(): boolean;
|
||||||
seek(seconds: number): Promise<void>;
|
seek(seconds: number): Promise<void>;
|
||||||
|
getScreenshotURL(): Promise<string | null>;
|
||||||
// If no value for controls if specified, the player should use the default.
|
// If no value for controls if specified, the player should use the default.
|
||||||
setControls(controls?: boolean): Promise<void>;
|
setControls(controls?: boolean): Promise<void>;
|
||||||
isPaused(): boolean;
|
isPaused(): boolean;
|
||||||
|
|||||||
+30
-25
@@ -5,6 +5,35 @@ import { ViewMedia } from '../view/media';
|
|||||||
import { errorToConsole } from './basic';
|
import { errorToConsole } from './basic';
|
||||||
import { homeAssistantSignPath } from './ha';
|
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 (
|
export const downloadMedia = async (
|
||||||
hass: ExtendedHomeAssistant,
|
hass: ExtendedHomeAssistant,
|
||||||
cameraManager: CameraManager,
|
cameraManager: CameraManager,
|
||||||
@@ -30,29 +59,5 @@ export const downloadMedia = async (
|
|||||||
finalURL = response;
|
finalURL = response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The download attribute only works on the same origin.
|
downloadURL(finalURL);
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => ({
|
const styledDynamicButtons = this._dynamicMenuButtons.map((button) => ({
|
||||||
style: this._getStyleFromActions(config, view, button),
|
style: this._getStyleFromActions(config, view, button),
|
||||||
...button,
|
...button,
|
||||||
|
|||||||
@@ -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';
|
||||||
|
};
|
||||||
@@ -5,8 +5,9 @@ import {
|
|||||||
getCameraEntityFromConfig,
|
getCameraEntityFromConfig,
|
||||||
sortMedia,
|
sortMedia,
|
||||||
} from '../../src/camera-manager/util.js';
|
} from '../../src/camera-manager/util.js';
|
||||||
import { ViewMedia, ViewMediaType } from '../../src/view/media.js';
|
|
||||||
import { CameraConfig, cameraConfigSchema } from '../../src/types.js';
|
import { CameraConfig, cameraConfigSchema } from '../../src/types.js';
|
||||||
|
import { ViewMedia, ViewMediaType } from '../../src/view/media.js';
|
||||||
|
import { TestViewMedia } from '../test-utils.js';
|
||||||
|
|
||||||
describe('convertRangeToCacheFriendlyTimes', () => {
|
describe('convertRangeToCacheFriendlyTimes', () => {
|
||||||
it('should return cache friendly within hour range', () => {
|
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', () => {
|
describe('sortMedia', () => {
|
||||||
const media_1 = new TestViewMedia(
|
const media_1 = new TestViewMedia(
|
||||||
'id-1',
|
'id-1',
|
||||||
|
|||||||
+28
-3
@@ -1,4 +1,3 @@
|
|||||||
import { HomeAssistant } from 'custom-card-helpers';
|
|
||||||
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
|
import { HassEntities, HassEntity } from 'home-assistant-js-websocket';
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { mock } from 'vitest-mock-extended';
|
import { mock } from 'vitest-mock-extended';
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
} from '../src/camera-manager/types';
|
} from '../src/camera-manager/types';
|
||||||
import {
|
import {
|
||||||
CameraConfig,
|
CameraConfig,
|
||||||
|
ExtendedHomeAssistant,
|
||||||
FrigateCardCondition,
|
FrigateCardCondition,
|
||||||
FrigateCardConfig,
|
FrigateCardConfig,
|
||||||
MediaLoadedInfo,
|
MediaLoadedInfo,
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
frigateCardConfigSchema,
|
frigateCardConfigSchema,
|
||||||
} from '../src/types';
|
} from '../src/types';
|
||||||
import { Entity } from '../src/utils/ha/entity-registry/types';
|
import { Entity } from '../src/utils/ha/entity-registry/types';
|
||||||
|
import { ViewMedia, ViewMediaType } from '../src/view/media';
|
||||||
import { View, ViewParameters } from '../src/view/view';
|
import { View, ViewParameters } from '../src/view/view';
|
||||||
|
|
||||||
export const createCameraConfig = (config?: unknown): CameraConfig => {
|
export const createCameraConfig = (config?: unknown): CameraConfig => {
|
||||||
@@ -42,8 +43,8 @@ export const createConfig = (config?: RawFrigateCardConfig): FrigateCardConfig =
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createHASS = (states?: HassEntities): HomeAssistant => {
|
export const createHASS = (states?: HassEntities): ExtendedHomeAssistant => {
|
||||||
const hass = mock<HomeAssistant>();
|
const hass = mock<ExtendedHomeAssistant>();
|
||||||
if (states) {
|
if (states) {
|
||||||
hass.states = states;
|
hass.states = states;
|
||||||
}
|
}
|
||||||
@@ -167,3 +168,27 @@ export const createMediaLoadedInfo = (
|
|||||||
...options,
|
...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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { log } from '../../src/utils/debug.js';
|
|||||||
describe('log', () => {
|
describe('log', () => {
|
||||||
const spy = vi.spyOn(global.console, 'debug').mockReturnValue(undefined);
|
const spy = vi.spyOn(global.console, 'debug').mockReturnValue(undefined);
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
vi.resetAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
it('should do nothing without debug logging set', () => {
|
it('should do nothing without debug logging set', () => {
|
||||||
log({}, 'foo');
|
log({}, 'foo');
|
||||||
|
|||||||
@@ -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<Location>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when no media', async () => {
|
||||||
|
const cameraManager = createCameraManager();
|
||||||
|
mock<CameraManager>(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>(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>(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<Location>();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should download same origin via link', async () => {
|
||||||
|
const location: Location & { origin: string } = mock<Location>();
|
||||||
|
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>();
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1067,6 +1067,23 @@ describe('MenuButtonController', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should have screenshot button', () => {
|
||||||
|
const buttons = calculateButtons(controller, {
|
||||||
|
currentMediaLoadedInfo: createMediaLoadedInfo({
|
||||||
|
player: mock<FrigateCardMediaPlayer>(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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', () => {
|
it('should handle dynamic buttons', () => {
|
||||||
const button: MenuButton = {
|
const button: MenuButton = {
|
||||||
...dynamicButton,
|
...dynamicButton,
|
||||||
|
|||||||
@@ -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<CanvasRenderingContext2D>());
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user