diff --git a/src/app/app.module.ts b/src/app/app.module.ts index f3e4ed6b91..c3499ef352 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -30,6 +30,7 @@ import { DspApiConfigToken, DspApiConnectionToken, DspAppConfigToken, DspInstrum import { DialogHeaderComponent } from './main/dialog/dialog-header/dialog-header.component'; import { DialogComponent } from './main/dialog/dialog.component'; import { AdminImageDirective } from './main/directive/admin-image/admin-image.directive'; +import { DisableContextMenuDirective } from './main/directive/disable-context-menu.directive'; import { ExistingNameDirective } from './main/directive/existing-name/existing-name.directive'; import { ExternalLinksDirective } from './main/directive/external-links.directive'; import { GndDirective } from './main/directive/gnd/gnd.directive'; @@ -47,6 +48,7 @@ import { LinkifyPipe } from './main/pipes/string-transformation/linkify.pipe'; import { StringifyStringLiteralPipe } from './main/pipes/string-transformation/stringify-string-literal.pipe'; import { TitleFromCamelCasePipe } from './main/pipes/string-transformation/title-from-camel-case.pipe'; import { TruncatePipe } from './main/pipes/string-transformation/truncate.pipe'; +import { TimePipe } from './main/pipes/time.pipe'; import { SelectLanguageComponent } from './main/select-language/select-language.component'; import { DatadogRumService } from './main/services/datadog-rum.service'; import { MaterialModule } from './material-module'; @@ -98,9 +100,13 @@ import { PropertiesComponent } from './workspace/resource/properties/properties. import { AddRegionFormComponent } from './workspace/resource/representation/add-region-form/add-region-form.component'; import { ArchiveComponent } from './workspace/resource/representation/archive/archive.component'; import { AudioComponent } from './workspace/resource/representation/audio/audio.component'; +import { AvTimelineComponent } from './workspace/resource/representation/av-timeline/av-timeline.component'; import { DocumentComponent } from './workspace/resource/representation/document/document.component'; +import { ReplaceFileFormComponent } from './workspace/resource/representation/replace-file-form/replace-file-form.component'; import { StillImageComponent } from './workspace/resource/representation/still-image/still-image.component'; import { UploadComponent } from './workspace/resource/representation/upload/upload.component'; +import { VideoPreviewComponent } from './workspace/resource/representation/video/video-preview/video-preview.component'; +import { VideoComponent } from './workspace/resource/representation/video/video.component'; import { ResourceInstanceFormComponent } from './workspace/resource/resource-instance-form/resource-instance-form.component'; import { SelectOntologyComponent } from './workspace/resource/resource-instance-form/select-ontology/select-ontology.component'; import { SelectProjectComponent } from './workspace/resource/resource-instance-form/select-project/select-project.component'; @@ -153,7 +159,6 @@ import { SearchSelectOntologyComponent } from './workspace/search/advanced-searc import { ExpertSearchComponent } from './workspace/search/expert-search/expert-search.component'; import { FulltextSearchComponent } from './workspace/search/fulltext-search/fulltext-search.component'; import { SearchPanelComponent } from './workspace/search/search-panel/search-panel.component'; -import { ReplaceFileFormComponent } from './workspace/resource/representation/replace-file-form/replace-file-form.component'; // translate: AoT requires an exported function for factories export function httpLoaderFactory(httpClient: HttpClient) { @@ -172,6 +177,7 @@ export function httpLoaderFactory(httpClient: HttpClient) { AppComponent, ArchiveComponent, AudioComponent, + AvTimelineComponent, BoardComponent, BooleanValueComponent, CollaborationComponent, @@ -190,6 +196,7 @@ export function httpLoaderFactory(httpClient: HttpClient) { DecimalValueComponent, DialogComponent, DialogHeaderComponent, + DisableContextMenuDirective, DisplayEditComponent, DocumentComponent, DragDropDirective, @@ -241,6 +248,7 @@ export function httpLoaderFactory(httpClient: HttpClient) { PropertiesComponent, PropertyFormComponent, PropertyInfoComponent, + ReplaceFileFormComponent, ResourceAndPropertySelectionComponent, ResourceClassFormComponent, ResourceClassInfoComponent, @@ -285,6 +293,7 @@ export function httpLoaderFactory(httpClient: HttpClient) { TextValueAsXMLComponent, TextValueHtmlLinkDirective, TimeInputComponent, + TimePipe, TimeValueComponent, TitleFromCamelCasePipe, TruncatePipe, @@ -295,7 +304,8 @@ export function httpLoaderFactory(httpClient: HttpClient) { UserMenuComponent, UsersComponent, UsersListComponent, - ReplaceFileFormComponent, + VideoComponent, + VideoPreviewComponent ], imports: [ AngularSplitModule.forRoot(), diff --git a/src/app/main/dialog/dialog.component.html b/src/app/main/dialog/dialog.component.html index d403b2c59c..c3a8ab6dbb 100644 --- a/src/app/main/dialog/dialog.component.html +++ b/src/app/main/dialog/dialog.component.html @@ -394,7 +394,7 @@
- +
diff --git a/src/app/main/directive/disable-context-menu.directive.spec.ts b/src/app/main/directive/disable-context-menu.directive.spec.ts new file mode 100644 index 0000000000..96cc7cd1e2 --- /dev/null +++ b/src/app/main/directive/disable-context-menu.directive.spec.ts @@ -0,0 +1,8 @@ +import { DisableContextMenuDirective } from './disable-context-menu.directive'; + +describe('DisableContextMenuDirective', () => { + it('should create an instance', () => { + const directive = new DisableContextMenuDirective(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/src/app/main/directive/disable-context-menu.directive.ts b/src/app/main/directive/disable-context-menu.directive.ts new file mode 100644 index 0000000000..5be846ab93 --- /dev/null +++ b/src/app/main/directive/disable-context-menu.directive.ts @@ -0,0 +1,17 @@ +import { Directive, HostListener } from '@angular/core'; + +@Directive({ + selector: '[appDisableContextMenu]' +}) +export class DisableContextMenuDirective { + + constructor() { } + + @HostListener('contextmenu', ['$event']) + + onRightClick(event: Event) { + event.preventDefault(); + } + + +} diff --git a/src/app/main/pipes/time.pipe.spec.ts b/src/app/main/pipes/time.pipe.spec.ts new file mode 100644 index 0000000000..09b81d66cf --- /dev/null +++ b/src/app/main/pipes/time.pipe.spec.ts @@ -0,0 +1,27 @@ +import { TimePipe } from './time.pipe'; + +describe('TimePipe', () => { + + let pipe = new TimePipe(); + + beforeEach(() => { + pipe = new TimePipe(); + }); + + it('create an instance', () => { + expect(pipe).toBeTruthy(); + }); + + + it('should convert 123 seconds into 2 minutes and 3 seconds', () => { + const seconds = 123; + const time = pipe.transform(seconds); + expect(time).toEqual('02:03'); + }); + + it('should convert 12342 seconds into 3 hours 25 minutes and 42 seconds', () => { + const seconds = 12342; + const time = pipe.transform(seconds); + expect(time).toEqual('03:25:42'); + }); +}); diff --git a/src/app/main/pipes/time.pipe.ts b/src/app/main/pipes/time.pipe.ts new file mode 100644 index 0000000000..f875b37d64 --- /dev/null +++ b/src/app/main/pipes/time.pipe.ts @@ -0,0 +1,30 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +/** + * the TimePipe transforms n seconds to hh:mm:ss + * or in case of zero hours to mm:ss + */ +@Pipe({ + name: 'appTime' +}) +export class TimePipe implements PipeTransform { + + transform(value: number): string { + + const dateObj: Date = new Date(value * 1000); + const hours: number = dateObj.getUTCHours(); + const minutes = dateObj.getUTCMinutes(); + const seconds = dateObj.getSeconds(); + + if (hours === 0) { + return minutes.toString().padStart(2, '0') + ':' + + seconds.toString().padStart(2, '0'); + } else { + return hours.toString().padStart(2, '0') + ':' + + minutes.toString().padStart(2, '0') + ':' + + seconds.toString().padStart(2, '0'); + } + + } + +} diff --git a/src/app/main/services/notification.service.ts b/src/app/main/services/notification.service.ts index 0d4fb9e08d..fd17cc5126 100644 --- a/src/app/main/services/notification.service.ts +++ b/src/app/main/services/notification.service.ts @@ -16,7 +16,7 @@ export class NotificationService { // todo: maybe we can add more parameters like: // action: string = 'x', duration: number = 4200 // and / or type: 'note' | 'warning' | 'error' | 'success'; which can be used for the panelClass - openSnackBar(notification: string | ApiResponseError): void { + openSnackBar(notification: string | ApiResponseError, type?: 'success' | 'error'): void { let duration = 5000; let message: string; let panelClass: string; @@ -30,10 +30,10 @@ export class NotificationService { const defaultStatusMsg = this._statusMsg.default; message = `${defaultStatusMsg[notification.status].message} (${notification.status}): ${defaultStatusMsg[notification.status].description}`; } - panelClass = 'error'; + panelClass = type ? type : 'error'; } else { message = notification; - panelClass = 'success'; + panelClass = type ? type : 'success'; } this._snackBar.open(message, 'x', { diff --git a/src/app/workspace/resource/representation/archive/archive.component.ts b/src/app/workspace/resource/representation/archive/archive.component.ts index 7dabe1647e..761dd397d2 100644 --- a/src/app/workspace/resource/representation/archive/archive.component.ts +++ b/src/app/workspace/resource/representation/archive/archive.component.ts @@ -20,7 +20,6 @@ export class ArchiveComponent implements OnInit { @Input() parentResource: ReadResource; originalFilename: string; - temp: string; constructor( @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, @@ -79,7 +78,9 @@ export class ArchiveComponent implements OnInit { ); dialogRef.afterClosed().subscribe((data) => { - this._replaceFile(data); + if (data) { + this._replaceFile(data); + } }); } diff --git a/src/app/workspace/resource/representation/audio/audio.component.ts b/src/app/workspace/resource/representation/audio/audio.component.ts index 6ab4a594aa..b75207f3c8 100644 --- a/src/app/workspace/resource/representation/audio/audio.component.ts +++ b/src/app/workspace/resource/representation/audio/audio.component.ts @@ -52,7 +52,9 @@ export class AudioComponent implements OnInit { ); dialogRef.afterClosed().subscribe((data) => { - this._replaceFile(data); + if (data) { + this._replaceFile(data); + } }); } diff --git a/src/app/workspace/resource/representation/av-timeline/av-timeline.component.html b/src/app/workspace/resource/representation/av-timeline/av-timeline.component.html new file mode 100644 index 0000000000..07d7e220ae --- /dev/null +++ b/src/app/workspace/resource/representation/av-timeline/av-timeline.component.html @@ -0,0 +1,10 @@ +
+
+
+
+
+
+
+
diff --git a/src/app/workspace/resource/representation/av-timeline/av-timeline.component.scss b/src/app/workspace/resource/representation/av-timeline/av-timeline.component.scss new file mode 100644 index 0000000000..3b834f7ecf --- /dev/null +++ b/src/app/workspace/resource/representation/av-timeline/av-timeline.component.scss @@ -0,0 +1,63 @@ +// timeline / progress bar +.timeline-wrapper { + width: calc(100% - 32px); + height: 24px; + top: 0; + position: absolute; + cursor: pointer; + + .progress-wrapper { + width: 100%; + height: 2px; + padding: 11px 0; + position: absolute; + overflow: hidden; + display: flex; + + .progress-background, + // .progress-buffer, + .progress-fill { + height: 2px; + width: 100%; + } + .progress-background { + background-color: whitesmoke; + position: absolute; + transform-origin: 100% 100%; + transition: transform 20ms cubic-bezier(0.25, 0.8, 0.25, 1), + background-color 20ms cubic-bezier(0.25, 0.8, 0.25, 1); + } + + .progress-fill { + background-color: red; + position: absolute; + transform: scaleX(0); + transform-origin: 0 0; + transition: transform 20ms cubic-bezier(0.25, 0.8, 0.25, 1), + background-color 20ms cubic-bezier(0.25, 0.8, 0.25, 1); + } + } + + .thumb { + cursor: grab; + position: absolute; + left: -7px; + bottom: 2px; + box-sizing: border-box; + width: 20px; + height: 20px; + border: 3px solid transparent; + border-radius: 50%; + background-color: red; + transform: scale(0.7); + transition: transform 20ms cubic-bezier(0.25, 0.8, 0.25, 1), + background-color 20ms cubic-bezier(0.25, 0.8, 0.25, 1), + border-color 20ms cubic-bezier(0.25, 0.8, 0.25, 1); + transition: none; + + &.dragging { + cursor: grabbing; + border: 3px solid red; + } + } +} diff --git a/src/app/workspace/resource/representation/av-timeline/av-timeline.component.spec.ts b/src/app/workspace/resource/representation/av-timeline/av-timeline.component.spec.ts new file mode 100644 index 0000000000..eaaca89bec --- /dev/null +++ b/src/app/workspace/resource/representation/av-timeline/av-timeline.component.spec.ts @@ -0,0 +1,27 @@ +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; + +import { AvTimelineComponent } from './av-timeline.component'; + +describe('AvTimelineComponent', () => { + let component: AvTimelineComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + AvTimelineComponent + ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AvTimelineComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/workspace/resource/representation/av-timeline/av-timeline.component.ts b/src/app/workspace/resource/representation/av-timeline/av-timeline.component.ts new file mode 100644 index 0000000000..1e0a2af219 --- /dev/null +++ b/src/app/workspace/resource/representation/av-timeline/av-timeline.component.ts @@ -0,0 +1,233 @@ +import { CdkDragMove } from '@angular/cdk/drag-drop'; +import { + Component, + ElementRef, + EventEmitter, + HostListener, + Input, + OnChanges, + Output, + SimpleChange, + ViewChild +} from '@angular/core'; + +export interface PointerValue { + position: number; + time: number; +} +/** + * this component can be used in video and audio players + */ +@Component({ + selector: 'app-av-timeline', + templateUrl: './av-timeline.component.html', + styleUrls: ['./av-timeline.component.scss'] +}) +export class AvTimelineComponent implements OnChanges { + + // current time value + @Input() value: number; + + // start time value + @Input() min?= 0; + + // end time value: Normally this is the duration + @Input() max: number; + + // in case parent resized: Will be used in video player when switching between cinema and default view + @Input() resized: boolean; + + // send click position to parent + @Output() changed = new EventEmitter(); + + // send mouse position to parent + @Output() move = new EventEmitter(); + + // send dimension and position of the timeline + @Output() dimension = new EventEmitter(); + + // timeline element: main container + @ViewChild('timeline') timelineEle: ElementRef; + + // progress element: thin bar line + @ViewChild('progress') progressEle: ElementRef; + + // thumb element: current postion pointer + @ViewChild('thumb') thumbEle: ElementRef; + + // in case of draging the thumb element + dragging = false; + + // size of timeline; will be used to calculate progress position in pixel corresponding to time value + timelineDimension: DOMRect | null = null; + + constructor() { + } + + @HostListener('mouseenter', ['$event']) onEnter(e: MouseEvent) { + this._onMouseenter(e); + } + + @HostListener('mousemove', ['$event']) onMousemove(e: MouseEvent) { + this._onMousemove(e); + } + + @HostListener('mouseup', ['$event']) onMouseup(e: MouseEvent) { + this._onMouseup(e); + } + + @HostListener('window:resize', ['$event']) onWindwoResiz(e: Event) { + this._onWindowResize(e); + } + + ngOnChanges(changes: { [propName: string]: SimpleChange }) { + if (!this.timelineEle && !this.progressEle) { + return; + } + + if (!this.timelineDimension) { + // calculate timeline dimension if it doesn't exist + this.timelineDimension = this._getTimelineDimensions(); + } else { + // recalculate timeline dimension because resized parameter has changed + if (changes.resized) { + this.timelineDimension = this._getResizedTimelineDimensions(); + } + } + + // emit the dimension to the parent + this.dimension.emit(this.timelineDimension); + + // update pointer position from time + this.updatePositionFromTime(this.value); + } + + /** + * updates position from time value + * @param time + */ + updatePositionFromTime(time: number) { + // calc position on the x axis from time value + const percent: number = (time / this.max); + + const pos: number = this.timelineDimension.width * percent; + + this.updatePosition(pos); + } + + /** + * updates position of the thumb + * @param pos + */ + updatePosition(pos: number) { + // already played time: fill with red background color + const fillPos = (pos / this.timelineDimension.width); + + // background (timeline fill) start position + const bgPos = (1 - fillPos); + + if (!this.dragging) { + // update thumb position if not dragging + this.thumbEle.nativeElement.style.transform = 'translateX(' + pos + 'px) scale(.7)'; + } + // adjust progress width / fill already played time + this.progressEle.nativeElement.children[0].style.transform = 'translateX(0px) scale3d(' + bgPos + ', 1, 1)'; + // adjust progress width / progress background + this.progressEle.nativeElement.children[2].style.transform = 'translateX(0px) scale3d(' + fillPos + ', 1, 1)'; + } + + /** + * toggles dragging + */ + toggleDragging() { + this.dragging = !this.dragging; + } + + /** + * drags action + * @param ev + */ + dragAction(ev: CdkDragMove) { + const pos: number = (ev.pointerPosition.x - this.timelineDimension.left); + this.updatePosition(pos); + } + + /** + * mouse enters timeline + */ + private _onMouseenter(ev: MouseEvent) { + this.timelineDimension = this._getTimelineDimensions(); + } + + /** + * mouse moves on timeline + */ + private _onMousemove(ev: MouseEvent) { + + const pos: number = ev.clientX - this.timelineDimension.left; + + const percent: number = pos / this.timelineDimension.width; + + let time: number = (percent * this.max); + + if (time < 0) { + time = 0; + } else if (time > this.max) { + time = this.max; + } + + this.move.emit({ position: ev.clientX, time }); + } + + /** + * determines action after click or drop event + * @param ev + */ + private _onMouseup(ev: MouseEvent) { + + const pos: number = (ev.clientX - this.timelineDimension.left); + + this.updatePosition(pos); + + const percentage: number = (pos / this.timelineDimension.width); + + // calc time value to submit to parent + const time: number = (percentage * this.max); + + this.changed.emit(time); + } + + /** + * event listener on window resize + */ + private _onWindowResize(ev: Event) { + this.timelineDimension = this._getResizedTimelineDimensions(); + this.dimension.emit(this.timelineDimension); + } + + /** + * get the bounding client rect of the slider track element. + * The track is used rather than the native element to ignore the extra space that the thumb can + * take up. + */ + private _getTimelineDimensions(): DOMRect | null { + return this.timelineEle ? this.timelineEle.nativeElement.getBoundingClientRect() : null; + } + + /** + * gets resized timeline dimensions + * @returns resized timeline dimensions + */ + private _getResizedTimelineDimensions(): DOMRect | null { + // recalculate timeline dimension + const newDimension: DOMRect = this._getTimelineDimensions(); + + if (this.timelineDimension.width !== newDimension.width) { + return newDimension; + } else { + return; + } + + } + +} diff --git a/src/app/workspace/resource/representation/document/document.component.ts b/src/app/workspace/resource/representation/document/document.component.ts index e16397e872..5cd596fa74 100644 --- a/src/app/workspace/resource/representation/document/document.component.ts +++ b/src/app/workspace/resource/representation/document/document.component.ts @@ -69,7 +69,9 @@ export class DocumentComponent implements OnInit { ); dialogRef.afterClosed().subscribe((data) => { - this._replaceFile(data); + if (data) { + this._replaceFile(data); + } }); } diff --git a/src/app/workspace/resource/representation/replace-file-form/replace-file-form.component.html b/src/app/workspace/resource/representation/replace-file-form/replace-file-form.component.html index 243e4f5658..d0ce31bd9c 100644 --- a/src/app/workspace/resource/representation/replace-file-form/replace-file-form.component.html +++ b/src/app/workspace/resource/representation/replace-file-form/replace-file-form.component.html @@ -20,7 +20,7 @@ - diff --git a/src/app/workspace/resource/representation/still-image/still-image.component.ts b/src/app/workspace/resource/representation/still-image/still-image.component.ts index f3b2326855..1a8446b520 100644 --- a/src/app/workspace/resource/representation/still-image/still-image.component.ts +++ b/src/app/workspace/resource/representation/still-image/still-image.component.ts @@ -361,7 +361,9 @@ export class StillImageComponent implements OnChanges, OnDestroy { ); dialogRef.afterClosed().subscribe((data) => { - this._replaceFile(data); + if (data) { + this._replaceFile(data); + } }); } diff --git a/src/app/workspace/resource/representation/video/video-preview/video-preview.component.html b/src/app/workspace/resource/representation/video/video-preview/video-preview.component.html new file mode 100644 index 0000000000..867a5a8320 --- /dev/null +++ b/src/app/workspace/resource/representation/video/video-preview/video-preview.component.html @@ -0,0 +1,4 @@ +
+

+ {{ time | appTime }} +

diff --git a/src/app/workspace/resource/representation/video/video-preview/video-preview.component.scss b/src/app/workspace/resource/representation/video/video-preview/video-preview.component.scss new file mode 100644 index 0000000000..e2a53d51b9 --- /dev/null +++ b/src/app/workspace/resource/representation/video/video-preview/video-preview.component.scss @@ -0,0 +1,89 @@ +@mixin box-shadow($x: 0, $y: 1px, $blur: 3px, $alpha: 0.5) { + box-shadow: $x $y $blur rgba(0, 0, 0, $alpha); +} + +:host { + display: block; + + .matrix-loader { + display: none; + } + + width: 100%; + height: 100%; + border-radius: 2px; + border: 1px solid rgba(28, 28, 28, 0.9); + background-color: rgba(28, 28, 28, 1); + position: relative; + + .frame { + cursor: ew-resize; + background-repeat: no-repeat; + background-size: auto; + margin: 0 auto; + width: 100%; + height: 100%; + display: block; + position: inherit; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .frame-time { + position: absolute; + bottom: 2px; + margin: 0 auto; + width: 100%; + text-align: center; + + .time { + background: rgba(28, 28, 28, 0.9); + color: white; + padding: 3px 6px; + border-radius: 2px; + font-size: small; + } + } + + &.active { + z-index: 99; + @include box-shadow(); + + .frame { + height: 280%; + width: 280%; + background-size: 280%; + } + + .preview-time { + position: absolute; + bottom: -12px; + text-align: center; + width: 100%; + color: white; + display: grid; + .time { + background-color: rgba(28, 28, 28, 0.9); + padding: 3px 6px; + border-radius: 2px; + } + } + } +} + +.preview { + &.active .flipbook { + width: 100%; + height: 100%; + background: red; + margin-top: -40px; + margin-left: -40px; + position: absolute; + + border-radius: 2px; + } + + .preview-time { + } +} diff --git a/src/app/workspace/resource/representation/video/video-preview/video-preview.component.spec.ts b/src/app/workspace/resource/representation/video/video-preview/video-preview.component.spec.ts new file mode 100644 index 0000000000..20613d59f9 --- /dev/null +++ b/src/app/workspace/resource/representation/video/video-preview/video-preview.component.spec.ts @@ -0,0 +1,78 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { ReadMovingImageFileValue } from '@dasch-swiss/dsp-js'; +import { TimePipe } from 'src/app/main/pipes/time.pipe'; +import { FileRepresentation } from '../../file-representation'; +import { VideoPreviewComponent } from './video-preview.component'; + + + +const videoFileValue: ReadMovingImageFileValue = { + 'type': 'http://api.knora.org/ontology/knora-api/v2#MovingImageFileValue', + 'id': 'http://rdfh.ch/1111/dyNT0dvbSgGrxVONpaH5FA/values/73Re06_TQDS4FM8SOyCHcQ', + 'attachedToUser': 'http://rdfh.ch/users/root', + 'arkUrl': 'http://0.0.0.0:3336/ark:/72163/1/1111/dyNT0dvbSgGrxVONpaH5FAT/73Re06_TQDS4FM8SOyCHcQG', + 'versionArkUrl': 'http://0.0.0.0:3336/ark:/72163/1/1111/dyNT0dvbSgGrxVONpaH5FAT/73Re06_TQDS4FM8SOyCHcQG.20220329T112456951082Z', + 'valueCreationDate': '2022-03-29T11:24:56.951082Z', + 'hasPermissions': 'CR knora-admin:ProjectAdmin|M knora-admin:ProjectMember', + 'userHasPermission': 'CR', + 'uuid': '73Re06_TQDS4FM8SOyCHcQ', + 'filename': '5AiQkeJNbQn-ClrXWkJVFvB.mp4', + 'fileUrl': 'http://0.0.0.0:1024/1111/5AiQkeJNbQn-ClrXWkJVFvB.mp4/file', + 'dimX': 0, + 'dimY': 0, + 'duration': 0, + 'fps': 0, + 'strval': 'http://0.0.0.0:1024/1111/5AiQkeJNbQn-ClrXWkJVFvB.mp4/file', + 'property': 'http://api.knora.org/ontology/knora-api/v2#hasMovingImageFileValue', + 'propertyLabel': 'hat Filmdatei', + 'propertyComment': 'Connects a Representation to a movie file' +}; + +@Component({ + template: ` + + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild(VideoPreviewComponent) videoPreviewComp: VideoPreviewComponent; + + videoFileRepresentation: FileRepresentation; + + ngOnInit() { + this.videoFileRepresentation = new FileRepresentation(videoFileValue); + } +} + +describe('VideoPreviewComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + VideoPreviewComponent, + TimePipe + ], + imports: [ + HttpClientTestingModule + ], + providers: [ + + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + }); +}); diff --git a/src/app/workspace/resource/representation/video/video-preview/video-preview.component.ts b/src/app/workspace/resource/representation/video/video-preview/video-preview.component.ts new file mode 100644 index 0000000000..d58c52fac2 --- /dev/null +++ b/src/app/workspace/resource/representation/video/video-preview/video-preview.component.ts @@ -0,0 +1,398 @@ +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { + Component, + ElementRef, + EventEmitter, + HostListener, + Input, + OnChanges, + OnInit, + Output, + ViewChild +} from '@angular/core'; +import { fromEvent, merge, Observable } from 'rxjs'; +import { map, take, tap } from 'rxjs/operators'; +import { NotificationService } from 'src/app/main/services/notification.service'; +import { FileRepresentation } from '../../file-representation'; + +export interface MovingImageSidecar { + '@context': string; + 'checksumDerivative': string; + 'checksumOriginal': string; + 'duration': number; + 'fileSize': number; + 'fps': number; + 'height': number; + 'id': string; + 'internalMimeType': string; + 'originalFilename': string; + 'width': number; +} + +export interface Dimension { + 'width': number; + 'height': number; +} + +@Component({ + selector: 'app-video-preview', + templateUrl: './video-preview.component.html', + styleUrls: ['./video-preview.component.scss'], +}) +export class VideoPreviewComponent implements OnInit, OnChanges { + + @Input() dispTime = false; + + // needed video information: name and duration + @Input() src: FileRepresentation; + + // show frame at the corresponding time + @Input() time?: number; + + // if video file has changed; it will run ngOnChanges + @Input() fileHasChanged = false; + + // in preview only, when clicking on the flipbook, it opens the video at this position + @Output() open = new EventEmitter<{ video: string; time: number }>(); + + // sends the metadata (sipi sidecar) to the parent e.g. video player + @Output() fileMetadata = new EventEmitter(); + + // emit true when the matrix file (or the default error file) is loaded; + // this helps to avoid an empty or black preview frame + @Output() loaded = new EventEmitter(); + + @ViewChild('frame') frame: ElementRef; + + fileInfo: MovingImageSidecar; + + focusOnPreview = false; + + // video information: aspect ration + aspectRatio: number; + + // preview images are organized in matrix files; + // we need the last number of those files and the number of lines from the last matrix file + // we need the number of these files and the number of lines of the last matrix file + // 1. matrix file name + matrix: string; + // 2. matrix dimension + matrixWidth: number; + matrixHeight: number; + // 3. number of matrixes and number of lines of last file and number of last possible frame + lastMatrixNr: number; + lastMatrixFrameNr: number; + // 4. dimension of one frame inside the matrix + matrixFrameWidth: number; + matrixFrameHeight: number; + + previewError = false; + + // size of frame to be displayed; corresponds to dimension of parent container + frameWidth: number; + frameHeight: number; + + // proportion between matrix frame size and parent container size + // to calculate matrix background size + proportion: number; + + constructor( + private _host: ElementRef, + private _http: HttpClient + ) { } + + @HostListener('mouseenter', ['$event']) onEnter(e: MouseEvent) { + this.toggleFlipbook(true); + } + + @HostListener('mouseleave', ['$event']) onLeave(e: MouseEvent) { + this.toggleFlipbook(false); + } + + @HostListener('mousemove', ['$event']) onMove(e: MouseEvent) { + this.updatePreviewByPosition(e); + } + + @HostListener('click', ['$event']) onClick(e: MouseEvent) { + this.openVideo(); + } + + ngOnInit(): void { + const requestOptions = { + headers: new HttpHeaders({ 'Content-Type': 'application/json' }), + withCredentials: true + }; + + const pathToJson = this.src.fileValue.fileUrl.substring(0, this.src.fileValue.fileUrl.lastIndexOf('/')) + '/knora.json'; + + this._http.get(pathToJson, requestOptions).subscribe( + (res: MovingImageSidecar) => { + this.fileInfo = res; + this.fileMetadata.emit(res); + } + ); + + } + + ngOnChanges() { + this.time = this.time || 0; + + if (this.frame.nativeElement) { + this.updatePreviewByTime(); + } + + } + + /** + * toggles flipbook + * @param active + */ + toggleFlipbook(active: boolean) { + this.focusOnPreview = active; + + let i = 0; + const j = 0; + + if (this.focusOnPreview) { + // automatic playback of individual frames from first matrix + // --> TODO: activate this later with an additional parameter (@Input) to switch between mousemove and automatic preview + // this.autoPlay(i, j); + + } else { + i = 0; + } + + } + + /** + * run frame by frame automatically; only in preview mode + * @param i + * @param j + * @param [delay] + */ + autoPlay(i: number, j: number, delay: number = 250) { + let cssParams: string; + let x = 0; + let y = 0; + + setTimeout(() => { + + x = i * this.matrixFrameWidth; + y = j * this.matrixFrameHeight; + + cssParams = '-' + x + 'px -' + y + 'px'; + this.frame.nativeElement.style['background-position'] = cssParams; + + i++; + if (i < 6 && this.focusOnPreview) { + this.autoPlay(i, j); + } else { + i = 0; + j++; + if (j < 6 && this.focusOnPreview) { + this.autoPlay(i, j); + } + } + }, delay); + } + + + /** + * calculates sizes of the preview frame; + * - always depends on aspect ration of the video + * - with of the matrix file is always the same (960px) + * @param image + */ + calculateSizes(image: string, fileNumber: number) { + + // host dimension + const parentFrameWidth: number = this._host.nativeElement.offsetWidth; + const parentFrameHeight: number = this._host.nativeElement.offsetHeight; + + this._getMatrixDimension(image).subscribe( + (dim: Dimension) => { + + // we got a dimension. There's no error + this.previewError = false; + + // whole matrix dimension is: + this.matrixWidth = dim.width; + this.matrixHeight = dim.height; + + let lines: number = (this.fileInfo.duration > 360 ? 6 : Math.round(this.fileInfo.duration / 60)); + lines = (lines > 0 ? lines : 1); + + // last matrix file could have a different height than the previous ones + // this means the number of lines could be different + this.lastMatrixNr = Math.floor((this.fileInfo.duration - 10) / 360); + if (this.lastMatrixNr === fileNumber) { + // re-calc number of lines + this.lastMatrixFrameNr = Math.floor((this.fileInfo.duration - 8) / 10); + lines = Math.floor((this.lastMatrixFrameNr - (this.lastMatrixNr * 36)) / 6) + 1; + } + + // get matrix frame dimension + this.matrixFrameWidth = (this.matrixWidth / 6); + this.matrixFrameHeight = (this.matrixHeight / lines); + + // set proportion between matrix frame width and the container where the preview frame has to be displayed + this.proportion = (this.matrixFrameWidth / parentFrameWidth); + + // to avoid vertical overflow, we have to check the size resp. the proportion + if ((this.matrixFrameHeight / this.proportion) > parentFrameHeight) { + this.proportion = (this.matrixFrameHeight / parentFrameHeight); + } + + // set width and height of the frame + this.frameWidth = Math.round(this.matrixFrameWidth / this.proportion); + this.frameHeight = Math.round(this.matrixFrameHeight / this.proportion); + + // set the size of the matrix file + this.frame.nativeElement.style['background-image'] = 'url(' + this.matrix + ')'; + this.frame.nativeElement.style['background-size'] = Math.round(this.matrixWidth / this.proportion) + 'px auto'; + this.frame.nativeElement.style['width'] = this.frameWidth + 'px'; + this.frame.nativeElement.style['height'] = this.frameHeight + 'px'; + this.loaded.emit(true); + }, + (error: Error) => { + // preview file is not available: show default error image + this.frame.nativeElement.style['background-image'] = 'url(assets/images/preview-not-available.png)'; + this.frame.nativeElement.style['background-size'] = 'cover'; + this.frame.nativeElement.style['width'] = '100%'; + this.frame.nativeElement.style['height'] = '100%'; + this.loaded.emit(true); + } + ); + + } + + /** + * updates preview frame by mouse position + * @param ev + */ + updatePreviewByPosition(ev: MouseEvent) { + const position: number = ev.offsetX; + + // one frame per 6 pixels + if (Number.isInteger(position / 6)) { + // calculate time from relative mouse position; + this.time = (ev.offsetX / this._host.nativeElement.offsetWidth) * this.fileInfo.duration; + + this.updatePreviewByTime(); + } + } + + /** + * updates preview frame by time + */ + updatePreviewByTime() { + + // overflow fixes + if (this.time < 0) { + this.time = 0; + } + if (this.time > this.fileInfo.duration) { + this.time = this.fileInfo.duration; + } + + // get current matrix image; one matrix contains 6 minute of the video + let curMatrixNr: number = Math.floor(this.time / 360); + + if (curMatrixNr <= 0) { + curMatrixNr = 0; + } + + // set current matrix file url + this._getMatrixFile(curMatrixNr); + + if (!this.previewError) { + + let curFrameNr: number = Math.floor(this.time / 10) - Math.floor(36 * curMatrixNr); + + if (curFrameNr < 0) { + curFrameNr = 0; + } + if (curFrameNr > this.lastMatrixFrameNr) { + curFrameNr = this.lastMatrixFrameNr; + } + + // calculate current line and column number in the matrix and get current frame / preview image position + const curLineNr: number = Math.floor(curFrameNr / 6); + const curColNr: number = Math.floor(curFrameNr - (curLineNr * 6)); + const cssParams: string = '-' + (curColNr * this.frameWidth) + 'px -' + (curLineNr * this.frameHeight) + 'px'; + + this.frame.nativeElement.style['background-image'] = 'url(' + this.matrix + ')'; + this.frame.nativeElement.style['background-position'] = cssParams; + } + + } + + /** + * opens video by clicking on a certain frame + * used in preview only. + * --> Not sure if we have to keep it in DSP-APP. + * It will be part of the grid view and has to be activated when using there. + */ + openVideo() { + // this.open.emit({ video: this.fileInfo.name, time: Math.round(this.time) }); + } + + /** + * get matrix file by filenumber and with information from video file + * @param fileNumber + */ + private _getMatrixFile(fileNumber: number) { + + // get matrix url from video url + // get base path from http://0.0.0.0:1024/1111/5AiQkeJNbQn-ClrXWkJVFvB.mp4/file + // => http://0.0.0.0:1024/1111/5AiQkeJNbQn-ClrXWkJVFvB + const basePath = this.src.fileValue.fileUrl.substring(0, this.src.fileValue.fileUrl.lastIndexOf('/')).replace(/\.[^/.]+$/, ''); + // and file name + // => 5AiQkeJNbQn-ClrXWkJVFvB + const fileName = basePath.substring(basePath.lastIndexOf('/') + 1); + + const matrixUrl = basePath + '/' + fileName + '_m_' + fileNumber + '.jpg/file'; + + // if the new matrix url is different than the current one, the current one will be replaced + if (this.matrix !== matrixUrl) { + this.matrix = matrixUrl; + this.calculateSizes(this.matrix, fileNumber); + } + + } + + /** + * gets matrix dimension (width and height) + * if the file does not exist, return an error + * @param matrix + * @returns matrix dimension or error event + */ + private _getMatrixDimension(matrix: string): Observable { + + const mapLoadedImage = (event): Dimension => ({ + width: event.target.width, + height: event.target.height + }); + + const image = new Image(); + + const imageComplete = fromEvent(image, 'load').pipe( + take(1), + map(mapLoadedImage) + ); + const imageError = fromEvent(image, 'error').pipe( + tap(x => { + this.previewError = true; + const error: Error = new Error(); + error.message = 'Preview not available'; + error.name = '404 error'; + throw (error); + }) + ); + image.src = matrix; + return merge(imageComplete, imageError); + + } + + +} diff --git a/src/app/workspace/resource/representation/video/video.component.html b/src/app/workspace/resource/representation/video/video.component.html new file mode 100644 index 0000000000..51c482f11b --- /dev/null +++ b/src/app/workspace/resource/representation/video/video.component.html @@ -0,0 +1,81 @@ +
+
+ + + + + + + +
+ +
+ + + +
+
+
+ + + + + + + + + + + + + +
+

+ {{ currentTime | appTime }} + / {{ duration | appTime }} +

+
+ + + + + + +
+
+ +
diff --git a/src/app/workspace/resource/representation/video/video.component.scss b/src/app/workspace/resource/representation/video/video.component.scss new file mode 100644 index 0000000000..ca071cc384 --- /dev/null +++ b/src/app/workspace/resource/representation/video/video.component.scss @@ -0,0 +1,127 @@ +@mixin box-shadow($x: 0, $y: 1px, $blur: 3px, $alpha: 0.5) { + box-shadow: $x $y $blur rgba(0, 0, 0, $alpha); +} + +.player { + background: black; + height: 522px; + + .container { + position: relative; + height: 450px; + + .video { + position: absolute; + display: block; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0 auto; + width: 100%; + height: 100%; + } + + .preview-line { + width: 100%; + width: calc(100% - 16px); + position: absolute; + bottom: 0; + margin: 0 8px; + + .preview { + position: relative; + bottom: 0; + display: block; + border: 2px solid rgba(28, 28, 28, 0.9); + border-radius: 2px; + width: 158px; + height: 90px; + + app-video-preview { + position: absolute; + } + + } + } + + app-progress-indicator { + position: absolute; + margin-left: -24px; + } + + } + + .controls { + background: rgba(0, 0, 0, 0.5); + color: white; + position: relative; + + .mat-toolbar-row { + height: 24px; + } + + .progress { + width: 100%; + cursor: pointer; + } + + .action { + position: inherit; + height: 40px; + + .mat-icon-button[disabled][disabled] { + color: rgba(255, 255, 255, 0.26); + } + + .empty-space { + display: block; + width: 48px; + } + + .time { + margin-top: 12px; + padding: 0 6px; + cursor: all-scroll; + position: relative; + } + } + } +} + +// cinema mode = bigger video player, but not fullscreen +.player.cinema { + width: 100vw; + height: 100vh; + margin: 0; + top: 0; + left: 0; + position: fixed; + z-index: 999; + @include box-shadow(); + + .container { + width: 100%; + height: calc(100% - 80px); + position: fixed; + .video { + max-width: 100%; + } + + .preview-line { + position: fixed; + bottom: 80px; + } + } + + .controls { + position: fixed; + bottom: 0; + height: 80px; + + .action { + margin: 24px 0 0; + } + } + +} + diff --git a/src/app/workspace/resource/representation/video/video.component.spec.ts b/src/app/workspace/resource/representation/video/video.component.spec.ts new file mode 100644 index 0000000000..6b48255342 --- /dev/null +++ b/src/app/workspace/resource/representation/video/video.component.spec.ts @@ -0,0 +1,85 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { Component, OnInit, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ReadMovingImageFileValue } from '@dasch-swiss/dsp-js'; +import { TimePipe } from 'src/app/main/pipes/time.pipe'; +import { AvTimelineComponent } from '../av-timeline/av-timeline.component'; +import { FileRepresentation } from '../file-representation'; +import { VideoPreviewComponent } from './video-preview/video-preview.component'; +import { VideoComponent } from './video.component'; + +const videoFileValue: ReadMovingImageFileValue = { + 'type': 'http://api.knora.org/ontology/knora-api/v2#MovingImageFileValue', + 'id': 'http://rdfh.ch/1111/dyNT0dvbSgGrxVONpaH5FA/values/73Re06_TQDS4FM8SOyCHcQ', + 'attachedToUser': 'http://rdfh.ch/users/root', + 'arkUrl': 'http://0.0.0.0:3336/ark:/72163/1/1111/dyNT0dvbSgGrxVONpaH5FAT/73Re06_TQDS4FM8SOyCHcQG', + 'versionArkUrl': 'http://0.0.0.0:3336/ark:/72163/1/1111/dyNT0dvbSgGrxVONpaH5FAT/73Re06_TQDS4FM8SOyCHcQG.20220329T112456951082Z', + 'valueCreationDate': '2022-03-29T11:24:56.951082Z', + 'hasPermissions': 'CR knora-admin:ProjectAdmin|M knora-admin:ProjectMember', + 'userHasPermission': 'CR', + 'uuid': '73Re06_TQDS4FM8SOyCHcQ', + 'filename': '5AiQkeJNbQn-ClrXWkJVFvB.mp4', + 'fileUrl': 'http://0.0.0.0:1024/1111/5AiQkeJNbQn-ClrXWkJVFvB.mp4/file', + 'dimX': 0, + 'dimY': 0, + 'duration': 0, + 'fps': 0, + 'strval': 'http://0.0.0.0:1024/1111/5AiQkeJNbQn-ClrXWkJVFvB.mp4/file', + 'property': 'http://api.knora.org/ontology/knora-api/v2#hasMovingImageFileValue', + 'propertyLabel': 'hat Filmdatei', + 'propertyComment': 'Connects a Representation to a movie file' +}; + +@Component({ + template: ` + + ` +}) +class TestHostComponent implements OnInit { + + @ViewChild(VideoComponent) videoPlayerComp: VideoComponent; + + videoFileRepresentation: FileRepresentation; + + ngOnInit() { + this.videoFileRepresentation = new FileRepresentation(videoFileValue); + } +} + +describe('VideoComponent', () => { + let testHostComponent: TestHostComponent; + let testHostFixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + VideoComponent, + VideoPreviewComponent, + AvTimelineComponent, + TimePipe + ], + imports: [ + HttpClientTestingModule, + MatButtonModule, + MatIconModule, + MatToolbarModule, + MatTooltipModule + ] + }) + .compileComponents(); + }); + + beforeEach(() => { + testHostFixture = TestBed.createComponent(TestHostComponent); + testHostComponent = testHostFixture.componentInstance; + testHostFixture.detectChanges(); + }); + + it('should create', () => { + expect(testHostComponent).toBeTruthy(); + }); +}); diff --git a/src/app/workspace/resource/representation/video/video.component.ts b/src/app/workspace/resource/representation/video/video.component.ts new file mode 100644 index 0000000000..b208674469 --- /dev/null +++ b/src/app/workspace/resource/representation/video/video.component.ts @@ -0,0 +1,369 @@ +import { Component, ElementRef, HostListener, Inject, Input, OnInit, ViewChild } from '@angular/core'; +import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { + ApiResponseError, + Constants, + KnoraApiConnection, + ReadMovingImageFileValue, + ReadResource, + UpdateFileValue, + UpdateResource, + UpdateValue, + WriteValueResponse +} from '@dasch-swiss/dsp-js'; +import { mergeMap } from 'rxjs/operators'; +import { DspApiConnectionToken } from 'src/app/main/declarations/dsp-api-tokens'; +import { DialogComponent } from 'src/app/main/dialog/dialog.component'; +import { ErrorHandlerService } from 'src/app/main/error/error-handler.service'; +import { + EmitEvent, + Events, + UpdatedFileEventValue, + ValueOperationEventService +} from '../../services/value-operation-event.service'; +import { PointerValue } from '../av-timeline/av-timeline.component'; +import { FileRepresentation } from '../file-representation'; + +@Component({ + selector: 'app-video', + templateUrl: './video.component.html', + styleUrls: ['./video.component.scss'] +}) +export class VideoComponent implements OnInit { + + @Input() src: FileRepresentation; + + @Input() start?= 0; + + @Input() parentResource: ReadResource; + + @ViewChild('videoEle') videoEle: ElementRef; + + @ViewChild('timeline') timeline: ElementRef; + + @ViewChild('progress') progress: ElementRef; + + @ViewChild('preview') preview: ElementRef; + + loading = true; + + // video file url + video: SafeUrl; + + // video information + aspectRatio: number; + + // preview image information + frameWidth = 160; + halfFrameWidth: number = Math.round(this.frameWidth / 2); + frameHeight: number; + lastFrameNr: number; + + // preview images are organised in matrix files; we'll need the last number of those files + matrixWidth: number = Math.round(this.frameWidth * 6); + matrixHeight: number; + lastMatrixNr: number; + lastMatrixLine: number; + + // size of progress bar / timeline + timelineDimension: DOMRect; + + // time information + duration: number; + currentTime: number = this.start; + previewTime = 0; + + // seconds per pixel to calculate preview image on timeline + secondsPerPixel: number; + + // percent of video loaded + currentBuffer: number; + + // status + play = false; + reachedTheEnd = false; + + // volume + volume = .75; + muted = false; + + // video player mode + cinemaMode = false; + + // matTooltipPosition + matTooltipPos = 'below'; + + // if file was replaced, we have to reload the preview + fileHasChanged = false; + + constructor( + @Inject(DspApiConnectionToken) private _dspApiConnection: KnoraApiConnection, + private _dialog: MatDialog, + private _sanitizer: DomSanitizer, + private _errorHandler: ErrorHandlerService, + private _valueOperationEventService: ValueOperationEventService + ) { } + + @HostListener('document:keydown', ['$event']) onKeydownHandler(event: KeyboardEvent) { + if (event.key === 'Escape' && this.cinemaMode) { + this.cinemaMode = false; + } + } + + ngOnInit(): void { + this.video = this._sanitizer.bypassSecurityTrustUrl(this.src.fileValue.fileUrl); + this.fileHasChanged = false; + } + + /** + * stop playing and go back to start + */ + goToStart() { + this.videoEle.nativeElement.pause(); + this.play = false; + this.currentTime = 0; + this.videoEle.nativeElement.currentTime = this.currentTime; + } + + /** + * toggle play / pause + */ + togglePlay() { + + this.play = !this.play; + + if (this.play) { + this.videoEle.nativeElement.play(); + } else { + this.videoEle.nativeElement.pause(); + } + + } + + /** + * toggle player size between cinema (big) and normal mode + */ + toggleCinemaMode() { + this.cinemaMode = !this.cinemaMode; + } + + + /** + * update current time info and buffer size + * @param ev Event + */ + timeUpdate(ev: Event) { + // current time + this.currentTime = this.videoEle.nativeElement.currentTime; + + // buffer progress + this.currentBuffer = (this.videoEle.nativeElement.buffered.end(0) / this.duration) * 100; + + let range = 0; + const bf = this.videoEle.nativeElement.buffered; + + while (!(bf.start(range) <= this.currentTime && this.currentTime <= bf.end(range))) { + range += 1; + } + + if (this.currentTime === this.duration && this.play) { + this.play = false; + this.reachedTheEnd = true; + } else { + this.reachedTheEnd = false; + } + // --> TODO: activate the buffer information + // const loadStartPercentage = (bf.start(range) / this.duration) * 100; + // const loadEndPercentage = (bf.end(range) / this.duration) * 100; + // const loadPercentage = (loadEndPercentage - loadStartPercentage); + } + + /** + * as soon as video has status "loadedmetadata" we're able to read + * information about duration, video size and set volume + * + */ + loadedMetadata() { + // get video duration + this.duration = this.videoEle.nativeElement.duration; + + // set default volume + this.videoEle.nativeElement.volume = this.volume; + + // load preview file + this.displayPreview(true); + + } + + /** + * what happens when video is loaded + */ + loadedVideo() { + this.loading = false; + this.play = !this.videoEle.nativeElement.paused; + } + + /** + * video navigation from button (-/+ 10 sec) + * + * @param range positive or negative number value + */ + updateTimeFromButton(range: number) { + if (range > 0 && this.currentTime > (this.duration - 10)) { + this._navigate(this.duration); + } else if (range < 0 && this.currentTime < 10) { + this._navigate(0); + } else { + this._navigate(this.currentTime + range); + } + } + /** + * video navigation from timeline / progress bar + * + * @param ev MatSliderChange + */ + updateTimeFromSlider(time: number) { + this._navigate(time); + } + /** + * video navigation from scroll event + * + * @param ev WheelEvent + */ + updateTimeFromScroll(ev: WheelEvent) { + ev.preventDefault(); + this._navigate(this.currentTime + (ev.deltaY / 25)); + } + + + /** + * event on mouses move on timeline + * @param ev MouseEvent + */ + mouseMove(ev: MouseEvent) { + this._calcPreviewTime(ev); + } + + /** + * show preview image during "mousemove" on progress bar / timeline + * + * @param ev PointerValue + */ + updatePreview(ev: PointerValue) { + + this.displayPreview(true); + + this.previewTime = Math.round(ev.time); + + // position from left: + let leftPosition: number = (ev.position - this.timelineDimension.x) - this.halfFrameWidth; + + // prevent overflow of preview image on the left + if (leftPosition <= 8) { + leftPosition = 8; + } + + // prevent overflow of preview image on the right + if (leftPosition >= (this.timelineDimension.width - this.frameWidth + 8)) { + leftPosition = this.timelineDimension.width - this.frameWidth + 8; + } + + // set preview positon on x axis + this.preview.nativeElement.style.left = leftPosition + 'px'; + + } + + /** + * show preview image or hide it + * + * @param status true = show ('block'), false = hide ('none') + */ + displayPreview(status: boolean) { + this.preview.nativeElement.style.display = (status ? 'block' : 'none'); + } + + /** + * opens replace file dialog + * + */ + openReplaceFileDialog() { + const propId = this.parentResource.properties[Constants.HasMovingImageFileValue][0].id; + + const dialogConfig: MatDialogConfig = { + width: '800px', + maxHeight: '80vh', + position: { + top: '112px' + }, + data: { mode: 'replaceFile', title: 'Video (mp4)', subtitle: 'Update the video file of this resource', representation: 'movingImage', id: propId }, + disableClose: true + }; + const dialogRef = this._dialog.open( + DialogComponent, + dialogConfig + ); + + dialogRef.afterClosed().subscribe((data) => { + if (data) { + this._replaceFile(data); + } + }); + } + + /** + * general video navigation: Update current video time from position + * + * @param position Pixelnumber + */ + private _navigate(position: number) { + this.videoEle.nativeElement.currentTime = position; + } + + /** + * calcs time in preview frame + * @param ev + */ + private _calcPreviewTime(ev: MouseEvent) { + this.previewTime = Math.round((ev.offsetX / this.timeline.nativeElement.clientWidth) * this.duration); + this.previewTime = this.previewTime > this.duration ? this.duration : this.previewTime; + this.previewTime = this.previewTime < 0 ? 0 : this.previewTime; + } + + /** + * replaces file + * @param file + */ + private _replaceFile(file: UpdateFileValue) { + this.goToStart(); + + this.fileHasChanged = true; + + const updateRes = new UpdateResource(); + updateRes.id = this.parentResource.id; + updateRes.type = this.parentResource.type; + updateRes.property = Constants.HasMovingImageFileValue; + updateRes.value = file; + + this._dspApiConnection.v2.values.updateValue(updateRes as UpdateResource).pipe( + mergeMap((res: WriteValueResponse) => this._dspApiConnection.v2.values.getValue(this.parentResource.id, res.uuid)) + ).subscribe( + (res2: ReadResource) => { + this.src.fileValue.fileUrl = (res2.properties[Constants.HasMovingImageFileValue][0] as ReadMovingImageFileValue).fileUrl; + this.src.fileValue.filename = (res2.properties[Constants.HasMovingImageFileValue][0] as ReadMovingImageFileValue).filename; + this.src.fileValue.strval = (res2.properties[Constants.HasMovingImageFileValue][0] as ReadMovingImageFileValue).strval; + + this.ngOnInit(); + + this.loadedMetadata(); + + this._valueOperationEventService.emit( + new EmitEvent(Events.FileValueUpdated, new UpdatedFileEventValue( + res2.properties[Constants.HasMovingImageFileValue][0]))); + }, + (error: ApiResponseError) => { + this._errorHandler.showMessage(error); + } + ); + } +} diff --git a/src/app/workspace/resource/resource-link-form/resource-link-form.component.html b/src/app/workspace/resource/resource-link-form/resource-link-form.component.html index 3cbfa90c24..946287b407 100644 --- a/src/app/workspace/resource/resource-link-form/resource-link-form.component.html +++ b/src/app/workspace/resource/resource-link-form/resource-link-form.component.html @@ -12,7 +12,7 @@ + [placeholder]="'Collection label'"> {{ formErrors.label }} diff --git a/src/app/workspace/resource/resource.component.html b/src/app/workspace/resource/resource.component.html index 7b8df5d771..52f6216818 100644 --- a/src/app/workspace/resource/resource.component.html +++ b/src/app/workspace/resource/resource.component.html @@ -19,11 +19,13 @@ (regionAdded)="updateRegions($event)"> - + + + diff --git a/src/app/workspace/resource/resource.component.ts b/src/app/workspace/resource/resource.component.ts index f4ca4e093d..600ef4996d 100644 --- a/src/app/workspace/resource/resource.component.ts +++ b/src/app/workspace/resource/resource.component.ts @@ -16,7 +16,7 @@ import { KnoraApiConnection, ReadArchiveFileValue, ReadAudioFileValue, - ReadDocumentFileValue, ReadResource, + ReadDocumentFileValue, ReadMovingImageFileValue, ReadResource, ReadResourceSequence, ReadStillImageFileValue, SystemPropertyDefinition } from '@dasch-swiss/dsp-js'; @@ -454,6 +454,12 @@ export class ResourceComponent implements OnInit, OnChanges, OnDestroy { const audio = new FileRepresentation(fileValue); representations.push(audio); + } else if (resource.res.properties[Constants.HasMovingImageFileValue]) { + + const fileValue: ReadMovingImageFileValue = resource.res.properties[Constants.HasMovingImageFileValue][0] as ReadMovingImageFileValue; + const video = new FileRepresentation(fileValue); + representations.push(video); + } else if (resource.res.properties[Constants.HasArchiveFileValue]) { const fileValue: ReadArchiveFileValue = resource.res.properties[Constants.HasArchiveFileValue][0] as ReadArchiveFileValue; @@ -516,6 +522,7 @@ export class ResourceComponent implements OnInit, OnChanges, OnDestroy { if (resource.res.properties[Constants.HasStillImageFileValue] || resource.res.properties[Constants.HasDocumentFileValue] || resource.res.properties[Constants.HasAudioFileValue] || + resource.res.properties[Constants.HasMovingImageFileValue] || resource.res.properties[Constants.HasArchiveFileValue]) { // --> TODO: check if resources is a StillImageRepresentation using the ontology responder (support for subclass relations required) // the resource is a StillImageRepresentation, check if there are regions pointing to it diff --git a/src/assets/images/preview-not-available.png b/src/assets/images/preview-not-available.png new file mode 100644 index 0000000000..fd76c9d292 Binary files /dev/null and b/src/assets/images/preview-not-available.png differ diff --git a/src/assets/style/_elements.scss b/src/assets/style/_elements.scss index 68305550ef..7dcc6cc61a 100644 --- a/src/assets/style/_elements.scss +++ b/src/assets/style/_elements.scss @@ -293,9 +293,8 @@ a, position: relative; padding: 0; - &.audio { - height: auto; - } + &.video, + &.audio, &.archive { height: auto; } @@ -595,6 +594,13 @@ $gc-small: $form-width - $gc-large - 4; } } +// progress-indicator inside drag & drop container +.dd-container { + .app-progress-indicator.default { + margin: 16px auto !important; + } +} + // -------------------------------------- // tab bar in project, user route @@ -640,7 +646,8 @@ $gc-small: $form-width - $gc-large - 4; color: #000; background-color: rgba($active, 0.9); } - &.failed { + &.failed, + &.error { color: #fff; background-color: rgba($warn, 0.9); } diff --git a/src/assets/style/main.scss b/src/assets/style/main.scss index ea8babdc00..79e9dae3af 100644 --- a/src/assets/style/main.scss +++ b/src/assets/style/main.scss @@ -204,3 +204,19 @@ button.space-reducer { border-radius: 0 !important; } } + +// moving image player: overwrite mat-slider +.progress { + .mat-slider-track-background { + background-color: whitesmoke !important; + } + + .mat-slider-wrapper { + top: 12px !important; + } + + .mat-slider-thumb { + background-color: $accent !important; + border-color: $accent !important; + } +}