Add screenshot support.

This commit is contained in:
Dermot Duffy
2023-06-16 19:36:45 -07:00
parent ad23083d72
commit 39b985b4df
27 changed files with 413 additions and 72 deletions
+13 -2
View File
@@ -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}`);
}
+4
View File
@@ -95,6 +95,10 @@ export class FrigateCardImage extends LitElement implements FrigateCardMediaPlay
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.
* @returns The entity or undefined if no camera entity is available.
+5
View File
@@ -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<string | null> {
return this._player ? screenshotMedia(this._player.video) : null;
}
disconnectedCallback(): void {
this._player = undefined;
}
+4
View File
@@ -54,6 +54,10 @@ export class FrigateCardLiveHA extends LitElement implements FrigateCardMediaPla
return this._playerRef.value?.isPaused() ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
return await this._playerRef.value?.getScreenshotURL() ?? null;
}
protected render(): TemplateResult | void {
if (!this.hass) {
return;
+4
View File
@@ -49,6 +49,10 @@ export class FrigateCardLiveImage extends LitElement implements FrigateCardMedia
return this._refImage.value?.isPaused() ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
return await this._refImage.value?.getScreenshotURL() ?? null;
}
protected render(): TemplateResult | void {
if (!this.hass || !this.cameraConfig) {
return;
+7
View File
@@ -84,6 +84,10 @@ export class FrigateCardLiveJSMPEG extends LitElement implements FrigateCardMedi
return this._jsmpegVideoPlayer?.player?.paused ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
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,
+10 -4
View File
@@ -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<string | null> {
const video = this._getPlayer();
return video ? screenshotMedia(video) : null;
}
connectedCallback(): void {
super.connectedCallback();
+11 -5
View File
@@ -756,19 +756,19 @@ export class FrigateCardLiveProvider
public async pause(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.pause();
await this._refProvider.value?.pause();
}
public async mute(): Promise<void> {
await this.updateComplete;
await this._refProvider.value?.updateComplete;
this._refProvider.value?.mute();
await this._refProvider.value?.mute();
}
public async unmute(): Promise<void> {
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<void> {
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<void> {
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<string | null> {
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').
+17
View File
@@ -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<Element & FrigateCardMediaPlayer> =
createRef();
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
protected _refImageProvider: Ref<HTMLImageElement> = createRef();
public async play(): Promise<void> {
await playMediaMutingIfNecessary(
@@ -629,6 +631,17 @@ export class FrigateCardViewerProvider
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.
*/
@@ -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`
${ViewMediaClassifier.isVideo(this.media)
? this.media.getVideoContentType() === VideoContentType.HLS
@@ -759,6 +774,7 @@ export class FrigateCardViewerProvider
title="${this.media.getTitle() ?? ''}"
muted
playsinline
crossorigin="anonymous"
?autoplay=${false}
?controls=${this.viewerConfig.controls.builtin}
@loadedmetadata=${(ev: Event) => {
@@ -789,6 +805,7 @@ export class FrigateCardViewerProvider
</video>
`
: html`<img
${ref(this._refImageProvider)}
aria-label="${this.media.getTitle() ?? ''}"
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
title="${this.media.getTitle() ?? ''}"
+1
View File
@@ -1786,6 +1786,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
)}
${this._renderMenuButton('play') /* */}
${this._renderMenuButton('mute')}
${this._renderMenuButton('screenshot')}
</div>
`
: ''}
+1
View File
@@ -282,6 +282,7 @@
"play": "Play / Pause",
"priority": "Priority",
"recordings": "Recordings",
"screenshot": "Screenshot",
"snapshots": "Snapshots",
"substreams": "Substream(s)",
"timeline": "Timeline",
+1
View File
@@ -279,6 +279,7 @@
"mute": "",
"play": "",
"priority": "Priorità",
"screenshot": "",
"snapshots": "Istantanee",
"substreams": "Flusso/i secondario/i",
"timeline": "Timeline",
+1
View File
@@ -281,6 +281,7 @@
"play": "",
"priority": "Prioridade",
"recordings": "Gravações",
"screenshot": "",
"snapshots": "Instantâneos",
"substreams": "Substream(s)",
"timeline": "Linha do tempo",
+1
View File
@@ -272,6 +272,7 @@
"mute": "",
"play": "",
"priority": "Prioridade",
"screenshot": "",
"snapshots": "Instantâneos",
"substreams": "substreams",
"timeline": "Linha do tempo",
+4
View File
@@ -81,6 +81,10 @@ customElements.whenDefined('ha-camera-stream').then(() => {
return this._player?.isPaused() ?? true;
}
public async getScreenshotURL(): Promise<string | null> {
return this._player ? await this._player.getScreenshotURL() : null;
}
/**
* Master render method.
* @returns A rendered template.
+8 -5
View File
@@ -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<string | null> {
return this._video ? screenshotMedia(this._video) : null;
}
// =====================================================================================
// Minor modifications from:
// - https://github.com/home-assistant/frontend/blob/dev/src/components/ha-hls-player.ts
+7 -2
View File
@@ -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<string | null> {
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
+4
View File
@@ -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<void>;
isMuted(): boolean;
seek(seconds: number): Promise<void>;
getScreenshotURL(): Promise<string | null>;
// If no value for controls if specified, the player should use the default.
setControls(controls?: boolean): Promise<void>;
isPaused(): boolean;
+30 -25
View File
@@ -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);
};
+10
View File
@@ -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,
+29
View File
@@ -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';
};