Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12,563 changes: 11,376 additions & 1,187 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<img [src]="file.url" alt="uploaded attachment preview" i18n-alt>
</ng-container>

<ng-container *ngIf="file?.type.includes('video')">
<ng-container *ngIf="isBrowserSupportedVideo()">
<video
width="100%"
controls
Expand All @@ -49,5 +49,16 @@
</p>
</video>
</ng-container>

<ng-container *ngIf="file?.type?.includes('video') && !isBrowserSupportedVideo()">
<div class="ion-padding ion-text-center">
<ion-icon name="videocam-off-outline" size="large" color="medium"></ion-icon>
<p class="body-2" i18n="unsupported video format message">
This video format is not supported for preview.
<br>
<a [href]="file.url" target="_blank" rel="noopener">Download to view</a>
</p>
</div>
</ng-container>
</div>
</ion-content>
12 changes: 12 additions & 0 deletions projects/v3/src/app/components/file-popup/file-popup.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ export class FilePopupComponent {
public sanitizer: DomSanitizer
) {}

/**
* @description checks if video format is natively supported by browsers.
* only mp4, webm, and ogg are widely supported.
*/
isBrowserSupportedVideo(): boolean {
if (!this.file?.type || !this.file.type.includes('video')) {
return false;
}
const supportedFormats = ['video/mp4', 'video/webm', 'video/ogg'];
return supportedFormats.some(format => this.file.type.includes(format));
}

download(keyboardEvent?: KeyboardEvent) {
if (keyboardEvent && (keyboardEvent?.code === 'Space' || keyboardEvent?.code === 'Enter')) {
keyboardEvent.preventDefault();
Expand Down
83 changes: 78 additions & 5 deletions projects/v3/src/app/components/topic/topic.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,27 @@ describe('TopicComponent', () => {
expect(component.previewFile).toHaveBeenCalledWith(file);
});

it('should open video modal when index 1 and file is video', () => {
it('should open video modal when index 1 and file is mp4 video', () => {
spyOn(component, 'previewVideoFile');
const file = { url: 'https://cdn.filestackcontent.com/abc123.mp4', name: 'video.mp4' };
component.actionBtnClick(file, 1);
expect(component.previewVideoFile).toHaveBeenCalledWith(file);
});

it('should open video modal when index 1 and file is webm video', () => {
spyOn(component, 'previewVideoFile');
const file = { url: 'https://cdn.filestackcontent.com/abc123.webm', name: 'video.webm' };
component.actionBtnClick(file, 1);
expect(component.previewVideoFile).toHaveBeenCalledWith(file);
});

it('should open video modal when index 1 and file is ogg video', () => {
spyOn(component, 'previewVideoFile');
const file = { url: 'https://cdn.filestackcontent.com/abc123.ogg', name: 'video.ogg' };
component.actionBtnClick(file, 1);
expect(component.previewVideoFile).toHaveBeenCalledWith(file);
});

it('should open new tab when index 1 and url is filestack but file is audio', () => {
spyOn(window, 'open');
spyOn(component, 'previewFile');
Expand Down Expand Up @@ -225,15 +239,34 @@ describe('TopicComponent', () => {
expect(icons).toEqual(['download']);
});

it('should return only download icon for non-mp4 video files', () => {
const file = { url: 'https://example.com/video.mov', name: 'video.mov' };
it('should return both download and search icons for webm video', () => {
const file = { url: 'https://example.com/video.webm', name: 'video.webm' };
const icons = component.getFileActionIcons(file);
expect(icons).toEqual(['download']);
expect(icons).toEqual(['download', 'search']);
});

it('should return both download and search icons for ogg video', () => {
const file = { url: 'https://example.com/video.ogg', name: 'video.ogg' };
const icons = component.getFileActionIcons(file);
expect(icons).toEqual(['download', 'search']);
});

it('should return only download icon for unsupported video formats', () => {
const formats = [
{ url: 'https://example.com/video.mov', name: 'video.mov' },
{ url: 'https://cdn.filestackcontent.com/abc123', name: 'file_example_AVI_640_800kB.avi' },
{ url: 'https://cdn.filestackcontent.com/abc123.wmv', name: 'video.wmv' },
{ url: 'https://example.com/video.mkv', name: 'video.mkv' },
];
for (const file of formats) {
const icons = component.getFileActionIcons(file);
expect(icons).withContext(file.name).toEqual(['download']);
}
});
});

describe('previewVideoFile', () => {
it('should open video modal with file properties', async () => {
it('should open video modal with mp4 mime type', async () => {
const modalSpy = jasmine.createSpyObj('Modal', ['present']);
spyOn(component['modalController'], 'create').and.returnValue(Promise.resolve(modalSpy));

Expand All @@ -252,5 +285,45 @@ describe('TopicComponent', () => {
});
expect(modalSpy.present).toHaveBeenCalled();
});

it('should open video modal with webm mime type', async () => {
const modalSpy = jasmine.createSpyObj('Modal', ['present']);
spyOn(component['modalController'], 'create').and.returnValue(Promise.resolve(modalSpy));

const file = { url: 'https://example.com/video.webm', name: 'test.webm' };
await component.previewVideoFile(file);

expect(component['modalController'].create).toHaveBeenCalledWith({
component: jasmine.anything(),
componentProps: {
file: {
url: file.url,
name: file.name,
type: 'video/webm',
},
},
});
expect(modalSpy.present).toHaveBeenCalled();
});

it('should open video modal with ogg mime type', async () => {
const modalSpy = jasmine.createSpyObj('Modal', ['present']);
spyOn(component['modalController'], 'create').and.returnValue(Promise.resolve(modalSpy));

const file = { url: 'https://example.com/video.ogg', name: 'test.ogg' };
await component.previewVideoFile(file);

expect(component['modalController'].create).toHaveBeenCalledWith({
component: jasmine.anything(),
componentProps: {
file: {
url: file.url,
name: file.name,
type: 'video/ogg',
},
},
});
expect(modalSpy.present).toHaveBeenCalled();
});
});
});
32 changes: 24 additions & 8 deletions projects/v3/src/app/components/topic/topic.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe
break;
case 1:
if (this._isVideoFile(file)) {
// show mp4 file in modal with html5 player
// show browser-supported video in modal with html5 player
this.previewVideoFile(file);
} else if (this._isFilestackUrl(file.url) && this._isFilestackPreviewSupported(file)) {
// show filestack document viewer
Expand All @@ -329,28 +329,44 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe
}

/**
* @description checks if file is an mp4 video (html5 browser-supported format)
* @description checks if file is a browser-supported video format (mp4, webm, ogg)
*/
private _isVideoFile(file: { url: string; name: string }): boolean {
const supportedExtensions = ['.mp4', '.webm', '.ogg'];
const urlLower = (file.url || '').toLowerCase();
const nameLower = (file.name || '').toLowerCase();
return urlLower.endsWith('.mp4') || nameLower.endsWith('.mp4');
return supportedExtensions.some(ext => urlLower.endsWith(ext) || nameLower.endsWith(ext));
}

/**
* @description derives video mime type from file extension
*/
private _getVideoMimeType(file: { url: string; name: string }): string {
const name = (file.name || file.url || '').toLowerCase();
if (name.endsWith('.webm')) return 'video/webm';
if (name.endsWith('.ogg')) return 'video/ogg';
return 'video/mp4';
}

/**
* @description checks if a file type is supported by filestack document viewer.
* supported: pdf, ppt/pptx, xls/xlsx, doc/docx, odt, odp, images, html, txt, ai, psd.
* unsupported: audio files (videos handled separately by html5 player).
* unsupported: audio and video files (filestack doesn't support media preview).
*/
private _isFilestackPreviewSupported(file: { url: string; name: string }): boolean {
const unsupportedExtensions = ['.mp3', '.wav', '.ogg', '.aac', '.flac', '.wma', '.m4a'];
const unsupportedExtensions = [
// audio formats
'.mp3', '.wav', '.ogg', '.aac', '.flac', '.wma', '.m4a',
// video formats (filestack doesn't support any video preview)
'.mp4', '.webm', '.avi', '.mov', '.wmv', '.mkv', '.flv', '.m4v',
];
const urlLower = (file.url || '').toLowerCase();
const nameLower = (file.name || '').toLowerCase();
return !unsupportedExtensions.some(ext => urlLower.endsWith(ext) || nameLower.endsWith(ext));
}

/**
* @description preview mp4 file in modal with html5 video player
* @description preview browser-supported video file in modal with html5 video player
*/
async previewVideoFile(file: { url: string; name: string }): Promise<void> {
const modal = await this.modalController.create({
Expand All @@ -359,7 +375,7 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe
file: {
url: file.url,
name: file.name,
type: 'video/mp4',
type: this._getVideoMimeType(file),
},
},
});
Expand All @@ -369,7 +385,7 @@ export class TopicComponent implements OnInit, OnChanges, AfterViewChecked, OnDe
/**
* @description returns action button icons for file attachment based on preview support.
* preview icon shown for:
* - mp4 video files (shown in html5 video modal)
* - browser-supported video files: mp4, webm, ogg (shown in html5 video modal)
* - filestack urls with document viewer supported file types
*/
getFileActionIcons(file: { url: string; name: string }): string[] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@
<ng-template #longWait>
<div>
<p i18n>Waited too long?</p>
<p i18n>Download <a target="_blank" href="{{video?.fileObject?.url}}">here</a></p>
<p i18n>Download <a target="_blank" href="{{video?.fileObject?.url}}" class="contrast">here</a></p>
</div>
</ng-template>
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
a.contrast {
color: var(--ion-color-primary-contrast);
}
.waiting-mgs {
display: flex;
padding: 16px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,32 @@
<img [src]="file.url" alt="uploaded attachment preview" i18n-alt>
</ng-container>

<ng-container *ngIf="file?.type.includes('video')">
<video controls>
<ng-container *ngIf="file?.url">
<source [src]="file.url"
[type]="file.type">
{{ file.url }}
</ng-container>
<ng-container *ngIf="isBrowserSupportedVideo()">
<video
width="100%"
controls
controlsList="nodownload"
preload="metadata"
playsinline
[src]="file.url"
(error)="handleVideoError($event)"
>
<p i18n="video not supported message">
Your browser doesn't support HTML5 video.
<a [href]="file.url" class="contrast">Download the video</a> instead.
</p>
</video>
</ng-container>

<ng-container *ngIf="file?.type?.includes('video') && !isBrowserSupportedVideo()">
<div class="ion-padding ion-text-center">
<ion-icon name="videocam-off-outline" size="large" color="medium"></ion-icon>
<p class="body-2" i18n="unsupported video format message">
This video format is not supported for preview.
<br>
<a [href]="file.url" target="_blank" rel="noopener">Download to view</a>
</p>
</div>
</ng-container>
</div>
</ion-content>
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ ion-header {
}
}
}

a.contrast {
color: var(--ion-color-primary-contrast);
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,40 @@ describe('ChatPreviewComponent', () => {
expect(modalSpy.dismiss).toHaveBeenCalled();
});
});

describe('isBrowserSupportedVideo', () => {
it('should return true for mp4', () => {
component.file = { url: 'video.mp4', type: 'video/mp4' };
expect(component.isBrowserSupportedVideo()).toBeTrue();
});

it('should return true for webm', () => {
component.file = { url: 'video.webm', type: 'video/webm' };
expect(component.isBrowserSupportedVideo()).toBeTrue();
});

it('should return true for ogg', () => {
component.file = { url: 'video.ogg', type: 'video/ogg' };
expect(component.isBrowserSupportedVideo()).toBeTrue();
});

it('should return false for unsupported video types', () => {
component.file = { url: 'video.avi', type: 'video/avi' };
expect(component.isBrowserSupportedVideo()).toBeFalse();
});

it('should return false when file type is not set', () => {
component.file = { url: 'video.mp4' };
expect(component.isBrowserSupportedVideo()).toBeFalse();
});
});

describe('handleVideoError', () => {
it('should log error details', () => {
spyOn(console, 'error');
const mockEvent = { target: { error: { code: 4, message: 'not supported' }, src: 'test.mp4', networkState: 3, readyState: 0 } } as any;
component.handleVideoError(mockEvent);
expect(console.error).toHaveBeenCalledWith('Video Error::', mockEvent);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,29 @@ export class ChatPreviewComponent {

this.modalController.dismiss();
}

/**
* @description checks if the video format is natively supported by the browser
*/
isBrowserSupportedVideo(): boolean {
const supportedTypes = ['video/mp4', 'video/webm', 'video/ogg'];
return this.file?.type && supportedTypes.includes(this.file.type);
}

/**
* @description handles video playback errors
*/
handleVideoError(videoError: Event): void {
console.error('Video Error::', videoError);
const target = videoError.target as HTMLVideoElement;
if (target) {
console.error('Video error details:', {
code: target.error?.code,
message: target.error?.message,
src: target.src,
networkState: target.networkState,
readyState: target.readyState,
});
}
}
}