diff --git a/angular.json b/angular.json index b6b377205..ec4ff0695 100644 --- a/angular.json +++ b/angular.json @@ -42,7 +42,8 @@ "input": "src/theme/theme.scss" } ], - "scripts": [] + "scripts": [], + "webWorkerTsConfig": "tsconfig.worker.json" }, "configurations": { "production": { diff --git a/package-lock.json b/package-lock.json index 2c56f638d..b0bb0809c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16854,6 +16854,11 @@ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, + "event-target-shim": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz", + "integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==" + }, "eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", @@ -23834,6 +23839,15 @@ } } }, + "mp3-mediarecorder": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/mp3-mediarecorder/-/mp3-mediarecorder-4.0.5.tgz", + "integrity": "sha512-tu8XvKGMrdwNmEQTzBbaJRLBAuVNEzbzmCOnYzUyYuEb48Kwl97qA6f5nBEaZXveNmHgvvi0i85TjROPC49qFA==", + "requires": { + "event-target-shim": "6.0.2", + "vmsg": "0.4.0" + } + }, "mpd-parser": { "version": "0.22.1", "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.22.1.tgz", @@ -33487,6 +33501,11 @@ } } }, + "vmsg": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/vmsg/-/vmsg-0.4.0.tgz", + "integrity": "sha512-46BBqRSfqdFGUpO2j+Hpz8T9YE5uWG0/PWal1PT+R1o8NEthtjG/XWl4HzbB8hIHpg/UtmKvsxL2OKQBrIYcHQ==" + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index fc8bc34e4..2fb7ad968 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "mathjax": "2.7.9", "moment": "2.29.4", "moment-timezone": "0.5.38", + "mp3-mediarecorder": "^4.0.5", "nl.kingsquare.cordova.background-audio": "1.0.1", "ogv": "1.8.9", "rxjs": "6.5.5", diff --git a/scripts/copy-assets.js b/scripts/copy-assets.js index 03fec6038..72274160b 100644 --- a/scripts/copy-assets.js +++ b/scripts/copy-assets.js @@ -27,6 +27,7 @@ const ASSETS = { '/node_modules/mathjax/jax/output/SVG': '/lib/mathjax/jax/output/SVG', '/node_modules/mathjax/jax/output/PreviewHTML': '/lib/mathjax/jax/output/PreviewHTML', '/node_modules/mathjax/localization': '/lib/mathjax/localization', + '/node_modules/mp3-mediarecorder/dist/vmsg.wasm': '/lib/vmsg/vmsg.wasm', '/src/core/features/h5p/assets': '/lib/h5p', '/node_modules/ogv/dist': '/lib/ogv', '/node_modules/video.js/dist/video-js.min.css': '/lib/video.js/video-js.min.css', diff --git a/scripts/langindex.json b/scripts/langindex.json index ca500a630..95e7f3887 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1742,9 +1742,11 @@ "core.filenotfound": "resource", "core.fileuploader.addfiletext": "repository", "core.fileuploader.audio": "local_moodlemobileapp", + "core.fileuploader.audiotitle": "tiny_recordrtc", "core.fileuploader.camera": "local_moodlemobileapp", "core.fileuploader.confirmuploadfile": "local_moodlemobileapp", "core.fileuploader.confirmuploadunknownsize": "local_moodlemobileapp", + "core.fileuploader.discardrecording": "local_moodlemobileapp", "core.fileuploader.errorcapturingaudio": "local_moodlemobileapp", "core.fileuploader.errorcapturingimage": "local_moodlemobileapp", "core.fileuploader.errorcapturingvideo": "local_moodlemobileapp", @@ -1758,11 +1760,18 @@ "core.fileuploader.fileuploaded": "local_moodlemobileapp", "core.fileuploader.invalidfiletype": "repository", "core.fileuploader.maxbytesfile": "local_moodlemobileapp", + "core.fileuploader.microphonepermissiondenied": "local_moodlemobileapp", + "core.fileuploader.microphonepermissionrestricted": "local_moodlemobileapp", "core.fileuploader.more": "data", + "core.fileuploader.pauserecording": "local_moodlemobileapp", "core.fileuploader.photoalbums": "local_moodlemobileapp", "core.fileuploader.readingfile": "local_moodlemobileapp", "core.fileuploader.readingfileperc": "local_moodlemobileapp", + "core.fileuploader.resumerecording": "local_moodlemobileapp", "core.fileuploader.selectafile": "local_moodlemobileapp", + "core.fileuploader.startrecording": "tiny_recordrtc", + "core.fileuploader.startrecordinginstructions": "local_moodlemobileapp", + "core.fileuploader.stoprecording": "tiny_recordrtc", "core.fileuploader.uploadafile": "local_moodlemobileapp", "core.fileuploader.uploading": "local_moodlemobileapp", "core.fileuploader.uploadingperc": "local_moodlemobileapp", diff --git a/src/core/classes/errors/captureerror.ts b/src/core/classes/errors/captureerror.ts index 302ffb5a8..084240290 100644 --- a/src/core/classes/errors/captureerror.ts +++ b/src/core/classes/errors/captureerror.ts @@ -14,6 +14,8 @@ import { CoreError } from './error'; +export const CAPTURE_ERROR_NO_MEDIA_FILES = 3; + /** * Capture error. */ diff --git a/src/core/components/chrono/chrono.ts b/src/core/components/chrono/chrono.ts index 8f30406a1..0604cc65e 100644 --- a/src/core/components/chrono/chrono.ts +++ b/src/core/components/chrono/chrono.ts @@ -46,6 +46,7 @@ export class CoreChronoComponent implements OnInit, OnChanges, OnDestroy { @Input() startTime = 0; // Number of milliseconds to put in the chrono before starting. @Input() endTime?: number; // Number of milliseconds to stop the chrono. @Input() reset?: boolean; // Set it to true to reset the chrono. + @Input() hours = true; @Output() onEnd: EventEmitter; // Will emit an event when the endTime is reached. time = 0; diff --git a/src/core/components/chrono/core-chrono.html b/src/core/components/chrono/core-chrono.html index 5f0f28ade..fccdbca93 100644 --- a/src/core/components/chrono/core-chrono.html +++ b/src/core/components/chrono/core-chrono.html @@ -1 +1 @@ -{{ time / 1000 | coreSecondsToHMS }} \ No newline at end of file +{{ time / 1000 | coreSecondsToHMS:hours }} diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.component.ts b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.component.ts new file mode 100644 index 000000000..184558e6b --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.component.ts @@ -0,0 +1,316 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { ChangeDetectionStrategy, Component, ElementRef, OnDestroy } from '@angular/core'; +import { CoreModalComponent } from '@classes/modal-component'; +import { CorePlatform } from '@services/platform'; +import { Diagnostic, DomSanitizer, Translate } from '@singletons'; +import { BehaviorSubject, combineLatest, Observable, OperatorFunction } from 'rxjs'; +import { Mp3MediaRecorder } from 'mp3-mediarecorder'; +import { map, shareReplay, tap } from 'rxjs/operators'; +import { initAudioEncoderMessage } from '@features/fileuploader/utils/worker-messages'; +import { SafeUrl } from '@angular/platform-browser'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CAPTURE_ERROR_NO_MEDIA_FILES, CoreCaptureError } from '@classes/errors/captureerror'; +import { CoreFileUploaderAudioRecording } from '@features/fileuploader/services/fileuploader'; +import { CoreFile, CoreFileProvider } from '@services/file'; +import { CorePath } from '@singletons/path'; + +@Component({ + selector: 'core-fileuploader-audio-recorder', + styleUrls: ['./audio-recorder.scss'], + templateUrl: 'audio-recorder.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CoreFileUploaderAudioRecorderComponent extends CoreModalComponent + implements OnDestroy { + + recordingUrl$: Observable; + histogramAnalyzer$: Observable; + status$: Observable<'recording-ongoing' | 'recording-paused' | 'done' | 'empty'>; + + protected recording: AudioRecording | null; + protected media$: BehaviorSubject; + protected recording$: Observable; + + constructor(elementRef: ElementRef) { + super(elementRef); + + this.recording = null; + this.media$ = new BehaviorSubject(null); + this.recording$ = this.media$.pipe( + recorderAudioRecording(), + shareReplay(), + tap(recording => this.recording = recording), + ); + this.recordingUrl$ = this.recording$.pipe( + map(recording => recording && DomSanitizer.bypassSecurityTrustUrl(recording.url)), + ); + this.histogramAnalyzer$ = this.media$.pipe(map(media => { + if (!media?.analyser || CorePlatform.prefersReducedMotion()) { + return null; + } + + return media.analyser; + })); + this.status$ = combineLatest([this.media$.pipe(recorderStatus(), shareReplay()), this.recording$]) + .pipe(map(([recordingStatus, recording]) => { + if (recordingStatus === 'recording') { + return 'recording-ongoing'; + } + + if (recordingStatus === 'paused') { + return 'recording-paused'; + } + + if (recording) { + return 'done'; + } + + return 'empty'; + })); + } + + /** + * @inheritdoc + */ + ngOnDestroy(): void { + this.media$.value?.recorder.stop(); + } + + /** + * Start recording. + */ + async startRecording(): Promise { + const media = await this.createMedia(); + + this.media$.next(media); + + media.recorder.start(); + } + + /** + * Stop recording. + */ + stopRecording(): void { + this.media$.value?.recorder.stop(); + } + + /** + * Stop recording. + */ + pauseRecording(): void { + this.media$.value?.recorder.pause(); + } + + /** + * Stop recording. + */ + resumeRecording(): void { + this.media$.value?.recorder.resume(); + } + + /** + * Discard recording. + */ + discardRecording(): void { + this.media$.next(null); + } + + /** + * Dismiss modal without a result. + */ + async cancel(): Promise { + this.close(new CoreCaptureError(CAPTURE_ERROR_NO_MEDIA_FILES)); + } + + /** + * Dismiss the modal with the current recording as a result. + */ + async submit(): Promise { + if (!this.recording) { + return; + } + + const fileName = await CoreFile.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, 'recording.mp3'); + const filePath = CorePath.concatenatePaths(CoreFileProvider.TMPFOLDER, fileName); + const fileEntry = await CoreFile.writeFile(filePath, this.recording.blob); + + this.close({ + name: fileEntry.name, + fullPath: fileEntry.toURL(), + type: 'audio/mpeg', + }); + } + + /** + * Create media instances. + * + * @returns Media instances. + */ + protected async createMedia(): Promise { + await this.prepareMicrophoneAuthorization(); + + const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const audioContext = new window.AudioContext(); + const source = audioContext.createMediaStreamSource(mediaStream); + const analyser = audioContext.createAnalyser(); + + analyser.fftSize = 2048; + source.connect(analyser); + + return { + analyser, + recorder: new Mp3MediaRecorder(mediaStream, { worker: this.startWorker(), audioContext }), + }; + } + + /** + * Make sure that microphone usage has been authorized. + */ + protected async prepareMicrophoneAuthorization(): Promise { + if (!CorePlatform.isMobile()) { + return; + } + + const status = await Diagnostic.requestMicrophoneAuthorization(); + + switch (status) { + case Diagnostic.permissionStatus.DENIED_ONCE: + case Diagnostic.permissionStatus.DENIED_ALWAYS: + throw new Error(Translate.instant('core.fileuploader.microphonepermissiondenied')); + case Diagnostic.permissionStatus.RESTRICTED: + throw new Error(Translate.instant('core.fileuploader.microphonepermissionrestricted')); + } + } + + /** + * Start worker script. + * + * @returns Worker. + */ + protected startWorker(): Worker { + const worker = new Worker('./audio-recorder.worker', { type: 'module' }); + + worker.postMessage( + initAudioEncoderMessage({ vmsgWasmUrl: `${document.head.baseURI}assets/lib/vmsg/vmsg.wasm` }), + ); + + return worker; + } + +} + +/** + * Audio recording data. + */ +interface AudioRecording { + url: string; + blob: Blob; +} + +/** + * Media instances. + */ +interface AudioRecorderMedia { + recorder: Mp3MediaRecorder; + analyser: AnalyserNode; +} + +/** + * Observable operator that listens to a recorder and emits a recording file. + * + * @returns Operator. + */ +function recorderAudioRecording(): OperatorFunction { + return source => new Observable(subscriber => { + let audioChunks: Blob[] = []; + let previousRecorder: Mp3MediaRecorder | undefined; + const onDataAvailable = event => audioChunks.push(event.data); + const onError = event => CoreDomUtils.showErrorModal(event.error); + const onStop = () => { + const blob = new Blob(audioChunks, { type: 'audio/mpeg' }); + + subscriber.next({ + url: URL.createObjectURL(blob), + blob, + }); + }; + const subscription = source.subscribe(media => { + previousRecorder?.removeEventListener('dataavailable', onDataAvailable); + previousRecorder?.removeEventListener('error', onError); + previousRecorder?.removeEventListener('stop', onStop); + + media?.recorder.addEventListener('dataavailable', onDataAvailable); + media?.recorder.addEventListener('error', onError); + media?.recorder.addEventListener('stop', onStop); + + audioChunks = []; + previousRecorder = media?.recorder; + + subscriber.next(null); + }); + + subscriber.next(null); + + return () => { + subscription.unsubscribe(); + + previousRecorder?.removeEventListener('dataavailable', onDataAvailable); + previousRecorder?.removeEventListener('error', onError); + previousRecorder?.removeEventListener('stop', onStop); + }; + }); +} + +/** + * Observable operator that listens to a recorder and emits its recording status. + * + * @returns Operator. + */ +function recorderStatus(): OperatorFunction { + return source => new Observable(subscriber => { + let previousRecorder: Mp3MediaRecorder | undefined; + const onStart = () => subscriber.next('recording'); + const onPause = () => subscriber.next('paused'); + const onResume = () => subscriber.next('recording'); + const onStop = () => subscriber.next('inactive'); + const subscription = source.subscribe(media => { + previousRecorder?.removeEventListener('start', onStart); + previousRecorder?.removeEventListener('pause', onPause); + previousRecorder?.removeEventListener('resume', onResume); + previousRecorder?.removeEventListener('stop', onStop); + + media?.recorder.addEventListener('start', onStart); + media?.recorder.addEventListener('pause', onPause); + media?.recorder.addEventListener('resume', onResume); + media?.recorder.addEventListener('stop', onStop); + + previousRecorder = media?.recorder; + + subscriber.next(media?.recorder.state ?? 'inactive'); + }); + + subscriber.next('inactive'); + + return () => { + subscription.unsubscribe(); + + previousRecorder?.removeEventListener('start', onStart); + previousRecorder?.removeEventListener('pause', onPause); + previousRecorder?.removeEventListener('resume', onResume); + previousRecorder?.removeEventListener('stop', onStop); + }; + }); +} diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.html b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.html new file mode 100644 index 000000000..60858f79f --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.html @@ -0,0 +1,74 @@ +
+

{{ 'core.fileuploader.audiotitle' | translate }}

+ + + + +
+ +
+ + +
+

{{ 'core.fileuploader.startrecordinginstructions' | translate }}

+ + + + +
+ +
+ + + +
+
+
+ + +
+ +
+ + + + + + + +
+ +
+ + + +
+
+
+ +
+ + +
+
+ + + +
+ +
+ + {{ 'core.save' | translate }} + +
+
+
+
diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.scss b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.scss new file mode 100644 index 000000000..6bd83cef0 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.scss @@ -0,0 +1,119 @@ +:host { + color: var(--ion-text-color, #000); + + header { + display: flex; + justify-content: space-between; + align-items: center; + + h1 { + margin: 0; + font-size: 16px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0.15px; + } + + ion-button { + --padding-start: 0; + --padding-end: 0; + --icon-size: 1.8em; + + // Offset padding for visual alignment. + margin: calc((var(--icon-size) - var(--a11y-min-target-size)) / 2); + } + + } + + hr { + background: var(--gray-300); + margin: 16px 0; + } + + .core-audio-recorder--wrapper { + display: flex; + flex-direction: column; + align-items: center; + + p { + font-size: 14px; + font-weight: 400; + line-height: 20px; + letter-spacing: 0.25px; + text-align: center; + opacity: 0.6; + margin-top: 0; + margin-bottom: 16px; + } + + ion-button[shape="round"] { + --border-radius: 99px; + --padding-start: 16px; + --padding-end: 16px; + --padding-top: 16px; + --padding-bottom: 16px; + + height: max-content; + } + + core-audio-histogram { + width: 100%; + height: 35px; + display: block; + } + + audio { + width: 100%; + margin-bottom: 16px; + } + + .core-audio-recorder--controls { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + .core-audio-recorder--control { + width: 33%; + text-align: center; + + &:first-child { + text-align: start; + } + + &:last-child { + text-align: end; + } + + &.chrono { + padding: 0 16px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + } + + ion-button { + margin: 0; + } + + .core-audio-recorder--recording-marker { + width: 8px; + height: 8px; + margin-inline-end: 4px; + border-radius: 4px; + background: var(--danger); + } + + core-chrono.recording { + color: var(--danger); + } + + } + + } + + } + +} diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.worker.ts b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.worker.ts new file mode 100644 index 000000000..9b46372b2 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.worker.ts @@ -0,0 +1,32 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { isInitAudioEncoderMessage } from '@features/fileuploader/utils/worker-messages'; +import { initMp3MediaEncoder } from 'mp3-mediarecorder/worker'; + +/** + * Handle worker message. + * + * @param event Worker message event. + */ +function onMessage(event: MessageEvent): void { + if (!isInitAudioEncoderMessage(event.data)) { + return; + } + + removeEventListener('message', onMessage); + initMp3MediaEncoder(event.data.config); +} + +addEventListener('message', onMessage); diff --git a/src/core/features/fileuploader/components/components.module.ts b/src/core/features/fileuploader/components/components.module.ts new file mode 100644 index 000000000..118c8e573 --- /dev/null +++ b/src/core/features/fileuploader/components/components.module.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreFileUploaderAudioRecorderComponent } from './audio-recorder/audio-recorder.component'; +import { CoreFileUploaderAudioHistogramComponent } from './audio-histogram/audio-histogram'; + +@NgModule({ + imports: [ + CoreSharedModule, + ], + declarations: [ + CoreFileUploaderAudioRecorderComponent, + CoreFileUploaderAudioHistogramComponent, + ], + exports: [ + CoreFileUploaderAudioRecorderComponent, + CoreFileUploaderAudioHistogramComponent, + ], +}) +export class CoreFileUploaderComponentsModule {} diff --git a/src/core/features/fileuploader/fileuploader.module.ts b/src/core/features/fileuploader/fileuploader.module.ts index c0774617d..4b9f58e4f 100644 --- a/src/core/features/fileuploader/fileuploader.module.ts +++ b/src/core/features/fileuploader/fileuploader.module.ts @@ -13,6 +13,7 @@ // limitations under the License. import { APP_INITIALIZER, NgModule, Type } from '@angular/core'; +import { CoreFileUploaderComponentsModule } from '@features/fileuploader/components/components.module'; import { CoreFileUploaderProvider } from './services/fileuploader'; import { CoreFileUploaderDelegate, CoreFileUploaderDelegateService } from './services/fileuploader-delegate'; @@ -30,6 +31,9 @@ export const CORE_FILEUPLOADER_SERVICES: Type[] = [ ]; @NgModule({ + imports: [ + CoreFileUploaderComponentsModule, + ], providers: [ { provide: APP_INITIALIZER, diff --git a/src/core/features/fileuploader/lang.json b/src/core/features/fileuploader/lang.json index 22d14df4a..588b497ee 100644 --- a/src/core/features/fileuploader/lang.json +++ b/src/core/features/fileuploader/lang.json @@ -1,9 +1,11 @@ { "addfiletext": "Add file", "audio": "Audio", + "audiotitle": "Record audio", "camera": "Camera", "confirmuploadfile": "You are about to upload {{size}}. Are you sure you want to continue?", "confirmuploadunknownsize": "It was not possible to calculate the size of the upload. Are you sure you want to continue?", + "discardrecording": "Discard recording", "errorcapturingaudio": "Error capturing audio.", "errorcapturingimage": "Error capturing image.", "errorcapturingvideo": "Error capturing video.", @@ -17,13 +19,20 @@ "filesofthesetypes": "Accepted file types:", "invalidfiletype": "{{$a}} filetype cannot be accepted.", "maxbytesfile": "The file {{$a.file}} is too large. The maximum size you can upload is {{$a.size}}.", + "microphonepermissiondenied": "Permission to access the microphone has been denied.", + "microphonepermissionrestricted": "Microphone access is restricted.", "more": "More", + "pauserecording": "Pause recording", "photoalbums": "Photo albums", "readingfile": "Reading file", "readingfileperc": "Reading file: {{$a}}%", + "resumerecording": "Resume recording", "selectafile": "Select a file", + "startrecording": "Start recording", + "startrecordinginstructions": "Tap to start recording", + "stoprecording": "Stop recording", "uploadafile": "Upload a file", "uploading": "Uploading", "uploadingperc": "Uploading: {{$a}}%", "video": "Video" -} \ No newline at end of file +} diff --git a/src/core/features/fileuploader/services/fileuploader-helper.ts b/src/core/features/fileuploader/services/fileuploader-helper.ts index 47b649a28..cba30e2a7 100644 --- a/src/core/features/fileuploader/services/fileuploader-helper.ts +++ b/src/core/features/fileuploader/services/fileuploader-helper.ts @@ -29,9 +29,14 @@ import { makeSingleton, Translate, Camera, Chooser, ActionSheetController } from import { CoreLogger } from '@singletons/logger'; import { CoreCanceledError } from '@classes/errors/cancelederror'; import { CoreError } from '@classes/errors/error'; -import { CoreFileUploader, CoreFileUploaderProvider, CoreFileUploaderOptions } from './fileuploader'; +import { + CoreFileUploader, + CoreFileUploaderProvider, + CoreFileUploaderOptions, + CoreFileUploaderAudioRecording, +} from './fileuploader'; import { CoreFileUploaderDelegate } from './fileuploader-delegate'; -import { CoreCaptureError } from '@classes/errors/captureerror'; +import { CAPTURE_ERROR_NO_MEDIA_FILES, CoreCaptureError } from '@classes/errors/captureerror'; import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreWSUploadFileResult } from '@services/ws'; import { CoreSites } from '@services/sites'; @@ -466,9 +471,9 @@ export class CoreFileUploaderHelperProvider { * @param defaultMessage Key of the default message to show. */ protected treatCaptureError(error: CoreCaptureError, defaultMessage: string): void { - // Cancelled or error. If cancelled, error is an object with code = 3. + // Cancelled or error. If cancelled, error is an object with code = CAPTURE_EROR_NO_MEDIA_FILES. if (error) { - if (error.code != 3) { + if (error.code !== CAPTURE_ERROR_NO_MEDIA_FILES) { // Error, not cancelled. this.logger.error('Error while recording audio/video', error); @@ -514,7 +519,7 @@ export class CoreFileUploaderHelperProvider { } return new CoreError(error); - } else if ('code' in error && error.code == 3) { + } else if ('code' in error && error.code === CAPTURE_ERROR_NO_MEDIA_FILES) { throw new CoreCanceledError(); } else { throw error; @@ -539,34 +544,22 @@ export class CoreFileUploaderHelperProvider { ): Promise { this.logger.debug('Trying to record a ' + (isAudio ? 'audio' : 'video') + ' file'); - // The mimetypes param is only for browser, the Cordova plugin doesn't support it. - const captureOptions = { limit: 1, mimetypes: mimetypes }; - let media: MediaFile; + let media: MediaFile | CoreFileUploaderAudioRecording; try { - const medias = isAudio ? await CoreFileUploader.captureAudio(captureOptions) : - await CoreFileUploader.captureVideo(captureOptions); + const medias = isAudio + ? await CoreFileUploader.captureAudio() + : await CoreFileUploader.captureVideo({ limit: 1 }); media = medias[0]; // We used limit 1, we only want 1 media. } catch (error) { + const defaultError = isAudio ? 'core.fileuploader.errorcapturingaudio' : 'core.fileuploader.errorcapturingvideo'; - if (isAudio && this.isNoAppError(error) && CorePlatform.isMobile()) { - // No app to record audio, fallback to capture it ourselves. - try { - media = await CoreFileUploader.captureAudioInApp(); - } catch (error) { - throw this.treatCaptureError(error, 'core.fileuploader.errorcapturingaudio'); // Throw the right error. - } - - } else { - const defaultError = isAudio ? 'core.fileuploader.errorcapturingaudio' : 'core.fileuploader.errorcapturingvideo'; - - throw this.treatCaptureError(error, defaultError); // Throw the right error. - } + throw this.treatCaptureError(error, defaultError); // Throw the right error. } let path = media.fullPath; - const error = CoreFileUploader.isInvalidMimetype(mimetypes, path); // Verify that the mimetype is supported. + const error = CoreFileUploader.isInvalidMimetype(mimetypes, media.fullPath); if (error) { throw new Error(error); @@ -773,7 +766,6 @@ export class CoreFileUploaderHelperProvider { options: CoreFileUploaderOptions, siteId?: string, ): Promise { - const errorStr = Translate.instant('core.error'); const retryStr = Translate.instant('core.retry'); const uploadingStr = Translate.instant('core.fileuploader.uploading'); diff --git a/src/core/features/fileuploader/services/fileuploader.ts b/src/core/features/fileuploader/services/fileuploader.ts index f40ee7490..1a10f6de6 100644 --- a/src/core/features/fileuploader/services/fileuploader.ts +++ b/src/core/features/fileuploader/services/fileuploader.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { CameraOptions } from '@ionic-native/camera/ngx'; import { FileEntry } from '@ionic-native/file/ngx'; -import { MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture/ngx'; +import { MediaFile, CaptureError, CaptureVideoOptions } from '@ionic-native/media-capture/ngx'; import { Subject } from 'rxjs'; import { CoreFile, CoreFileProvider } from '@services/file'; @@ -25,14 +25,15 @@ import { CoreMimetypeUtils } from '@services/utils/mimetype'; import { CoreTimeUtils } from '@services/utils/time'; import { CoreUtils } from '@services/utils/utils'; import { CoreWSFile, CoreWSFileUploadOptions, CoreWSUploadFileResult } from '@services/ws'; -import { makeSingleton, Translate, MediaCapture, ModalController, Camera } from '@singletons'; +import { makeSingleton, Translate, MediaCapture, Camera } from '@singletons'; import { CoreLogger } from '@singletons/logger'; -import { CoreEmulatorCaptureMediaComponent } from '@features/emulator/components/capture-media/capture-media'; import { CoreError } from '@classes/errors/error'; import { CoreSite } from '@classes/site'; import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; import { CorePath } from '@singletons/path'; import { CorePlatform } from '@services/platform'; +import { CoreFileUploaderAudioRecorderComponent } from '@features/fileuploader/components/audio-recorder/audio-recorder.component'; +import { CoreModals } from '@services/modals'; /** * File upload options. @@ -132,14 +133,21 @@ export class CoreFileUploaderProvider { /** * Start the audio recorder application and return information about captured audio clip files. * - * @param options Options. * @returns Promise resolved with the result. */ - async captureAudio(options: CaptureAudioOptions): Promise { + async captureAudio(): Promise { this.onAudioCapture.next(true); try { - return await MediaCapture.captureAudio(options); + if (!CorePlatform.supportsMediaCapture() || !CorePlatform.supportsWebAssembly()) { + const media = await MediaCapture.captureAudio({ limit: 1 }); + + return media; + } + + const recording = await this.captureAudioInApp(); + + return [recording]; } finally { this.onAudioCapture.next(false); } @@ -150,27 +158,14 @@ export class CoreFileUploaderProvider { * * @returns Promise resolved with the file. */ - async captureAudioInApp(): Promise { - const params = { - type: 'audio', - }; + async captureAudioInApp(): Promise { + const recording = await CoreModals.openSheet(CoreFileUploaderAudioRecorderComponent); - const modal = await ModalController.create({ - component: CoreEmulatorCaptureMediaComponent, - cssClass: 'core-modal-fullscreen', - componentProps: params, - backdropDismiss: false, - }); - - await modal.present(); - - const result = await modal.onWillDismiss(); - - if (result.role == 'success') { - return result.data[0]; - } else { - throw result.data; + if (!recording) { + throw new Error('Recording missing from audio capture'); } + + return recording; } /** @@ -335,7 +330,7 @@ export class CoreFileUploaderProvider { * @param mediaFile File object to upload. * @returns Options. */ - getMediaUploadOptions(mediaFile: MediaFile): CoreFileUploaderOptions { + getMediaUploadOptions(mediaFile: MediaFile | CoreFileUploaderAudioRecording): CoreFileUploaderOptions { const options: CoreFileUploaderOptions = {}; let filename = mediaFile.name; @@ -781,3 +776,9 @@ export type CoreFileUploaderTypeListInfoEntry = { name?: string; extlist: string; }; + +export type CoreFileUploaderAudioRecording = { + name: string; + fullPath: string; + type: string; +}; diff --git a/src/core/features/fileuploader/utils/worker-messages.ts b/src/core/features/fileuploader/utils/worker-messages.ts new file mode 100644 index 000000000..f31572a8c --- /dev/null +++ b/src/core/features/fileuploader/utils/worker-messages.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Mp3WorkerConfig } from 'mp3-mediarecorder/types/config.type'; + +export interface InitAudioEncoderMessage { + name: 'init-audio-encoder'; + config: Mp3WorkerConfig; +} + +/** + * Check whether the given data is an init audio encoder message. + * + * @param message Message. + * @returns Whether the data is an init audio encoder message. + */ +export function isInitAudioEncoderMessage(message: unknown): message is InitAudioEncoderMessage { + return typeof message === 'object' + && message !== null + && 'name' in message + && message['name'] === 'init-audio-encoder'; +} + +/** + * Create an init audio encoder message. + * + * @param config Audio encoder config. + * @returns Message. + */ +export function initAudioEncoderMessage(config: Mp3WorkerConfig): InitAudioEncoderMessage { + return { + name: 'init-audio-encoder', + config, + }; +} diff --git a/src/core/pipes/seconds-to-hms.ts b/src/core/pipes/seconds-to-hms.ts index 91115ea62..4a14d02b1 100644 --- a/src/core/pipes/seconds-to-hms.ts +++ b/src/core/pipes/seconds-to-hms.ts @@ -40,7 +40,7 @@ export class CoreSecondsToHMSPipe implements PipeTransform { * @param seconds Number of seconds. * @returns Formatted seconds. */ - transform(seconds: string | number): string { + transform(seconds: string | number, showHours: boolean = true): string { if (!seconds || seconds < 0) { seconds = 0; } else if (typeof seconds == 'string') { @@ -62,8 +62,9 @@ export class CoreSecondsToHMSPipe implements PipeTransform { const minutes = Math.floor(seconds / CoreConstants.SECONDS_MINUTE); seconds -= minutes * CoreConstants.SECONDS_MINUTE; - return CoreTextUtils.twoDigits(hours) + ':' + CoreTextUtils.twoDigits(minutes) + ':' + - CoreTextUtils.twoDigits(seconds); + return showHours + ? CoreTextUtils.twoDigits(hours) + ':' + CoreTextUtils.twoDigits(minutes) + ':' + CoreTextUtils.twoDigits(seconds) + : CoreTextUtils.twoDigits(minutes) + ':' + CoreTextUtils.twoDigits(seconds); } } diff --git a/src/core/services/platform.ts b/src/core/services/platform.ts index c00787d1b..87e40fdfb 100644 --- a/src/core/services/platform.ts +++ b/src/core/services/platform.ts @@ -80,6 +80,33 @@ export class CorePlatformService extends Platform { return this.is('cordova'); } + /** + * Check whether the device is configured to reduce motion. + * + * @returns Whether the device is configured to reduce motion. + */ + prefersReducedMotion(): boolean { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } + + /** + * Checks whether media capture is supported. + * + * @returns Whether media capture is supported. + */ + supportsMediaCapture(): boolean { + return 'mediaDevices' in navigator; + } + + /** + * Checks whether web assembly is supported. + * + * @returns Whether web assembly is supported. + */ + supportsWebAssembly(): boolean { + return 'WebAssembly' in window; + } + } export const CorePlatform = makeSingleton(CorePlatformService); diff --git a/src/core/utils/types.d.ts b/src/core/utils/types.d.ts index 469d87f0a..0cfbe30e7 100644 --- a/src/core/utils/types.d.ts +++ b/src/core/utils/types.d.ts @@ -12,6 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +/** + * Helper type to infer class instance types. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type Constructor = { new(...args: any[]): T }; + /** * Helper type to flatten complex types. */ diff --git a/tsconfig.worker.json b/tsconfig.worker.json new file mode 100644 index 000000000..1c8cc55ed --- /dev/null +++ b/tsconfig.worker.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/worker", + "lib": [ + "es2018", + "webworker" + ], + "types": [] + }, + "include": [ + "src/**/*.worker.ts" + ] +}