diff --git a/src/angular-app/bellows/shared/audio-recorder/audio-recorder.component.ts b/src/angular-app/bellows/shared/audio-recorder/audio-recorder.component.ts index b9b7373e39..19ca23020b 100644 --- a/src/angular-app/bellows/shared/audio-recorder/audio-recorder.component.ts +++ b/src/angular-app/bellows/shared/audio-recorder/audio-recorder.component.ts @@ -2,9 +2,11 @@ import { Project } from 'src/angular-app/bellows/shared/model/project.model'; import { Session, SessionService } from 'src/angular-app/bellows/core/session.service'; import { webmFixDuration } from "webm-fix-duration"; import * as angular from "angular"; +import { RecordingStateService } from 'src/angular-app/languageforge/lexicon/editor/recording-state.service'; +import { NoticeService } from '../../core/notice/notice.service'; export class AudioRecorderController implements angular.IController { - static $inject = ["$interval", "$scope", "sessionService"]; + static $inject = ["$interval", "$scope", "sessionService", "recordingStateService", "silNoticeService"]; project: Project; session: Session; @@ -20,11 +22,14 @@ export class AudioRecorderController implements angular.IController { callback: (blob: Blob) => void; durationInMilliseconds: number; interval: angular.IPromise; + private hasUnresolvedRecording = false; constructor( - private $interval: angular.IIntervalService, - private $scope: angular.IScope, - private sessionService: SessionService + private readonly $interval: angular.IIntervalService, + private readonly $scope: angular.IScope, + private readonly sessionService: SessionService, + private readonly recordingStateService: RecordingStateService, + private readonly notice: NoticeService, ) {} $onInit(): void { @@ -34,7 +39,13 @@ export class AudioRecorderController implements angular.IController { }); } - private startRecording() { + private startRecording(): boolean { + if (!this.recordingStateService.startRecording()) { + this.notice.push(this.notice.WARN, "Recording is already in progress", undefined, undefined, 4000); + return false; + } + + this.hasUnresolvedRecording = true; this.recordingTime = "0:00"; var codecSpecs: string; if(this.project.audioRecordingCodec === 'webm'){ @@ -109,6 +120,7 @@ export class AudioRecorderController implements angular.IController { console.error(err); } ); + return true; } private stopRecording() { @@ -124,9 +136,12 @@ export class AudioRecorderController implements angular.IController { } toggleRecording() { - if (this.isRecording) this.stopRecording(); - else this.startRecording(); - this.isRecording = !this.isRecording; + if (this.isRecording) { + this.stopRecording(); + this.isRecording = false; + } else { + this.isRecording = this.startRecording(); + } } close() { @@ -134,10 +149,12 @@ export class AudioRecorderController implements angular.IController { this.stopRecording(); } this.callback(null); + this.resolveRecording(); } saveAudio() { this.callback(this.blob); + this.resolveRecording(); } recordingSupported() { @@ -153,6 +170,14 @@ export class AudioRecorderController implements angular.IController { if (this.isRecording) { this.stopRecording(); } + this.resolveRecording(); + } + + private resolveRecording() { + if (this.hasUnresolvedRecording) { + this.recordingStateService.resolveRecording(); + this.hasUnresolvedRecording = false; + } } } diff --git a/src/angular-app/languageforge/lexicon/editor/editor.component.ts b/src/angular-app/languageforge/lexicon/editor/editor.component.ts index dc92d7acc5..4197d4b93b 100644 --- a/src/angular-app/languageforge/lexicon/editor/editor.component.ts +++ b/src/angular-app/languageforge/lexicon/editor/editor.component.ts @@ -34,6 +34,7 @@ import { LexOptionList } from '../shared/model/option-list.model'; import { FieldControl } from './field/field-control.model'; import { OfflineCacheUtilsService } from '../../../bellows/core/offline/offline-cache-utils.service'; import { IDeferred } from 'angular'; +import { RecordingStateService } from './recording-state.service'; class Show { more: () => void; @@ -94,6 +95,7 @@ export class LexiconEditorController implements angular.IController { 'lexRightsService', 'lexSendReceive', 'offlineCacheUtils', + 'recordingStateService', ]; constructor(private readonly $filter: angular.IFilterService, @@ -115,6 +117,7 @@ export class LexiconEditorController implements angular.IController { private readonly rightsService: LexiconRightsService, private readonly sendReceive: LexiconSendReceiveService, private readonly offlineCacheUtils: OfflineCacheUtilsService, + private readonly recordingStateService: RecordingStateService, ) { } $onInit(): void { @@ -232,8 +235,8 @@ export class LexiconEditorController implements angular.IController { this.$state.go('importExport'); } - returnToList(): void { - this.saveCurrentEntry(); + async returnToList(): Promise { + await this.saveCurrentEntry(); this.setCurrentEntry(); this.hideRightPanelWithoutAnimation(); this.$state.go('editor.list', { @@ -359,7 +362,7 @@ export class LexiconEditorController implements angular.IController { failCallback: (reason?: any) => void = () => { }) => { const isNewEntry = LexiconEditorController.entryIsNew(this.currentEntry); if (isNewEntry) { - // We have to wait for the initial save to complete so that we have + // We have to wait for the initial save to complete so that we save with the same entry ID await this.saving$.promise; } @@ -369,6 +372,10 @@ export class LexiconEditorController implements angular.IController { // entry and is NOT going to a different entry (as is the case with editing another entry. let newEntryTempId: string; + // We might be saving, because the user is navigating away from this entry, + // in which case we don't want to lose the upload. + await this.recordingStateService.uploading$(); + if (this.hasUnsavedChanges() && this.lecRights.canEditEntry()) { this.cancelAutoSaveTimer(); this.sendReceive.setStateUnsynced(); @@ -1125,7 +1132,15 @@ export class LexiconEditorController implements angular.IController { return this.lecConfig.inputSystems[inputSystemTag].abbreviation; } - private setCurrentEntry(entry: LexEntry = new LexEntry()): void { + private setCurrentEntry(entry: LexEntry = new LexEntry()): Promise { + if (entry.id === this.currentEntry.id && this.recordingStateService.hasUnsavedChanges()) { + // If it's the same entry, then we're just making sure the UI is as up to date + // as possible. If the user is recording or has audio to save, then we can't touch + // the UI, because the audio will get lost. + // If it's a different entry, we assume the user is intentionally discarding the audio. + return; + } + // align custom fields into model entry = this.alignCustomFieldsInData(entry); diff --git a/src/angular-app/languageforge/lexicon/editor/editor.module.ts b/src/angular-app/languageforge/lexicon/editor/editor.module.ts index 63ebdd79dc..26378254b5 100644 --- a/src/angular-app/languageforge/lexicon/editor/editor.module.ts +++ b/src/angular-app/languageforge/lexicon/editor/editor.module.ts @@ -10,6 +10,7 @@ import {LexiconCoreModule} from '../core/lexicon-core.module'; import {EditorCommentsModule} from './comment/comment.module'; import {LexiconEditorComponent, LexiconEditorEntryController, LexiconEditorListController} from './editor.component'; import {EditorFieldModule} from './field/field.module'; +import { RecordingStateService } from './recording-state.service'; export const LexiconEditorModule = angular .module('lexiconEditorModule', [ @@ -27,6 +28,7 @@ export const LexiconEditorModule = angular .component('lexiconEditor', LexiconEditorComponent) .controller('EditorListCtrl', LexiconEditorListController) .controller('EditorEntryCtrl', LexiconEditorEntryController) + .service('recordingStateService', RecordingStateService) .config(['$stateProvider', ($stateProvider: angular.ui.IStateProvider) => { // State machine from ui.router diff --git a/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts b/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts index 2990f2f1d0..a89369e5f7 100644 --- a/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts +++ b/src/angular-app/languageforge/lexicon/editor/field/dc-audio.component.ts @@ -11,6 +11,7 @@ import {UploadFile, UploadResponse} from '../../../../bellows/shared/model/uploa import {LexiconProjectService} from '../../core/lexicon-project.service'; import {Rights} from '../../core/lexicon-rights.service'; import {LexiconUtilityService} from '../../core/lexicon-utility.service'; +import { RecordingStateService } from '../recording-state.service'; export class FieldAudioController implements angular.IController { dcFilename: string; @@ -21,18 +22,27 @@ export class FieldAudioController implements angular.IController { showAudioUpload: boolean = false; showAudioRecorder: boolean = false; + private uploading$: angular.IDeferred; + static $inject = ['$filter', '$state', 'Upload', 'modalService', 'silNoticeService', 'sessionService', - 'lexProjectService', '$scope' + 'lexProjectService', '$scope', '$q', 'recordingStateService' ]; - constructor(private $filter: angular.IFilterService, private $state: angular.ui.IStateService, - private Upload: any, private modalService: ModalService, - private notice: NoticeService, private sessionService: SessionService, - private lexProjectService: LexiconProjectService, private $scope: angular.IScope) { - - this.$scope.$watch(() => this.dcFilename, () => this.showAudioRecorder = false); - } + constructor( + private $filter: angular.IFilterService, + private $state: angular.ui.IStateService, + private Upload: angular.angularFileUpload.IUploadService, + private modalService: ModalService, + private notice: NoticeService, + private sessionService: SessionService, + private lexProjectService: LexiconProjectService, + private $scope: angular.IScope, + private $q: angular.IQService, + private recordingStateService: RecordingStateService, + ) { + this.$scope.$watch(() => this.dcFilename, () => this.showAudioRecorder = false); + } hasAudio(): boolean { if (this.dcFilename == null) { @@ -101,14 +111,17 @@ export class FieldAudioController implements angular.IController { } this.notice.setLoading('Uploading ' + file.name + '...'); - this.Upload.upload({ + this.uploading$ = this.$q.defer(); + this.recordingStateService.startUploading(this.uploading$.promise); + return this.Upload.upload({ + method: 'POST', url: '/upload/audio', data: { file, previousFilename: this.dcFilename, recordedInBrowser: recordedInBrowser } - }).then((response: UploadResponse) => { + }).then((response) => { this.notice.cancelLoading(); const isUploadSuccess = response.data.result; if (isUploadSuccess) { @@ -143,7 +156,7 @@ export class FieldAudioController implements angular.IController { (evt: ProgressEvent) => { this.notice.setPercentComplete(Math.floor(100.0 * evt.loaded / evt.total)); - }); + }).finally(() => this.uploading$.resolve()); }); } @@ -170,6 +183,9 @@ export class FieldAudioController implements angular.IController { return filename.substr(filename.indexOf('_') + 1); } + $onDestroy() { + this.uploading$?.resolve(); + } } export const FieldAudioComponent: angular.IComponentOptions = { diff --git a/src/angular-app/languageforge/lexicon/editor/recording-state.service.ts b/src/angular-app/languageforge/lexicon/editor/recording-state.service.ts new file mode 100644 index 0000000000..3275a1ff3d --- /dev/null +++ b/src/angular-app/languageforge/lexicon/editor/recording-state.service.ts @@ -0,0 +1,39 @@ +import * as angular from 'angular'; + +export class RecordingStateService { + static $inject: string[] = ['$q']; + + private hasUnresolvedRecording = false; + private uploads$: angular.IPromise[] = []; + + constructor(private readonly $q: angular.IQService) { + } + + startRecording(): boolean { + if (this.hasUnresolvedRecording) { + return false; + } else { + this.hasUnresolvedRecording = true; + return true; + } + } + + resolveRecording(): void { + this.hasUnresolvedRecording = false; + } + + startUploading(upload: angular.IPromise): void { + this.uploads$.push(upload); + upload.finally(() => + this.uploads$ = this.uploads$.filter(_upload => _upload !== upload) + ); + } + + uploading$(): angular.IPromise { + return this.$q.all(this.uploads$); + } + + hasUnsavedChanges(): boolean { + return this.hasUnresolvedRecording || this.uploads$.length > 0; + } +}