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