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 @@
+
+
+
+
+
+
+
+
+
+
+ skip_previous
+
+
+ {{ reachedTheEnd ? "replay" : (play ? "pause" : "play_arrow") }}
+
+
+
+
+ replay_10
+
+
+
+
+ {{ currentTime | appTime }}
+ / {{ duration | appTime }}
+
+
+
+
+ forward_10
+
+
+
+
+ {{ muted ? "volume_mute" : "volume_up" }}
+
+
+
+ {{
+ cinemaMode ? "fullscreen_exit" : "fullscreen"
+ }}
+
+
+ cloud_upload
+
+
+
+
+
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;
+ }
+}