From 86fc2a7d58e9d3227e3e94b01f14f469d45829b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Kilchenmann?= Date: Fri, 22 Apr 2022 08:59:22 +0200 Subject: [PATCH] feat(representation): implement video player incl. preview (DEV-701) (#698) * feat(representation): implement video player incl. preview * refactor: format code * refactor(av): better host handler * refactor(representation): clean up code in video components * test(pipe): test time pipe * test(representation): fix and write video tests * refactor(representation): video details * chore(deps): bump js-lib to latest * feat(video): replace moving image file functionality * fix(video): set correct preview position on timeline * style(preview): hide preview on click * refactor(av-timeline): clean up code and add more comments * refactor(video-preview): clean up code and add more comments * refactor(video): clean up code and add more comments * refactor(video): clean up code and add more comments * feat(video): matrix file error handler and code refactoring * fix(video): better calc * refactor(video): clean up code * refactor(video): remove commented code and fix typo * fix(video): update time after replacing video file * fix(representation): replace file only, if data exists --- src/app/app.module.ts | 14 +- src/app/main/dialog/dialog.component.html | 2 +- .../disable-context-menu.directive.spec.ts | 8 + .../disable-context-menu.directive.ts | 17 + src/app/main/pipes/time.pipe.spec.ts | 27 ++ src/app/main/pipes/time.pipe.ts | 30 ++ src/app/main/services/notification.service.ts | 6 +- .../archive/archive.component.ts | 5 +- .../representation/audio/audio.component.ts | 4 +- .../av-timeline/av-timeline.component.html | 10 + .../av-timeline/av-timeline.component.scss | 63 +++ .../av-timeline/av-timeline.component.spec.ts | 27 ++ .../av-timeline/av-timeline.component.ts | 233 ++++++++++ .../document/document.component.ts | 4 +- .../replace-file-form.component.html | 2 +- .../still-image/still-image.component.ts | 4 +- .../video-preview.component.html | 4 + .../video-preview.component.scss | 89 ++++ .../video-preview.component.spec.ts | 78 ++++ .../video-preview/video-preview.component.ts | 398 ++++++++++++++++++ .../representation/video/video.component.html | 81 ++++ .../representation/video/video.component.scss | 127 ++++++ .../video/video.component.spec.ts | 85 ++++ .../representation/video/video.component.ts | 369 ++++++++++++++++ .../resource-link-form.component.html | 2 +- .../resource/resource.component.html | 4 +- .../workspace/resource/resource.component.ts | 9 +- src/assets/images/preview-not-available.png | Bin 0 -> 11822 bytes src/assets/style/_elements.scss | 15 +- src/assets/style/main.scss | 16 + 30 files changed, 1714 insertions(+), 19 deletions(-) create mode 100644 src/app/main/directive/disable-context-menu.directive.spec.ts create mode 100644 src/app/main/directive/disable-context-menu.directive.ts create mode 100644 src/app/main/pipes/time.pipe.spec.ts create mode 100644 src/app/main/pipes/time.pipe.ts create mode 100644 src/app/workspace/resource/representation/av-timeline/av-timeline.component.html create mode 100644 src/app/workspace/resource/representation/av-timeline/av-timeline.component.scss create mode 100644 src/app/workspace/resource/representation/av-timeline/av-timeline.component.spec.ts create mode 100644 src/app/workspace/resource/representation/av-timeline/av-timeline.component.ts create mode 100644 src/app/workspace/resource/representation/video/video-preview/video-preview.component.html create mode 100644 src/app/workspace/resource/representation/video/video-preview/video-preview.component.scss create mode 100644 src/app/workspace/resource/representation/video/video-preview/video-preview.component.spec.ts create mode 100644 src/app/workspace/resource/representation/video/video-preview/video-preview.component.ts create mode 100644 src/app/workspace/resource/representation/video/video.component.html create mode 100644 src/app/workspace/resource/representation/video/video.component.scss create mode 100644 src/app/workspace/resource/representation/video/video.component.spec.ts create mode 100644 src/app/workspace/resource/representation/video/video.component.ts create mode 100644 src/assets/images/preview-not-available.png 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 0000000000000000000000000000000000000000..fd76c9d292d799e483f01887ae7b733403d6be51 GIT binary patch literal 11822 zcmeHtbyO6<_wRxrC7sep!wP~(!-{lwNG%c~E!|6rfOIU22rS**NQv&Ulz?-lKS@+vmLlzuHF_p@XAujk@b`=r!GI3 z^>DK}R_w<<&u`Sp$BE#abz_S%Qz&$mx-Xau7?xv4m+8RTEkaEsqU7`5Z1Tdz{Aw8-e`I;;o4ORs5l*ip8 z=A-d8*)L3S@H~&^6Qp02R`EU?RO}b6$kbvV?~J$!X;xfxLd-{KCpNqYrb0hzy@2s~ zr2mZ~gBz|bctzI;I5Z&19_-p22#(nS7VRC89)!oT2phw|4#q97vU0IMKln=~zS@(a zBmBI3qz8Hr6mFY@z*&Ue5WTD?8>GyDsx%(a>he9?pP#vepcC()SJ4Vw4Z z!3WRn9VDkmC5qmEkpmvYuAYz^mB=ED%W`0E?!eDmsCqeNK3irz@#=fweS3cGW1RgJ zeqFjF(T6%u6z+a~+c?`ll4>NcQ0dP+ zZBN_&O!#B5>%qlhJ7Twd<$CY!Z8Me|N04f8F|wJFnGvS}=(MY)WU#AoY`c;XynV`Z z+l#}V-toCt-`qsD$H>D^@aA+acvH;u$vqzAQONGRQOm1cks%|(;O}l`2<1~5EnC^5 z=Z4Pj8Vd5?zB0PTW*GzoxB0c>+dnqDUAycDr8xCprm%2@tS+s|{vGy&d%)e%p?wz1 z0v=!wuse$e^e>pFa`26AbVX ztDuahwP=5gs&%ui2(ApJE%V%St!$Y33o=kjf6N_Jyn6WZk(TS~Cw5iP(`S(`?E#N0aRVZENm&eFKnLEs<}St`)aj}cGNv0b`*#}gjg^@AiUaSqvyO_@7ie)X@d zS`*d?^U*w6|B=hH`+cjO@lP>H6YRq}pf*rg{HL4h{T=PRf03Oh{IU2gx74L+vjpjK z<)hJ?4}+qi_N$$TfJ)h7zY9G-ZiF=&TYNI&SC6qa{YVAN?zx$tE9VkmCaBZ$;pQ$N z`1H{P8qk{i@aTclj|gcqHTds>aJDm1eXpqr;QNR10XSIn0Nj5F>z~MCG5jyAgvA4R z^k4JX06>%j0Ox<$X#LawX)6EZKWqNG#?Hn5pB7lJb07Ur3~2n1sr{#^m;V&ON0qmp z000r?e*_DVnMDTx@M@_l$Qk%y?Pn9TRw{Z|%VA;3=_<)3={}Z=&u2}=m%~v|9OdZ! zK&27)o|RBFPE6e5e20?Ul|w704|rsCL^$vS8=EbhCw1uOap+Kk#!egB%mNJGSy~Ld zlr2eQzPO({t+aeqY1*`NQ!xd87+q8ck`rRfvEt)|v6A70zb3=~zkfg%_ah}DR-YcH zH|j@+|7p&w$<2O>x`0GY|42q*Z+}3*XS_o9``+!o|6(kc`}oV#$X*S`@i@uX@{d!Q zmdUJWnp*{;eKuc0Recm+y83c3&X9Mib!25+UD)i$F$3UV>nYDnIE+UzmTys4X=_FF z?7~zy?@6(N(YQ=ia{^5-_Guf@?k%Tu956~Wujsx8ctB%w?S<=Quj7bKa!@25?^Ubc zy5tmY4ylc|NG}&_?Zl@PDj*eh=9!&6dt31nCX@KOb_O4GEYnYm6@DbhU=^%N!J3nI z=B5hz9peuA6ZeiQuI#CncD+pRenwzg%G)3^6#(Ny-EvCeH?&jqCA|1*o*F}9xO^ks zv$>=)yIqFxDLBjecPp*DRcYza3JjK5L_ zmB})Q@jsBKMG9OA*KNyjPSJRVxcLiRB8T|-I?FIRLWoZGEvIG9*~h)JfOoxgIN?9W zsj+Y?<*<@Uaz@~e8e>}JRk>k*a;bb5%9J2=ig3yX74=xF!B0HL} z`8yMb@e)S{n$=H{rS*A4Uww*o&kDyaydtjatK>Y)uUAhzlRyyz*C=5PdlUb82o=+= zdP{86;B}E#2DhEo*)%6e7DvhS_3Eyxk=aQntLu)0%WFtM|8PQBVC2FBzZ9tdH!>-c zOmK6*>g>Y$ATLht`W(7XhTA4&xd6Zy+j{lCeDN9zt`70Q2aEJy-lZVZ>RlIJ!Oiv=p z(F#GanAmFLOiH6aFmuJXM~$5i6<;LySgsAa90pS#x+x=Mv`>5XjH`{i_BITAk1dU6 zL6qH=9nExEKd$UoD2qIcIj-ezie#y9X!vn5YQ;HbyC2!UgGPSdIlhC=SQgR8Od8zuQHH88i>!qMV#t%7jgO5@VhJrps_Fn=-N=@4vO&<+N7CZbH^N&0`6 z=j@uXR1&_&naM*{O;KwUwihuN&3XlbxvlM-Zc&RYiydCi_e-hw5Jqi70au9At99> zLDnVs7J^(!IjlMCpZ$JbTQto!U$j?pTqG3Lk2PxiNWaI_PD>`)Q@aFLP6pgHz2$vvpG~ zmoj4RcF>0}395F|CgSCP+EOIf7awvo7n{_4BNPix!G=+x#CN%KFAmZ-)8~d!ffWhb z%(mP~2K%VNZ_Std7U6BSZHMaeMS`wgTE+p9Q@JNM(X;*_zR&k%&EF=Xrj*9ymM=et za#hmmg#gyN<_im(ANt~Guf{w*>3I%A*8qK5;eqOA7YqhpZ|whA2#N-m&pkUL`{?57 zQp8fBcGeD=Y{(X6y*ZgA>H?}YU8%VwVpLoXS*70s8nX{Y_O&lE3_in=T221)1)EHa zm`dP9(Mv4X)N;2Q7nw^uHlKmri)|TgJ70a?{4P8qFTCWlg^^mo)-`eAuFas(0$E^+ zd9N?3{0tPWDQ1xEs>8A-KS1*&Ot_!6(!o^o5f+zV?9r?>&8!-61JUZzpXtKW5JXVq z7sZj^mlX!XE8CW8yu|Jc2}XB`2HK%)r9EkZd3(iYO4eIg$F%C@MIt?@+vy zca7`=_Osr6>yS1)RIum=vFA$RT$gPF)k-OWuDf6IP7Qq7iHsat1gRg%N?$e`GhJhX z%9c^(`yf!%}a*HjS<}4u`AQ zxX03a3Wpm}9csT>4H~nd_Q6?g8#v@%GQJg@$fKnPVauMzqdkE1Si=jz3lGxKNcpvM zzK3uw>2^6Expiv%ez~1rG{sOcSNghNYf4h>%+vasqin663PaD3BOd549dos(R~R!f3h=USV({e5_=Rz1y_1kHm4^3S_E7_;M0R z&#;O!mo$&02er|CH@q!K$qe8&o8+QBNeoP-P=^>%tUo;8_#{8-z1%794dV+5$M|hu z$8^=_DTckqUe15*ov(?#{4n+@M>y93D{cA>S0-H>A`ICR8>l5_Y^gMME~=6f8O?#Q zVEf!RkJg92hc$l35TCf?#;=q2Go^9Qye=sps)Gybs{@#9#eAcZ__pMdrp?S+X~ z<>bgtg#tl1PFpvHr@t0@ln9XpSk^9oAXT}1qUy6!kYz9dq{4wD;(w@5$9+EgK;=ig zpw|Y+98c8PF}9|l``*r>#RZ&NI2A~bw=XijG@RZPgdPu+}vd_10zFEARa-wzXPT1jV;0j z0uHPF5%A{kh1~4h?GNv?!{TvK8O8zW4KhVvAKfPHfMc|&JgptJtG3E>&;I%(ugd+f zM<1KSLi@>%I{BF294+U=+y0IS*uU#XozceUovS#JlLz_BycbSqkb%sUh>F+Ahxr^v zbGjzkUgG*s&-4>NcqO^qnKO7_DeGhmq4cIReBiLsy#&*HirE>xZDOaTc90K(OwdAJ zIudP~lFfcri@8mq4+a_rx9YGg4+w2wK%a{T{QhS7e{91HuC$c-*&LIW)*uM5WpXkY zUQR0ta9~>ki5$3o)5_xvj8E*)5;0n1omilmjU?m}kVro7ANtMGc|e#YpS`X}BJrG3 z?6G$Wi2&M22G(gVe9zP_eR4@^*0X3_6V~9G&kog^-r8&>_zjd zPX~_o7!7%PZ@H0CO`V-OMprN8dvj&9s)O`QEd+%g1lM)GM;A^nG#% zv}f!}oy#Oe4$S17zO+>~AGGjL#na!8Ho5uZzqXDw>4hW)W|_ZetB%I zV98{?A?K{F9Ow@w$Sw7?=4uJzFNgNTuDXIYV<(qVq!ohJEF78Zwi-A~o|A zBrr&@!JJz3<&B(`kWwfzGBD-t5@1}mKAtmFZ-||=mUannVwnP&yJ&O_|E&IiU6!Rj z5JK4vOz`gyAMP~~V! zD+6DjDtliPr3rKG(e8^H%0tfH!$Cwb1qXC{PIoTY(iD12-96nHTs~|wtKo3%`J+SE z0k0xCB7UwBi!6{#8!;#aP06y_o0m|NA0ieA>*nKn;UT@mC9CvgmfI)xQR2(>|F~9c zxiRwspM_J;d%~INOqAR$T{>;9iiy=AG0oMxEd?Gob=70jG0J)4MQe)llmG))mZe2H z>tv5aLXetH9m_yq +LH`O2edee-A=pHBn+Q=b`tH}YhCi7JkbJcALIJI? z&42*@7(}ikE&30hF?s_WS8;oZ&*qUGFK%?_+Fg^LPph7sT^6iVAk725&Qu+(hhU?Z zn)AG#Mx3fZC9I^u4n8KnG2N!SUqa;1+F+NDgv5u)N$1+UZ}jgV)_Wih*KI620gBTD zbGD-PeIA`9ahIyI=hoPVC?^YT9sRb5bu>MWB=DiF2nQFDeI;q~&;TOlO9pvBUr=}uIw5#!=-J$Cr zPD6E|-PWg*#}~e>clLo}B$$2G!o^V0tI}A4JM#Y$C~=_xBK-y8-aAbLluUWvd6jJQ z+->VPMyQf>fV+aFMKE??$zWAS234^4o)$`4H!o+EthF&qB22kR7q<&^{YK=Nh~$KS zhw#emGxd4}*UFm2?OXytgMnc)bdd|2Ja?MPzU5smM4jX}mRqYrQ0+3(|Fc}p4-JLf zU^XAF1VNijT>P_65t+Y{+t(h#f{e6dL<9Pe$kFfP$*X(zSz8xF?3iC7iyQ;{cVRQzFYERoE8vH0v4k@b{f9T1)N@SLGKo@%BD_NNpCBq zL3~HK0|GEchF6fI=}faP#}O<>tp>rGyLdG8W`o9Nd*)aELC%_2zevW2aKl(%Fp%lg z@_?3EuUzW38{#aU-Ut6a57|)buW-7X(YmjH3u%2_e@Fch!k_%Z45El9L0G2QW&m3- zcV4l-B{-Vdu^dRW7L-h7e=#N)s^R*qshbFA2XUF7eL+fhD-d)mbM_Y=W;+j5@9T6_+G05H6};Ntu3_SL== zt^)PC?+V{74!!{C*O6NvE6^1}?kaytZdSH_zXe@VcG7sRZ9U87!lO-w6CIOi5GO?_sE)ONt#vRp0`(0eczV85usZWgW5%8f=14V)MPyG;f{bhc%BYEKl@) z3l3+|pH=z^DKfh#{dpzR_517rXJ28ddoiKwK%G6n*5EscjDP*buWY^FtSpbA=M2eO zQf`D=cap=aIb#@bboSTrs>v-TekKi>^6|!@B8HMa|m} z!pa%Bdy$|X?_;{pitza4`ug3g36Qn}hzYK|<5tYmMCo4~=MdK0gV+O$^R0#mn$(hr z-$T*$(Yu8OdED2Zx)>N_h`=tjZ}-%qkYMZr;!+L1#mJ%ctcoNY`5=GBx88wNJn*=Q z_djPw;X{I#?AbYWtsULn#U0Aslk&>`_w6=sMjZzW?L+1_m;)2c30FpB=6CE3p=HGsM=NV-N9CvkhgXmV)HeWEJfE19G&>wIJuJB_?C@7Co&?sacb-J1UD8eI_kd&Ho-M#{ zI?e_n2zN{4vdLF%I|M0D-5h6DMluD+BVIi;8aeZU`~3TTORdy}u=WDSWFo%ji>ySQ zwaaO4ioyF|tVD80sl7QL zLrl5LSKdo0F@u83FD*16<00v6c3I6J+sW+&UAsKiQMuQYWm)(f+7#b>3|2@HcOhn5 z0c)_RccqdvfQFky#APgZLCsu3+V>EhLXi;TSqj+lM|IRRL`m{%85sv*ho(3t%x&*_O33G0vH#F<@bj2qa(gED7CP{@uh@DAgse&%mkLo|;6cTKB z1B=$ChI4OJk{sJ_j)H6y>KOo8=|PFDoY<(Cl>!9Fkv~m#GD_Y-uuq%kPX9P^<>Dcz z#*i6YP4OVVA2Ja0++tovWjh_yy=;R-MXSm)->6Q?%EkJQ(e0uObhgvv?ka|38<;jN z4O?c)w(nU-OcJR)g;yQ|*aQ!{D_nv)q?vC;gBK+EYL`X0oJI^gEPwfGWEfr*S?x? zj#4n}n}sZ@=1lG%-wZNK9%UY4nJMy9f%JvKNR=zv=n?8~2ZrJ5Bzqr?E)cfZ@=N5m z?PDW54du&shm%dir#M_WYpEbpSb%s?> zfVs9q%jT!r_f@_u>E9}bSGn;kXY4m4_#(EVK(gq_fYg%_nilj4Yw5>XbmDo7f$Ozx z)n-pb;;t>$C@OYQK%ur%uOsiS^kT|*_LcQGvMGx)!bKbqFto)C6gVx|Ipscw=e*u9p$N>>(uk>Aool>f5g4(nV24y5*y^kwBu$ixw`7~?S zO={(XqUanGH820ZDRI0nnIo|q@%SNUrJGzHXtmc@G%UkgG(!1pAYb<% z%c~FnSXzuNXql(Hoz;E|XP=&Q89w2X zifwbltE#kA z54oG!w8D3x2Al8x;KqMpV$l)dT*E1~AkSZ8tKzZy?^^Y0 z+8O3%dplnbof!hyQ`oB_ZJ7CnGySq2o_JBTXYZEsh z|Cc$Fr7k8E&M`MRDm-6XXcP)G)NC-BLuv}yhisvkbYBlz}e|XAZ&i`$+GXy_n>@ixpIz(xYl=n`|q=}ADbv(ym*J`vVW%OKGo0u zn21{lkX*%B9T(+b7{a&Ie&888u&WvPx+QOhG)BXkJPG(PQT@asck=BpZ zb0t^LKxfeZHb576a#CMKBS928IK7YH!al09rV$NpD8&ctMsp`&8$V55!>vcDkDEoe z7zZ#x7rEM!PuAta*Kqk(v9z|q{&=7R-A=vP!`MztS(j!|Nk(LXAVwi+1M$Kqg|y4< zM&KzZzb0ZAx@iP}ER&)>*HJ{H01U(>Zx2XrV zw#il?qNKcn5(#oT|-z$oX`!GsZtduywbyGBT z;QJ_nY)#TLxi5;p`wz7zr{Ar(9#yPU=wI|CU`tw>AFQf+JS8Fr^N^nt#& z%+Q8Y8!`WGOw-A#mpcI@4%|_yJ=%NxYX^OXK6z(H)T)(DAt!SrHQJiJ^izFqBqA0=L$?1;G_fM$XHopDvE!TSa#d6{ zDs_5t_;3Zgf6#F*EWa!>iP2l=I~-<2v?pz{++jN;Wn;%Y5&0MLoZCw+%!{COFL#DK zTls5r4M}~w7YIkBi@3+N4m}Nn(PT#nC|BImj+q%bgUgyp_qEDI&HVCYJ9k%>;AMM- zYs#}dM&0|A+zH#5m48p089k9tPO+2I&)~KW@b~+W%rDauA~kbq;0TAJovlP4=eq@8 z7vXlr|7-is;YMOkaS5y}eJ2L+eYDGSs6 z63-|9{{G+8y_gYNrqzb~zfB*Uy)*=IQCh~K42q~Z{iBy}yan8XTVz@|AN-yd4hhP6 zjwInx0uL+@T`K$y%Zd!?zQEBE4e6`EW(AxfS*Nejqci{y! z@3`Nqk}W&mYcjK0%|pI8{s_OVuywm}gr%zZZ<~&x(ta32DAeutO}OdcF1Y*Q87?+p zs@2Kc9n0q6YM&nhtEqhU;j#i7iXXVxW521F7a&v;kp6ri<#piw!6(kD?-yMwGy@Gp zzbxBolpquyqpfW_%i|T3BMFWVX{{Yunx=MpO9JHGCJT>M?YNU@4}A?iYcc|D+hb?j z7Wrh=pZNdr@yOP`d2_J8B$=9g(t4ZD!zPOIgSn;OQkapAgqDZP1WgOr_ zvtOp@e#&tdxK65{iTgVtYOuPSn(_ndzy*KV5-AOS>_f@QbEj*tZR`m+M$iC!y9@{> zF_RYP*0~1J*6{ak>Q5YeRHdPmqRCn4di%rz_#TG+e385}qZIT?5Y`g3of8;*NDkuL zXv$1A0Ec%JM!>$b6Ww;F-DhNcr;QPW#j|rBlGm^;>2_3M*pA~@D9$(si^^=(AJE?InGEVQaQz>SPbZb(GNY9z~9cQYOR8WA^c!td;rNhIyS(llpJ zs`|=r1motv$mR_|>h+W4<_DGO7TI>@;EfTj720Sqst1K&Aryq8Sn!~(OKqrg_8b|+ zeGOzvyy4*VkN|4D$$X+77QR+zJ>Jt)Iq{U!(DagnUk%c{lm0p{=&ebL`In;#MtyS1 zdK`xS`+E~A%m$0lR?9im6g{3$pFR<-7?W*rL;R$#+srOdWW9xF|AK@b8KmE(U!a|0 zr5RFWp78i5gle#Dp08@DoSKYIG_TJawS#X%2@^(-sGtB39ONOn>@?WU{eci4) z*l}~U-%pe_L?fuGbwA4H*ZBH4BCzZ?vo*v!s?7mz_$sh0@Nrfr*$|5arEZ)3M9Rq{ z{8Ohu{emsElI7k0;@enTyMNx!%?(nlyc&mXk9SeP(Qt%&n%(eP_?U1-!qIausH#lA zIih$B(~wU0&C@P6RN0>ZpG^4a@5GzSAt z@)WG7KXxl3@Y5o0WoMr zsaz7g&aRKr-7y&MY&j4z40u#l8J0;`=>{#-G>+;hYf^lAVLm<39PAg7Ui1S?)%sJ?Hp* zGfIM21}~MF`j$-ls7~1-?&)nd0+xqzgR;IsEE()lCs}HN;@`ZD)!S*znec=>wwc* z49?9M%W{@O=DD{2pQ#l4f1udy|5Nzi)GUrB{&>J!9f_y)f2Upt_}5ewwG^t~ScU&L DRedin literal 0 HcmV?d00001 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; + } +}