-
-
Compressing video... {{ compressionProgress }}%
+
+
+
+
+
Compressing video...
+
Please wait, this may take a moment
+
{{ compressionProgress }}%
-
diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.scss b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.scss
index 777750f15..f3a91d13f 100644
--- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.scss
+++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.scss
@@ -18,34 +18,56 @@
}
.compression-overlay {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- z-index: 100;
display: flex;
align-items: center;
justify-content: center;
- background: rgba(255, 255, 255, 0.9);
+ width: 100%;
+ height: 100%;
+ min-height: 400px;
+ background: #fff;
+ border-radius: 8px;
.compression-status {
text-align: center;
- padding: 24px;
+ padding: 32px;
+ width: 100%;
+ max-width: 320px;
+
+ .compression-icon {
+ margin-bottom: 16px;
+
+ ion-icon {
+ font-size: 48px;
+ opacity: 0.6;
+ }
+ }
ion-spinner {
- width: 32px;
- height: 32px;
- margin-bottom: 12px;
+ width: 44px;
+ height: 44px;
+ margin-bottom: 16px;
+ }
+
+ h3 {
+ margin: 0 0 4px;
+ font-weight: 600;
}
p {
- margin-bottom: 12px;
+ margin: 0 0 20px;
}
ion-progress-bar {
- width: 200px;
+ width: 100%;
margin: 0 auto;
+ border-radius: 4px;
+ --background: var(--ion-color-light, #f0f0f0);
+ }
+
+ .percentage {
+ margin: 8px 0 0;
+ font-weight: 500;
+ color: var(--ion-color-primary);
}
}
}
diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts
index c8f91fcaa..0312fc1dc 100644
--- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts
+++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.component.ts
@@ -1,7 +1,7 @@
import { UppyFileData, UppyUploaderService, ALLOWED_FILE_TYPES } from './uppy-uploader.service';
import { environment } from '@v3/environments/environment';
import { NotificationsService } from './../../services/notifications.service';
-import { Component, OnInit, Input, Output, EventEmitter, OnDestroy } from '@angular/core';
+import { ChangeDetectorRef, Component, OnInit, Input, Output, EventEmitter, OnDestroy } from '@angular/core';
import { Uppy, UppyFile, UppyOptions, } from '@uppy/core';
import { ModalController } from '@ionic/angular';
import { BrowserStorageService } from '../../services/storage.service';
@@ -31,10 +31,8 @@ export class UppyUploaderComponent implements OnInit, OnDestroy {
compressionProgress = 0;
private compressionSub: Subscription | undefined;
- /** exposes service isCompressing for template binding */
- get isCompressing(): boolean {
- return this.uppyUploaderService.isCompressing;
- }
+ /** true only when this component's own uppy instance is compressing */
+ isCompressing = false;
s3Info: {
path: string;
@@ -47,6 +45,7 @@ export class UppyUploaderComponent implements OnInit, OnDestroy {
private modalController: ModalController,
private storageService: BrowserStorageService,
private uppyUploaderService: UppyUploaderService,
+ private cdr: ChangeDetectorRef,
) {
this.uppyProps = this.uppyUploaderService.uppyProps;
this.uppyProps.height = '500px';
@@ -69,8 +68,11 @@ export class UppyUploaderComponent implements OnInit, OnDestroy {
allowedFileTypes: this.loadAllowedFileTypes(),
});
- this.compressionSub = this.uppyUploaderService.compressionProgress$.subscribe(p => {
- this.compressionProgress = p ? Math.round(p.progress * 100) : 0;
+ this.compressionSub = this.uppyUploaderService.compressionProgress$.subscribe(({ uppy, progress }) => {
+ if (uppy !== this.uppy) return;
+ this.isCompressing = progress !== null;
+ this.compressionProgress = progress ? Math.round(progress.progress * 100) : 0;
+ this.cdr.markForCheck();
});
}
diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts
index 34bb8f9c1..a66c8ae77 100644
--- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts
+++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.spec.ts
@@ -2,30 +2,21 @@ import { TestBed } from '@angular/core/testing';
import { ModalController } from '@ionic/angular';
import { UppyUploaderService } from './uppy-uploader.service';
import { BrowserStorageService } from '../../services/storage.service';
-import { Uppy, UppyFile } from '@uppy/core';
+import { Uppy } from '@uppy/core';
import { environment } from '../../../environments/environment';
import { FfmpegService } from '../../services/ffmpeg.service';
import { Subject } from 'rxjs';
describe('UppyUploaderService', () => {
let service: UppyUploaderService;
- let modalControllerSpy: jasmine.SpyObj
;
- let storageServiceSpy: jasmine.SpyObj;
- let uppyInstanceSpy: jasmine.SpyObj>;
let ffmpegServiceSpy: jasmine.SpyObj;
- let modalSpy: any;
+ let modalCtrlSpy: jasmine.SpyObj;
+ let storageSpy: jasmine.SpyObj;
beforeEach(() => {
- modalSpy = jasmine.createSpyObj('HTMLIonModalElement', ['present']);
- modalControllerSpy = jasmine.createSpyObj('ModalController', ['create']);
- modalControllerSpy.create.and.returnValue(Promise.resolve(modalSpy));
-
- storageServiceSpy = jasmine.createSpyObj('BrowserStorageService', ['getUser', 'clearByName']);
- storageServiceSpy.getUser.and.returnValue({ apikey: 'test-api-key' });
- storageServiceSpy.clearByName.and.returnValue({});
-
- uppyInstanceSpy = jasmine.createSpyObj('Uppy', ['use', 'on']);
- uppyInstanceSpy.on.and.returnValue(uppyInstanceSpy); // to allow method chaining
+ modalCtrlSpy = jasmine.createSpyObj('ModalController', ['create']);
+ storageSpy = jasmine.createSpyObj('BrowserStorageService', ['getUser', 'clearByName']);
+ storageSpy.getUser.and.returnValue({ apikey: 'test-key' });
ffmpegServiceSpy = jasmine.createSpyObj('FfmpegService', [
'shouldCompress',
@@ -52,8 +43,8 @@ describe('UppyUploaderService', () => {
TestBed.configureTestingModule({
providers: [
UppyUploaderService,
- { provide: ModalController, useValue: modalControllerSpy },
- { provide: BrowserStorageService, useValue: storageServiceSpy },
+ { provide: ModalController, useValue: modalCtrlSpy },
+ { provide: BrowserStorageService, useValue: storageSpy },
{ provide: FfmpegService, useValue: ffmpegServiceSpy },
],
});
@@ -65,6 +56,14 @@ describe('UppyUploaderService', () => {
expect(service).toBeTruthy();
});
+ it('should have compressingUppy null initially', () => {
+ expect(service.compressingUppy).toBeNull();
+ });
+
+ it('should expose compressionProgress$ subject', () => {
+ expect(service.compressionProgress$).toBeTruthy();
+ });
+
describe('createUppyInstance', () => {
it('should create an Uppy instance with correct options', () => {
const events = {
@@ -112,28 +111,26 @@ describe('UppyUploaderService', () => {
});
describe('initializeEventHandlers', () => {
- it('should set up event handlers on the Uppy instance', () => {
- const onUploadSuccessSpy = jasmine.createSpy('onUploadSuccess');
- const file = { id: 'file-123' } as UppyFile;
- const response = { status: 200 };
+ let mockUppy: any;
- (service as any).initializeEventHandlers(uppyInstanceSpy, onUploadSuccessSpy);
-
- // simulate the behavior that would happen when the handler is called
- onUploadSuccessSpy(file, response);
-
- expect(onUploadSuccessSpy).toHaveBeenCalledWith(file, response);
+ beforeEach(() => {
+ mockUppy = {
+ on: jasmine.createSpy('on').and.callFake(function(this: any) { return this; }),
+ };
});
- it('should clear cache when upload completes successfully', () => {
+ it('should set up event handlers on the Uppy instance', () => {
const onUploadSuccessSpy = jasmine.createSpy('onUploadSuccess');
+ (service as any).initializeEventHandlers(mockUppy, onUploadSuccessSpy);
+ expect(mockUppy.on).toHaveBeenCalled();
+ });
- (service as any).initializeEventHandlers(uppyInstanceSpy, onUploadSuccessSpy);
-
- // test the behavior by calling the method that the handler would trigger
- service['storageService'].clearByName('file-123');
+ it('should register upload-success handler', () => {
+ const onUploadSuccessSpy = jasmine.createSpy('onUploadSuccess');
+ (service as any).initializeEventHandlers(mockUppy, onUploadSuccessSpy);
- expect(storageServiceSpy.clearByName).toHaveBeenCalledWith('file-123');
+ const registeredEvents = mockUppy.on.calls.allArgs().map((args: any[]) => args[0]);
+ expect(registeredEvents).toContain('upload-success');
});
});
@@ -148,19 +145,90 @@ describe('UppyUploaderService', () => {
});
});
- it('should have isCompressing false initially', () => {
- expect(service.isCompressing).toBeFalse();
- });
-
- it('should expose compressionProgress$ subject', () => {
- expect(service.compressionProgress$).toBeTruthy();
- });
-
- describe('compressVideoFiles (via files-added event)', () => {
+ describe('compression preprocessor', () => {
it('should skip non-video files', () => {
ffmpegServiceSpy.shouldCompress.and.returnValue({ compress: false, reason: 'not a video file' });
- // the method is private but triggers via uppy event — verify no compression call
+ // the preprocessor is private — verify no compression call
expect(ffmpegServiceSpy.compressVideo).not.toHaveBeenCalled();
});
});
+
+ describe('open', () => {
+ it('should create a modal with backdropDismiss false', async () => {
+ const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']);
+ mockModal.present.and.returnValue(Promise.resolve());
+ modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal));
+
+ await service.open('chat');
+
+ expect(modalCtrlSpy.create).toHaveBeenCalledWith(jasmine.objectContaining({
+ backdropDismiss: false,
+ }));
+ });
+
+ it('should create a modal with canDismiss function', async () => {
+ const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']);
+ mockModal.present.and.returnValue(Promise.resolve());
+ modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal));
+
+ await service.open('chat');
+
+ const createArgs = modalCtrlSpy.create.calls.mostRecent().args[0];
+ expect(createArgs.canDismiss).toBeDefined();
+ expect(typeof createArgs.canDismiss).toBe('function');
+ });
+
+ it('should allow dismiss when not compressing', async () => {
+ const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']);
+ mockModal.present.and.returnValue(Promise.resolve());
+ modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal));
+
+ await service.open('chat');
+
+ const createArgs = modalCtrlSpy.create.calls.mostRecent().args[0];
+ service.compressingUppy = null;
+ const canDismissFn = createArgs.canDismiss as (data?: any, role?: string) => Promise;
+ const canDismiss = await canDismissFn();
+ expect(canDismiss).toBeTrue();
+ });
+
+ it('should block dismiss when compressing', async () => {
+ const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']);
+ mockModal.present.and.returnValue(Promise.resolve());
+ modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal));
+
+ await service.open('chat');
+
+ const createArgs = modalCtrlSpy.create.calls.mostRecent().args[0];
+ service.compressingUppy = {} as Uppy;
+ const canDismissFn = createArgs.canDismiss as (data?: any, role?: string) => Promise;
+ const canDismiss = await canDismissFn();
+ expect(canDismiss).toBeFalse();
+
+ // cleanup
+ service.compressingUppy = null;
+ });
+
+ it('should present the modal', async () => {
+ const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']);
+ mockModal.present.and.returnValue(Promise.resolve());
+ modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal));
+
+ await service.open('chat');
+
+ expect(mockModal.present).toHaveBeenCalled();
+ });
+
+ it('should pass the source as component prop', async () => {
+ const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['present']);
+ mockModal.present.and.returnValue(Promise.resolve());
+ modalCtrlSpy.create.and.returnValue(Promise.resolve(mockModal));
+
+ await service.open('assessment');
+
+ expect(modalCtrlSpy.create).toHaveBeenCalledWith(jasmine.objectContaining({
+ componentProps: { source: 'assessment' },
+ }));
+ });
+ });
});
diff --git a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts
index 0f67ba4d4..53bd6459f 100644
--- a/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts
+++ b/projects/v3/src/app/components/uppy-uploader/uppy-uploader.service.ts
@@ -1,7 +1,7 @@
/* eslint-disable no-console */
import { ModalController } from '@ionic/angular';
-import { Injectable } from '@angular/core';
+import { Injectable, NgZone } from '@angular/core';
import { UploadResult, Uppy, UppyFile, UppyOptions } from '@uppy/core';
import Tus from '@uppy/tus';
import { BrowserStorageService } from '../../services/storage.service';
@@ -102,16 +102,17 @@ export class UppyUploaderService {
};
};
- /** emits compression progress for ui consumption */
- readonly compressionProgress$ = new Subject();
+ /** emits compression progress scoped to a specific uppy instance */
+ readonly compressionProgress$ = new Subject<{ uppy: Uppy; progress: CompressionProgress | null }>();
- /** true while a video is being compressed */
- isCompressing = false;
+ /** the uppy instance currently compressing, or null when idle */
+ compressingUppy: Uppy | null = null;
constructor(
private modalController: ModalController,
private storageService: BrowserStorageService,
private ffmpegService: FfmpegService,
+ private ngZone: NgZone,
) {
}
@@ -171,6 +172,7 @@ export class UppyUploaderService {
});
this.initializeEventHandlers(uppy, events.onUploadSuccess);
+ this.registerCompressionPreProcessor(uppy);
return uppy;
}
@@ -180,7 +182,6 @@ export class UppyUploaderService {
console.log('file edit start', file);
}).on('files-added', (files: any) => {
console.log('files added', files);
- this.compressVideoFiles(uppy, files);
}).on('file-removed', (file: any) => {
console.log('file removed', file);
}).on('restriction-failed', (file: any, error: any) => {
@@ -203,61 +204,79 @@ export class UppyUploaderService {
}
/**
- * compress video files in-place within the uppy instance.
- * replaces original video blob with the compressed version.
+ * register a preprocessor that compresses video files before upload.
+ * uppy waits for the returned promise to resolve before starting the upload.
*/
- private async compressVideoFiles(uppy: Uppy, files: UppyFile[]): Promise {
- const videoFiles = files.filter(f => f.type?.startsWith('video/'));
- if (videoFiles.length === 0) {
- return;
- }
-
- for (const uppyFile of videoFiles) {
- const file = new File([uppyFile.data as Blob], uppyFile.name, { type: uppyFile.type });
- const check = this.ffmpegService.shouldCompress(file);
+ private registerCompressionPreProcessor(uppy: Uppy): void {
+ uppy.addPreProcessor(async (fileIDs: string[]) => {
+ const videoFileIDs = fileIDs.filter(id => {
+ const file = uppy.getFile(id);
+ return file?.type?.startsWith('video/');
+ });
- if (!check.compress) {
- console.log(`skipping compression for ${uppyFile.name}: ${check.reason}`);
- continue;
+ if (videoFileIDs.length === 0) {
+ return;
}
- try {
- this.isCompressing = true;
- this.compressionProgress$.next({ progress: 0, timeUs: 0 });
-
- // forward ffmpeg progress to our subject
- const sub = this.ffmpegService.progress$.subscribe(p => {
- this.compressionProgress$.next(p);
- });
-
- const result = await this.ffmpegService.compressVideo(file);
-
- sub.unsubscribe();
- this.compressionProgress$.next(null);
- this.isCompressing = false;
-
- if (!result.skipped) {
- console.log(`compressed ${uppyFile.name}: ${result.originalSize} → ${result.compressedSize} (${result.reductionPercent}% reduction)`);
-
- // replace the file data in uppy with the compressed version
- uppy.setFileState(uppyFile.id, {
- data: result.file,
- size: result.compressedSize,
- name: result.file.name,
- type: 'video/mp4',
- meta: {
- ...uppyFile.meta,
- name: result.file.name,
- type: 'video/mp4',
- },
+ for (const id of videoFileIDs) {
+ const uppyFile = uppy.getFile(id);
+ if (!uppyFile) continue;
+
+ const file = new File([uppyFile.data as Blob], uppyFile.name, { type: uppyFile.type });
+ const check = this.ffmpegService.shouldCompress(file);
+
+ if (!check.compress) {
+ console.log(`skipping compression for ${uppyFile.name}: ${check.reason}`);
+ continue;
+ }
+
+ try {
+ this.ngZone.run(() => {
+ this.compressingUppy = uppy;
+ this.compressionProgress$.next({ uppy, progress: { progress: 0, timeUs: 0 } });
+ });
+
+ const sub = this.ffmpegService.progress$.subscribe(p => {
+ this.compressionProgress$.next({ uppy, progress: p });
+ });
+
+ const result = await this.ffmpegService.compressVideo(file);
+
+ sub.unsubscribe();
+ this.ngZone.run(() => {
+ this.compressionProgress$.next({ uppy, progress: null });
+ this.compressingUppy = null;
+ });
+
+ if (!result.skipped) {
+ console.log(`compressed ${uppyFile.name}: ${result.originalSize} → ${result.compressedSize} (${result.reductionPercent}% reduction)`);
+
+ // guard: file may have been removed during async compression
+ if (uppy.getFile(id)) {
+ uppy.setFileState(id, {
+ data: result.file,
+ size: result.compressedSize,
+ name: result.file.name,
+ type: 'video/mp4',
+ meta: {
+ ...uppyFile.meta,
+ name: result.file.name,
+ type: 'video/mp4',
+ },
+ });
+ } else {
+ console.warn(`file ${id} was removed during compression, skipping state update`);
+ }
+ }
+ } catch (error) {
+ console.error(`compression failed for ${uppyFile.name}, uploading original:`, error);
+ this.ngZone.run(() => {
+ this.compressingUppy = null;
+ this.compressionProgress$.next({ uppy, progress: null });
});
}
- } catch (error) {
- console.error(`compression failed for ${uppyFile.name}, uploading original:`, error);
- this.isCompressing = false;
- this.compressionProgress$.next(null);
}
- }
+ });
}
/**
@@ -276,6 +295,8 @@ export class UppyUploaderService {
source
},
cssClass: 'uppy-uploader-modal',
+ backdropDismiss: false,
+ canDismiss: () => Promise.resolve(this.compressingUppy === null),
});
await modal.present();
diff --git a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts
index cc069ab1c..8ace74ba9 100644
--- a/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts
+++ b/projects/v3/src/app/pages/chat/chat-room/chat-room.component.spec.ts
@@ -15,6 +15,7 @@ import { Router, ActivatedRoute, convertToParamMap } from '@angular/router';
import { IonContent, ModalController, PopoverController } from '@ionic/angular';
import { TestUtils } from '@testingv3/utils';
import { mockMembers } from '@testingv3/fixtures';
+import { UppyUploaderService } from '../../../components/uppy-uploader/uppy-uploader.service';
export class MockElementRef extends ElementRef {
constructor() { super(null); }
@@ -31,6 +32,7 @@ describe('ChatRoomComponent', () => {
let routerSpy: jasmine.SpyObj;
let routeStub: Partial;
let MockIoncontent: IonContent;
+ let uppyUploaderServiceSpy: jasmine.SpyObj;
const modalSpy = jasmine.createSpyObj('Modal', ['present', 'onDidDismiss']);
modalSpy.onDidDismiss.and.returnValue(new Promise(() => { }));
let modalCtrlSpy: any;
@@ -110,8 +112,8 @@ describe('ChatRoomComponent', () => {
useValue: modalCtrlSpy
},
{
- provide: NotificationsService,
- useValue: jasmine.createSpyObj('NotificationsService', ['alert', 'presentToast']),
+ provide: UppyUploaderService,
+ useValue: jasmine.createSpyObj('UppyUploaderService', ['open'])
},
]
})
@@ -130,9 +132,9 @@ describe('ChatRoomComponent', () => {
storageSpy = TestBed.inject(BrowserStorageService) as jasmine.SpyObj;
pusherSpy = TestBed.inject(PusherService) as jasmine.SpyObj;
MockIoncontent = TestBed.inject(IonContent) as jasmine.SpyObj;
+ uppyUploaderServiceSpy = TestBed.inject(UppyUploaderService) as jasmine.SpyObj;
modalCtrlSpy = TestBed.inject(ModalController);
modalCtrlSpy.create.and.returnValue(Promise.resolve(modalSpy));
- // assign content for tests that need it
component.content = MockIoncontent;
fixture.detectChanges();
});
@@ -712,4 +714,57 @@ describe('ChatRoomComponent', () => {
});
});
+ describe('when testing attachmentSelectPopover()', () => {
+ it('should call uppyUploaderService.open with chat source', async () => {
+ const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['onDidDismiss']);
+ mockModal.onDidDismiss.and.returnValue(Promise.resolve({ data: null }));
+ uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve(mockModal));
+
+ await component.attachmentSelectPopover({});
+
+ expect(uppyUploaderServiceSpy.open).toHaveBeenCalledWith('chat');
+ });
+
+ it('should add attachment when modal returns valid data', async () => {
+ const mockFileData = {
+ source: 'chat',
+ id: 'file-1',
+ name: 'video.mp4',
+ extension: 'mp4',
+ meta: { relativePath: null, name: 'video.mp4', type: 'video/mp4' },
+ type: 'video/mp4',
+ data: {},
+ progress: { uploadStarted: 0, uploadComplete: true, percentage: 100, bytesUploaded: 1000, bytesTotal: 1000 },
+ size: 1000,
+ isGhost: false,
+ isRemote: false,
+ preview: '',
+ tus: { uploadUrl: 'https://upload.example.com/file-1' },
+ bucket: 'test-bucket',
+ path: 'uploads/video.mp4',
+ url: 'https://cdn.example.com/video.mp4',
+ };
+
+ const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['onDidDismiss']);
+ mockModal.onDidDismiss.and.returnValue(Promise.resolve({ data: mockFileData }));
+ uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve(mockModal));
+
+ spyOn(component, 'addAttachment');
+ await component.attachmentSelectPopover({});
+
+ expect(component.addAttachment).toHaveBeenCalledWith(mockFileData);
+ });
+
+ it('should not add attachment when modal returns no data', async () => {
+ const mockModal = jasmine.createSpyObj('HTMLIonModalElement', ['onDidDismiss']);
+ mockModal.onDidDismiss.and.returnValue(Promise.resolve({ data: null }));
+ uppyUploaderServiceSpy.open.and.returnValue(Promise.resolve(mockModal));
+
+ spyOn(component, 'addAttachment');
+ await component.attachmentSelectPopover({});
+
+ expect(component.addAttachment).not.toHaveBeenCalled();
+ });
+ });
+
});
diff --git a/projects/v3/src/app/services/ffmpeg.service.spec.ts b/projects/v3/src/app/services/ffmpeg.service.spec.ts
index 555d1ac7f..aef516e64 100644
--- a/projects/v3/src/app/services/ffmpeg.service.spec.ts
+++ b/projects/v3/src/app/services/ffmpeg.service.spec.ts
@@ -1,19 +1,63 @@
-import { TestBed } from '@angular/core/testing';
+import { TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { NgZone } from '@angular/core';
import { FfmpegService, CompressionResult } from './ffmpeg.service';
describe('FfmpegService', () => {
let service: FfmpegService;
+ let ngZone: NgZone;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(FfmpegService);
+ ngZone = TestBed.inject(NgZone);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
+ it('should inject NgZone', () => {
+ expect(ngZone).toBeTruthy();
+ });
+
+ describe('progress$ and log$', () => {
+ it('should expose progress$ subject', () => {
+ expect(service.progress$).toBeTruthy();
+ expect(typeof service.progress$.subscribe).toBe('function');
+ });
+
+ it('should expose log$ subject', () => {
+ expect(service.log$).toBeTruthy();
+ expect(typeof service.log$.subscribe).toBe('function');
+ });
+
+ it('should emit progress values to subscribers', () => {
+ const emitted: any[] = [];
+ const sub = service.progress$.subscribe(p => emitted.push(p));
+
+ service.progress$.next({ progress: 0.5, timeUs: 1000 });
+ service.progress$.next({ progress: 1.0, timeUs: 2000 });
+
+ expect(emitted.length).toBe(2);
+ expect(emitted[0].progress).toBe(0.5);
+ expect(emitted[1].progress).toBe(1.0);
+ sub.unsubscribe();
+ });
+
+ it('should emit log messages to subscribers', () => {
+ const logs: string[] = [];
+ const sub = service.log$.subscribe(msg => logs.push(msg));
+
+ service.log$.next('encoding started');
+ service.log$.next('frame=100');
+
+ expect(logs.length).toBe(2);
+ expect(logs[0]).toBe('encoding started');
+ sub.unsubscribe();
+ });
+ });
+
describe('isSupported', () => {
it('should return true when WebAssembly and BigInt64Array exist', () => {
expect(service.isSupported()).toBeTrue();
@@ -113,4 +157,33 @@ describe('FfmpegService', () => {
expect(service.isFfmpegLoaded()).toBeFalse();
});
});
+
+ describe('transcodeToMp4', () => {
+ it('should attempt to load ffmpeg if not loaded', async () => {
+ const loadSpy = spyOn(service, 'loadFFmpeg').and.rejectWith(new Error('test: skip actual load'));
+ const file = new File(['data'], 'video.avi', { type: 'video/x-msvideo' });
+
+ try {
+ await service.transcodeToMp4(file);
+ } catch {
+ // expected — we prevented actual load
+ }
+
+ expect(loadSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe('loadFFmpeg', () => {
+ it('should skip loading if already loaded', async () => {
+ // set isLoaded to true via reflection
+ (service as any).isLoaded = true;
+ const ffmpegSpy = spyOn((service as any).ffmpeg, 'load');
+
+ await service.loadFFmpeg();
+
+ expect(ffmpegSpy).not.toHaveBeenCalled();
+ // reset
+ (service as any).isLoaded = false;
+ });
+ });
});
diff --git a/projects/v3/src/app/services/ffmpeg.service.ts b/projects/v3/src/app/services/ffmpeg.service.ts
index 778d550cc..fee16834b 100644
--- a/projects/v3/src/app/services/ffmpeg.service.ts
+++ b/projects/v3/src/app/services/ffmpeg.service.ts
@@ -1,4 +1,4 @@
-import { Injectable } from '@angular/core';
+import { Injectable, NgZone } from '@angular/core';
import { FFmpeg } from '@ffmpeg/ffmpeg';
import { fetchFile } from '@ffmpeg/util';
import { Subject } from 'rxjs';
@@ -47,7 +47,7 @@ export class FfmpegService {
readonly progress$ = new Subject();
readonly log$ = new Subject();
- constructor() {
+ constructor(private ngZone: NgZone) {
this.ffmpeg = new FFmpeg();
}
@@ -101,7 +101,7 @@ export class FfmpegService {
});
this.ffmpeg.on('progress', ({ progress, time }) => {
- this.progress$.next({ progress, timeUs: time });
+ this.ngZone.run(() => this.progress$.next({ progress, timeUs: time }));
});
await this.ffmpeg.load({
@@ -162,8 +162,8 @@ export class FfmpegService {
const compressedFile = new File([blob], outputName, { type: 'video/mp4' });
// clean up virtual fs to free memory
- await this.ffmpeg.deleteFile(inputName);
- await this.ffmpeg.deleteFile(outputName);
+ try { await this.ffmpeg.deleteFile(inputName); } catch { /* already removed */ }
+ try { await this.ffmpeg.deleteFile(outputName); } catch { /* already removed */ }
const reductionPercent = Math.round((1 - compressedFile.size / file.size) * 100);
@@ -206,8 +206,8 @@ export class FfmpegService {
{ type: 'video/mp4' }
);
- await this.ffmpeg.deleteFile(inputName);
- await this.ffmpeg.deleteFile(outputName);
+ try { await this.ffmpeg.deleteFile(inputName); } catch { /* already removed */ }
+ try { await this.ffmpeg.deleteFile(outputName); } catch { /* already removed */ }
return outputFile;
}
@@ -223,8 +223,8 @@ export class FfmpegService {
const fileData = await this.ffmpeg.readFile('output.mp4');
const blob = new Blob([fileData as ArrayBuffer], { type: 'video/mp4' });
- await this.ffmpeg.deleteFile('input.avi');
- await this.ffmpeg.deleteFile('output.mp4');
+ try { await this.ffmpeg.deleteFile('input.avi'); } catch { /* already removed */ }
+ try { await this.ffmpeg.deleteFile('output.mp4'); } catch { /* already removed */ }
return blob;
}