13 changed files with 842 additions and 195 deletions
File diff suppressed because one or more lines are too long
@ -0,0 +1,74 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2020 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div fxLayout="column" fxLayoutAlign="center center" class="tb-web-camera" tb-fullscreen [fullscreen]="isShowCamera"> |
|||
<div [fxShow]="isEntityDetected && dataKeyDetected && isCameraSupport && isDeviceDetect" fxFlexFill> |
|||
<div [fxShow]="!isShowCamera" fxLayout="column" fxLayoutAlign="space-between center" fxFlexFill> |
|||
<div class="tb-web-camera__last-photo" fxFlex> |
|||
<span [fxShow]="!lastPhoto" class="tb-web-camera__last-photo_text" translate>widgets.input-widgets.no-image</span> |
|||
<img [fxShow]="lastPhoto" class="tb-web-camera__last-photo_img" [src]="lastPhoto" alt="last photo"/> |
|||
</div> |
|||
<button mat-raised-button color="primary" (click)="takePhoto()"> |
|||
{{ "widgets.input-widgets.take-photo" | translate }} |
|||
</button> |
|||
</div> |
|||
<div [fxShow]="isShowCamera" fxLayout="column" fxLayoutAlign="center center" class="camera-container"> |
|||
<div class="camera" [fxShow]="!isPreviewPhoto"> |
|||
<video autoplay muted playsinline class="camera-stream" #videoStream></video> |
|||
<div class="camera-controls" fxLayout="row wrap" fxLayoutAlign="space-between end"> |
|||
<div fxFlex></div> |
|||
<button mat-mini-fab color="primary" (click)="switchWebCamera()" [disabled]="singleDevice"> |
|||
<mat-icon>switch_camera</mat-icon> |
|||
</button> |
|||
<button mat-fab color="accent" (click)="createPhoto()"> |
|||
<mat-icon>photo_camera</mat-icon> |
|||
</button> |
|||
<button mat-mini-fab color="primary" (click)="closeCamera()"> |
|||
<mat-icon>close</mat-icon> |
|||
</button> |
|||
<div fxFlex></div> |
|||
</div> |
|||
</div> |
|||
<div class="camera" [fxShow]="isPreviewPhoto"> |
|||
<img alt="preview photo" class="camera-stream" [src]="previewPhoto"> |
|||
<canvas #canvas style="display:none;"></canvas> |
|||
<div class="camera-controls" fxLayout="row" fxLayoutAlign="space-between end"> |
|||
<div fxFlex></div> |
|||
<button mat-fab color="primary" [disabled]="updatePhoto" (click)="cancelPhoto()"> |
|||
<mat-icon>close</mat-icon> |
|||
</button> |
|||
<button mat-fab color="accent" [disabled]="updatePhoto" (click)="savePhoto()"> |
|||
<mat-icon>check</mat-icon> |
|||
</button> |
|||
<div fxFlex></div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="message-text" [fxHide]="isEntityDetected"> |
|||
{{ 'widgets.input-widgets.no-entity-selected' | translate }} |
|||
</div> |
|||
<div class="message-text" [fxShow]="isEntityDetected && !dataKeyDetected"> |
|||
{{ 'widgets.input-widgets.no-datakey-selected' | translate }} |
|||
</div> |
|||
<div class="message-text" [fxShow]="isEntityDetected && dataKeyDetected && !isCameraSupport"> |
|||
{{ 'widgets.input-widgets.no-support-web-camera' | translate }} |
|||
</div> |
|||
<div class="message-text" [fxShow]="isEntityDetected && dataKeyDetected && isCameraSupport && !isDeviceDetect"> |
|||
{{ 'widgets.input-widgets.no-support-web-camera' | translate }} |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,74 @@ |
|||
/** |
|||
* Copyright © 2016-2020 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
.tb-web-camera { |
|||
height: 100%; |
|||
|
|||
&__last-photo { |
|||
width: 100%; |
|||
margin: 5px 0; |
|||
text-align: center; |
|||
border: solid 1px; |
|||
|
|||
&_text { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: 50%; |
|||
margin-top: -.625em; |
|||
transform: translate(-50%, -50%); |
|||
} |
|||
|
|||
&_img { |
|||
width: 100%; |
|||
height: 100%; |
|||
object-fit: contain; |
|||
} |
|||
} |
|||
|
|||
.camera-container{ |
|||
height: 100%; |
|||
} |
|||
|
|||
.camera { |
|||
position: relative; |
|||
width: 100%; |
|||
height: 100%; |
|||
overflow: hidden; |
|||
|
|||
.camera-stream { |
|||
display: block; |
|||
width: 100%; |
|||
height: 100%; |
|||
object-fit: contain; |
|||
} |
|||
|
|||
.camera-controls { |
|||
position: absolute; |
|||
bottom: 0; |
|||
width: 100%; |
|||
padding: 0 5px 5px; |
|||
|
|||
.mat-button-base{ |
|||
margin: 6px 8px; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.message-text { |
|||
font-size: 18px; |
|||
color: #a0a0a0; |
|||
text-align: center; |
|||
} |
|||
} |
|||
@ -0,0 +1,274 @@ |
|||
///
|
|||
/// Copyright © 2016-2020 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { |
|||
Component, |
|||
ElementRef, |
|||
Inject, |
|||
Input, |
|||
NgZone, |
|||
OnDestroy, |
|||
OnInit, |
|||
ViewChild, |
|||
ViewEncapsulation |
|||
} from '@angular/core'; |
|||
import { PageComponent } from '@shared/components/page.component'; |
|||
import { WidgetContext } from '@home/models/widget-component.models'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { Overlay } from '@angular/cdk/overlay'; |
|||
import { UtilsService } from '@core/services/utils.service'; |
|||
import { Datasource, DatasourceData, DatasourceType } from '@shared/models/widget.models'; |
|||
import { WINDOW } from '@core/services/window.service'; |
|||
import { AttributeService } from '@core/http/attribute.service'; |
|||
import { EntityId } from '@shared/models/id/entity-id'; |
|||
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models'; |
|||
import { Observable } from 'rxjs'; |
|||
|
|||
interface WebCameraInputWidgetSettings { |
|||
widgetTitle: string; |
|||
imageQuality: number; |
|||
imageFormat: string; |
|||
maxWidth: number; |
|||
maxHeight: number; |
|||
} |
|||
|
|||
@Component({ |
|||
selector: 'tb-web-camera-widget', |
|||
templateUrl: './web-camera-input.component.html', |
|||
styleUrls: ['./web-camera-input.component.scss'], |
|||
encapsulation: ViewEncapsulation.None |
|||
}) |
|||
export class WebCameraInputWidgetComponent extends PageComponent implements OnInit, OnDestroy { |
|||
|
|||
constructor(@Inject(WINDOW) private window: Window, |
|||
protected store: Store<AppState>, |
|||
private elementRef: ElementRef, |
|||
private ngZone: NgZone, |
|||
private overlay: Overlay, |
|||
private utils: UtilsService, |
|||
private attributeService: AttributeService, |
|||
) { |
|||
super(store); |
|||
} |
|||
|
|||
public get videoElement() { |
|||
return this.videoStreamRef.nativeElement; |
|||
} |
|||
|
|||
public get canvasElement() { |
|||
return this.canvasRef.nativeElement; |
|||
} |
|||
|
|||
public get videoWidth() { |
|||
const videoRatio = this.getVideoAspectRatio(); |
|||
return Math.min(this.width, this.height * videoRatio); |
|||
} |
|||
|
|||
public get videoHeight() { |
|||
const videoRatio = this.getVideoAspectRatio(); |
|||
return Math.min(this.height, this.width / videoRatio); |
|||
} |
|||
|
|||
private static DEFAULT_IMAGE_TYPE = 'image/jpeg'; |
|||
private static DEFAULT_IMAGE_QUALITY = 0.92; |
|||
|
|||
@Input() |
|||
ctx: WidgetContext; |
|||
|
|||
@ViewChild('videoStream', {static: true}) videoStreamRef: ElementRef<HTMLVideoElement>; |
|||
@ViewChild('canvas', {static: true}) canvasRef: ElementRef<HTMLCanvasElement>; |
|||
|
|||
private videoInputsIndex = 0; |
|||
private settings: WebCameraInputWidgetSettings; |
|||
private datasource: Datasource; |
|||
private width = 640; |
|||
private height = 480; |
|||
private availableVideoInputs: MediaDeviceInfo[]; |
|||
private mediaStream: MediaStream; |
|||
|
|||
isEntityDetected = false; |
|||
dataKeyDetected = false; |
|||
isCameraSupport = false; |
|||
isDeviceDetect = false; |
|||
isShowCamera = false; |
|||
isPreviewPhoto = false; |
|||
singleDevice = true; |
|||
updatePhoto = false; |
|||
previewPhoto: any; |
|||
lastPhoto: any; |
|||
|
|||
private static hasGetUserMedia(): boolean { |
|||
return !!(window.navigator.mediaDevices && window.navigator.mediaDevices.getUserMedia); |
|||
} |
|||
|
|||
private static getAvailableVideoInputs(): Promise<MediaDeviceInfo[]> { |
|||
return new Promise((resolve, reject) => { |
|||
navigator.mediaDevices.enumerateDevices() |
|||
.then((devices: MediaDeviceInfo[]) => { |
|||
resolve(devices.filter((device: MediaDeviceInfo) => device.kind === 'videoinput')); |
|||
}) |
|||
.catch(err => { |
|||
reject(err.message || err); |
|||
}); |
|||
}); |
|||
} |
|||
|
|||
ngOnInit(): void { |
|||
this.ctx.$scope.webCameraInputWidget = this; |
|||
this.settings = this.ctx.settings; |
|||
this.datasource = this.ctx.datasources[0]; |
|||
|
|||
if (this.settings.widgetTitle && this.settings.widgetTitle.length) { |
|||
this.ctx.widgetTitle = this.utils.customTranslation(this.settings.widgetTitle, this.settings.widgetTitle); |
|||
} else { |
|||
this.ctx.widgetTitle = this.ctx.widgetConfig.title; |
|||
} |
|||
|
|||
this.width = this.settings.maxWidth ? this.settings.maxWidth : 640; |
|||
this.height = this.settings.maxHeight ? this.settings.maxWidth : 480; |
|||
|
|||
if (this.datasource.type === DatasourceType.entity) { |
|||
if (this.datasource.entityType && this.datasource.entityId) { |
|||
this.isEntityDetected = true; |
|||
} |
|||
} |
|||
if (this.datasource.dataKeys.length) { |
|||
this.dataKeyDetected = true; |
|||
} |
|||
this.detectAvailableDevices(); |
|||
} |
|||
|
|||
ngOnDestroy(): void { |
|||
this.stopMediaTracks(); |
|||
} |
|||
|
|||
private updateWidgetData(data: Array<DatasourceData>) { |
|||
const keyData = data[0].data; |
|||
if (keyData && keyData.length) { |
|||
this.lastPhoto = keyData[0][1]; |
|||
} |
|||
} |
|||
|
|||
public onDataUpdated() { |
|||
this.ngZone.run(() => { |
|||
this.updateWidgetData(this.ctx.defaultSubscription.data); |
|||
this.ctx.detectChanges(); |
|||
}); |
|||
} |
|||
|
|||
|
|||
private detectAvailableDevices(): void { |
|||
if (WebCameraInputWidgetComponent.hasGetUserMedia()) { |
|||
this.isCameraSupport = true; |
|||
WebCameraInputWidgetComponent.getAvailableVideoInputs().then((devices) => { |
|||
this.isDeviceDetect = !!devices.length; |
|||
this.singleDevice = devices.length < 2; |
|||
this.availableVideoInputs = devices; |
|||
this.ctx.detectChanges(); |
|||
}, () => { |
|||
this.availableVideoInputs = []; |
|||
} |
|||
) |
|||
} |
|||
} |
|||
|
|||
private getVideoAspectRatio(): number { |
|||
if (this.videoElement.videoWidth && this.videoElement.videoWidth > 0 && |
|||
this.videoElement.videoHeight && this.videoElement.videoHeight > 0) { |
|||
return this.videoElement.videoWidth / this.videoElement.videoHeight; |
|||
} |
|||
return this.width / this.height; |
|||
} |
|||
|
|||
private stopMediaTracks() { |
|||
if (this.mediaStream && this.mediaStream.getTracks) { |
|||
this.mediaStream.getTracks() |
|||
.forEach((track: MediaStreamTrack) => track.stop()); |
|||
} |
|||
} |
|||
|
|||
takePhoto() { |
|||
this.isShowCamera = true; |
|||
this.initWebCamera(this.availableVideoInputs[this.videoInputsIndex].deviceId); |
|||
} |
|||
|
|||
closeCamera() { |
|||
this.stopMediaTracks(); |
|||
this.videoElement.srcObject = null; |
|||
this.isShowCamera = false; |
|||
} |
|||
|
|||
cancelPhoto() { |
|||
this.isPreviewPhoto = false; |
|||
this.previewPhoto = ''; |
|||
} |
|||
|
|||
savePhoto() { |
|||
this.updatePhoto = true; |
|||
let task: Observable<any>; |
|||
const entityId: EntityId = { |
|||
entityType: this.datasource.entityType, |
|||
id: this.datasource.entityId |
|||
}; |
|||
const saveData = [{ |
|||
key: this.datasource.dataKeys[0].name, |
|||
value: this.previewPhoto |
|||
}]; |
|||
if (this.datasource.dataKeys[0].type === DataKeyType.attribute) { |
|||
task = this.attributeService.saveEntityAttributes(entityId, AttributeScope.SERVER_SCOPE, saveData); |
|||
} else if (this.datasource.dataKeys[0].type === DataKeyType.timeseries) { |
|||
task = this.attributeService.saveEntityTimeseries(entityId, 'scope', saveData); |
|||
} |
|||
task.subscribe(() => { |
|||
this.isPreviewPhoto = false; |
|||
this.updatePhoto = false; |
|||
this.closeCamera(); |
|||
}, () => { |
|||
this.updatePhoto = false; |
|||
}) |
|||
} |
|||
|
|||
switchWebCamera() { |
|||
this.videoInputsIndex = (this.videoInputsIndex + 1) % this.availableVideoInputs.length; |
|||
this.stopMediaTracks(); |
|||
this.initWebCamera(this.availableVideoInputs[this.videoInputsIndex].deviceId) |
|||
} |
|||
|
|||
createPhoto() { |
|||
this.canvasElement.width = this.videoWidth; |
|||
this.canvasElement.height = this.videoHeight; |
|||
this.canvasElement.getContext('2d').drawImage(this.videoElement, 0, 0, this.videoWidth, this.videoHeight); |
|||
|
|||
const mimeType: string = this.settings.imageFormat ? this.settings.imageFormat : WebCameraInputWidgetComponent.DEFAULT_IMAGE_TYPE; |
|||
const quality: number = this.settings.imageQuality ? this.settings.imageQuality : WebCameraInputWidgetComponent.DEFAULT_IMAGE_QUALITY; |
|||
this.previewPhoto = this.canvasElement.toDataURL(mimeType, quality); |
|||
this.isPreviewPhoto = true; |
|||
} |
|||
|
|||
private initWebCamera(deviceId?: string) { |
|||
if (window.navigator.mediaDevices && window.navigator.mediaDevices.getUserMedia) { |
|||
const videoTrackConstraints = { |
|||
video: {deviceId: deviceId !== '' ? {exact: deviceId} : undefined} |
|||
}; |
|||
|
|||
window.navigator.mediaDevices.getUserMedia(videoTrackConstraints).then((stream: MediaStream) => { |
|||
this.mediaStream = stream; |
|||
this.videoElement.srcObject = stream; |
|||
}) |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue