MOBILE-2314 fileuploader: Refactor audio recording
parent
bb64922a14
commit
d91f2ed51a
|
@ -42,7 +42,8 @@
|
||||||
"input": "src/theme/theme.scss"
|
"input": "src/theme/theme.scss"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": [],
|
||||||
|
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
|
|
@ -16854,6 +16854,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
|
"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": {
|
"eventemitter3": {
|
||||||
"version": "4.0.7",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
"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": {
|
"mpd-parser": {
|
||||||
"version": "0.22.1",
|
"version": "0.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.22.1.tgz",
|
"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": {
|
"w3c-hr-time": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
||||||
|
|
|
@ -122,6 +122,7 @@
|
||||||
"mathjax": "2.7.9",
|
"mathjax": "2.7.9",
|
||||||
"moment": "2.29.4",
|
"moment": "2.29.4",
|
||||||
"moment-timezone": "0.5.38",
|
"moment-timezone": "0.5.38",
|
||||||
|
"mp3-mediarecorder": "^4.0.5",
|
||||||
"nl.kingsquare.cordova.background-audio": "1.0.1",
|
"nl.kingsquare.cordova.background-audio": "1.0.1",
|
||||||
"ogv": "1.8.9",
|
"ogv": "1.8.9",
|
||||||
"rxjs": "6.5.5",
|
"rxjs": "6.5.5",
|
||||||
|
|
|
@ -27,6 +27,7 @@ const ASSETS = {
|
||||||
'/node_modules/mathjax/jax/output/SVG': '/lib/mathjax/jax/output/SVG',
|
'/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/jax/output/PreviewHTML': '/lib/mathjax/jax/output/PreviewHTML',
|
||||||
'/node_modules/mathjax/localization': '/lib/mathjax/localization',
|
'/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',
|
'/src/core/features/h5p/assets': '/lib/h5p',
|
||||||
'/node_modules/ogv/dist': '/lib/ogv',
|
'/node_modules/ogv/dist': '/lib/ogv',
|
||||||
'/node_modules/video.js/dist/video-js.min.css': '/lib/video.js/video-js.min.css',
|
'/node_modules/video.js/dist/video-js.min.css': '/lib/video.js/video-js.min.css',
|
||||||
|
|
|
@ -1742,9 +1742,11 @@
|
||||||
"core.filenotfound": "resource",
|
"core.filenotfound": "resource",
|
||||||
"core.fileuploader.addfiletext": "repository",
|
"core.fileuploader.addfiletext": "repository",
|
||||||
"core.fileuploader.audio": "local_moodlemobileapp",
|
"core.fileuploader.audio": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.audiotitle": "tiny_recordrtc",
|
||||||
"core.fileuploader.camera": "local_moodlemobileapp",
|
"core.fileuploader.camera": "local_moodlemobileapp",
|
||||||
"core.fileuploader.confirmuploadfile": "local_moodlemobileapp",
|
"core.fileuploader.confirmuploadfile": "local_moodlemobileapp",
|
||||||
"core.fileuploader.confirmuploadunknownsize": "local_moodlemobileapp",
|
"core.fileuploader.confirmuploadunknownsize": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.discardrecording": "local_moodlemobileapp",
|
||||||
"core.fileuploader.errorcapturingaudio": "local_moodlemobileapp",
|
"core.fileuploader.errorcapturingaudio": "local_moodlemobileapp",
|
||||||
"core.fileuploader.errorcapturingimage": "local_moodlemobileapp",
|
"core.fileuploader.errorcapturingimage": "local_moodlemobileapp",
|
||||||
"core.fileuploader.errorcapturingvideo": "local_moodlemobileapp",
|
"core.fileuploader.errorcapturingvideo": "local_moodlemobileapp",
|
||||||
|
@ -1758,11 +1760,18 @@
|
||||||
"core.fileuploader.fileuploaded": "local_moodlemobileapp",
|
"core.fileuploader.fileuploaded": "local_moodlemobileapp",
|
||||||
"core.fileuploader.invalidfiletype": "repository",
|
"core.fileuploader.invalidfiletype": "repository",
|
||||||
"core.fileuploader.maxbytesfile": "local_moodlemobileapp",
|
"core.fileuploader.maxbytesfile": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.microphonepermissiondenied": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.microphonepermissionrestricted": "local_moodlemobileapp",
|
||||||
"core.fileuploader.more": "data",
|
"core.fileuploader.more": "data",
|
||||||
|
"core.fileuploader.pauserecording": "local_moodlemobileapp",
|
||||||
"core.fileuploader.photoalbums": "local_moodlemobileapp",
|
"core.fileuploader.photoalbums": "local_moodlemobileapp",
|
||||||
"core.fileuploader.readingfile": "local_moodlemobileapp",
|
"core.fileuploader.readingfile": "local_moodlemobileapp",
|
||||||
"core.fileuploader.readingfileperc": "local_moodlemobileapp",
|
"core.fileuploader.readingfileperc": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.resumerecording": "local_moodlemobileapp",
|
||||||
"core.fileuploader.selectafile": "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.uploadafile": "local_moodlemobileapp",
|
||||||
"core.fileuploader.uploading": "local_moodlemobileapp",
|
"core.fileuploader.uploading": "local_moodlemobileapp",
|
||||||
"core.fileuploader.uploadingperc": "local_moodlemobileapp",
|
"core.fileuploader.uploadingperc": "local_moodlemobileapp",
|
||||||
|
|
|
@ -14,6 +14,8 @@
|
||||||
|
|
||||||
import { CoreError } from './error';
|
import { CoreError } from './error';
|
||||||
|
|
||||||
|
export const CAPTURE_ERROR_NO_MEDIA_FILES = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture error.
|
* Capture error.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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() startTime = 0; // Number of milliseconds to put in the chrono before starting.
|
||||||
@Input() endTime?: number; // Number of milliseconds to stop the chrono.
|
@Input() endTime?: number; // Number of milliseconds to stop the chrono.
|
||||||
@Input() reset?: boolean; // Set it to true to reset the chrono.
|
@Input() reset?: boolean; // Set it to true to reset the chrono.
|
||||||
|
@Input() hours = true;
|
||||||
@Output() onEnd: EventEmitter<void>; // Will emit an event when the endTime is reached.
|
@Output() onEnd: EventEmitter<void>; // Will emit an event when the endTime is reached.
|
||||||
|
|
||||||
time = 0;
|
time = 0;
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
<span role="timer">{{ time / 1000 | coreSecondsToHMS }}</span>
|
<span role="timer">{{ time / 1000 | coreSecondsToHMS:hours }}</span>
|
||||||
|
|
|
@ -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<CoreFileUploaderAudioRecording>
|
||||||
|
implements OnDestroy {
|
||||||
|
|
||||||
|
recordingUrl$: Observable<SafeUrl | null>;
|
||||||
|
histogramAnalyzer$: Observable<AnalyserNode | null>;
|
||||||
|
status$: Observable<'recording-ongoing' | 'recording-paused' | 'done' | 'empty'>;
|
||||||
|
|
||||||
|
protected recording: AudioRecording | null;
|
||||||
|
protected media$: BehaviorSubject<AudioRecorderMedia | null>;
|
||||||
|
protected recording$: Observable<AudioRecording | null>;
|
||||||
|
|
||||||
|
constructor(elementRef: ElementRef<HTMLElement>) {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
this.close(new CoreCaptureError(CAPTURE_ERROR_NO_MEDIA_FILES));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss the modal with the current recording as a result.
|
||||||
|
*/
|
||||||
|
async submit(): Promise<void> {
|
||||||
|
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<AudioRecorderMedia> {
|
||||||
|
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<void> {
|
||||||
|
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<AudioRecorderMedia | null, AudioRecording | null> {
|
||||||
|
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<AudioRecorderMedia | null, RecordingState> {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
<header>
|
||||||
|
<h1>{{ 'core.fileuploader.audiotitle' | translate }}</h1>
|
||||||
|
|
||||||
|
<ion-button shape="round" fill="clear" [attr.aria-label]="'core.close' | translate" (click)="cancel()">
|
||||||
|
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<ng-container *ngIf="(status$ | async) as status">
|
||||||
|
<div *ngIf="status === 'empty'" class="core-audio-recorder--wrapper">
|
||||||
|
<p>{{ 'core.fileuploader.startrecordinginstructions' | translate }}</p>
|
||||||
|
|
||||||
|
<ion-button shape="round" color="danger" [attr.aria-label]="'core.fileuploader.startrecording' | translate"
|
||||||
|
(click)="startRecording()">
|
||||||
|
<ion-icon slot="icon-only" name="fas-microphone"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="status.startsWith('recording')" class="core-audio-recorder--wrapper">
|
||||||
|
<core-audio-histogram *ngIf="(histogramAnalyzer$ | async) as analyser" [analyser]="analyser"
|
||||||
|
[paused]="status !== 'recording-ongoing'">
|
||||||
|
</core-audio-histogram>
|
||||||
|
|
||||||
|
<div class="core-audio-recorder--controls">
|
||||||
|
<div class="core-audio-recorder--control chrono">
|
||||||
|
<div *ngIf="status === 'recording-ongoing'" class="core-audio-recorder--recording-marker"></div>
|
||||||
|
<core-chrono [class.recording]="status === 'recording-ongoing'" [running]="status === 'recording-ongoing'" [hours]="false">
|
||||||
|
</core-chrono>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="core-audio-recorder--control">
|
||||||
|
<ion-button *ngIf="status === 'recording-ongoing'" shape="round" fill="clear"
|
||||||
|
[attr.aria-label]="'core.fileuploader.pauserecording' | translate" (click)="pauseRecording()">
|
||||||
|
<ion-icon slot="icon-only" name="fas-pause-circle"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ion-button *ngIf="status === 'recording-paused'" [attr.aria-label]="'core.fileuploader.resumerecording' | translate"
|
||||||
|
shape="round" fill="clear" color="danger" (click)="resumeRecording()">
|
||||||
|
<ion-icon slot="icon-only" name="fas-microphone"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="core-audio-recorder--control">
|
||||||
|
<ion-button shape="round" fill="clear" [attr.aria-label]="'core.fileuploader.stoprecording' | translate"
|
||||||
|
(click)="stopRecording()">
|
||||||
|
<ion-icon slot="icon-only" name="fa-check"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="status === 'done'" class="core-audio-recorder--wrapper">
|
||||||
|
<audio *ngIf="(recordingUrl$ | async) as recordingUrl" controls controlsList="nodownload">
|
||||||
|
<source [src]="recordingUrl" />
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
<div class="core-audio-recorder--controls">
|
||||||
|
<div class="core-audio-recorder--control">
|
||||||
|
<ion-button shape="round" fill="clear" color="danger" [attr.aria-label]="'core.fileuploader.discardrecording' | translate"
|
||||||
|
(click)="discardRecording()">
|
||||||
|
<ion-icon slot="icon-only" name="fas-trash"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="core-audio-recorder--control">
|
||||||
|
<ion-button (click)="submit()">
|
||||||
|
{{ 'core.save' | translate }}
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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);
|
|
@ -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 {}
|
|
@ -13,6 +13,7 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { APP_INITIALIZER, NgModule, Type } from '@angular/core';
|
import { APP_INITIALIZER, NgModule, Type } from '@angular/core';
|
||||||
|
import { CoreFileUploaderComponentsModule } from '@features/fileuploader/components/components.module';
|
||||||
|
|
||||||
import { CoreFileUploaderProvider } from './services/fileuploader';
|
import { CoreFileUploaderProvider } from './services/fileuploader';
|
||||||
import { CoreFileUploaderDelegate, CoreFileUploaderDelegateService } from './services/fileuploader-delegate';
|
import { CoreFileUploaderDelegate, CoreFileUploaderDelegateService } from './services/fileuploader-delegate';
|
||||||
|
@ -30,6 +31,9 @@ export const CORE_FILEUPLOADER_SERVICES: Type<unknown>[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
CoreFileUploaderComponentsModule,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: APP_INITIALIZER,
|
provide: APP_INITIALIZER,
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
{
|
{
|
||||||
"addfiletext": "Add file",
|
"addfiletext": "Add file",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
|
"audiotitle": "Record audio",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"confirmuploadfile": "You are about to upload {{size}}. Are you sure you want to continue?",
|
"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?",
|
"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.",
|
"errorcapturingaudio": "Error capturing audio.",
|
||||||
"errorcapturingimage": "Error capturing image.",
|
"errorcapturingimage": "Error capturing image.",
|
||||||
"errorcapturingvideo": "Error capturing video.",
|
"errorcapturingvideo": "Error capturing video.",
|
||||||
|
@ -17,11 +19,18 @@
|
||||||
"filesofthesetypes": "Accepted file types:",
|
"filesofthesetypes": "Accepted file types:",
|
||||||
"invalidfiletype": "{{$a}} filetype cannot be accepted.",
|
"invalidfiletype": "{{$a}} filetype cannot be accepted.",
|
||||||
"maxbytesfile": "The file {{$a.file}} is too large. The maximum size you can upload is {{$a.size}}.",
|
"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",
|
"more": "More",
|
||||||
|
"pauserecording": "Pause recording",
|
||||||
"photoalbums": "Photo albums",
|
"photoalbums": "Photo albums",
|
||||||
"readingfile": "Reading file",
|
"readingfile": "Reading file",
|
||||||
"readingfileperc": "Reading file: {{$a}}%",
|
"readingfileperc": "Reading file: {{$a}}%",
|
||||||
|
"resumerecording": "Resume recording",
|
||||||
"selectafile": "Select a file",
|
"selectafile": "Select a file",
|
||||||
|
"startrecording": "Start recording",
|
||||||
|
"startrecordinginstructions": "Tap to start recording",
|
||||||
|
"stoprecording": "Stop recording",
|
||||||
"uploadafile": "Upload a file",
|
"uploadafile": "Upload a file",
|
||||||
"uploading": "Uploading",
|
"uploading": "Uploading",
|
||||||
"uploadingperc": "Uploading: {{$a}}%",
|
"uploadingperc": "Uploading: {{$a}}%",
|
||||||
|
|
|
@ -29,9 +29,14 @@ import { makeSingleton, Translate, Camera, Chooser, ActionSheetController } from
|
||||||
import { CoreLogger } from '@singletons/logger';
|
import { CoreLogger } from '@singletons/logger';
|
||||||
import { CoreCanceledError } from '@classes/errors/cancelederror';
|
import { CoreCanceledError } from '@classes/errors/cancelederror';
|
||||||
import { CoreError } from '@classes/errors/error';
|
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 { 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 { CoreIonLoadingElement } from '@classes/ion-loading';
|
||||||
import { CoreWSUploadFileResult } from '@services/ws';
|
import { CoreWSUploadFileResult } from '@services/ws';
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
|
@ -466,9 +471,9 @@ export class CoreFileUploaderHelperProvider {
|
||||||
* @param defaultMessage Key of the default message to show.
|
* @param defaultMessage Key of the default message to show.
|
||||||
*/
|
*/
|
||||||
protected treatCaptureError(error: CoreCaptureError, defaultMessage: string): void {
|
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) {
|
||||||
if (error.code != 3) {
|
if (error.code !== CAPTURE_ERROR_NO_MEDIA_FILES) {
|
||||||
// Error, not cancelled.
|
// Error, not cancelled.
|
||||||
this.logger.error('Error while recording audio/video', error);
|
this.logger.error('Error while recording audio/video', error);
|
||||||
|
|
||||||
|
@ -514,7 +519,7 @@ export class CoreFileUploaderHelperProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CoreError(error);
|
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();
|
throw new CoreCanceledError();
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
|
@ -539,34 +544,22 @@ export class CoreFileUploaderHelperProvider {
|
||||||
): Promise<CoreWSUploadFileResult | FileEntry> {
|
): Promise<CoreWSUploadFileResult | FileEntry> {
|
||||||
this.logger.debug('Trying to record a ' + (isAudio ? 'audio' : 'video') + ' file');
|
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.
|
let media: MediaFile | CoreFileUploaderAudioRecording;
|
||||||
const captureOptions = { limit: 1, mimetypes: mimetypes };
|
|
||||||
let media: MediaFile;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const medias = isAudio ? await CoreFileUploader.captureAudio(captureOptions) :
|
const medias = isAudio
|
||||||
await CoreFileUploader.captureVideo(captureOptions);
|
? await CoreFileUploader.captureAudio()
|
||||||
|
: await CoreFileUploader.captureVideo({ limit: 1 });
|
||||||
|
|
||||||
media = medias[0]; // We used limit 1, we only want 1 media.
|
media = medias[0]; // We used limit 1, we only want 1 media.
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const defaultError = isAudio ? 'core.fileuploader.errorcapturingaudio' : 'core.fileuploader.errorcapturingvideo';
|
||||||
|
|
||||||
if (isAudio && this.isNoAppError(error) && CorePlatform.isMobile()) {
|
throw this.treatCaptureError(error, defaultError); // Throw the right error.
|
||||||
// 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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = media.fullPath;
|
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) {
|
if (error) {
|
||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
|
@ -773,7 +766,6 @@ export class CoreFileUploaderHelperProvider {
|
||||||
options: CoreFileUploaderOptions,
|
options: CoreFileUploaderOptions,
|
||||||
siteId?: string,
|
siteId?: string,
|
||||||
): Promise<CoreWSUploadFileResult> {
|
): Promise<CoreWSUploadFileResult> {
|
||||||
|
|
||||||
const errorStr = Translate.instant('core.error');
|
const errorStr = Translate.instant('core.error');
|
||||||
const retryStr = Translate.instant('core.retry');
|
const retryStr = Translate.instant('core.retry');
|
||||||
const uploadingStr = Translate.instant('core.fileuploader.uploading');
|
const uploadingStr = Translate.instant('core.fileuploader.uploading');
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { CameraOptions } from '@ionic-native/camera/ngx';
|
import { CameraOptions } from '@ionic-native/camera/ngx';
|
||||||
import { FileEntry } from '@ionic-native/file/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 { Subject } from 'rxjs';
|
||||||
|
|
||||||
import { CoreFile, CoreFileProvider } from '@services/file';
|
import { CoreFile, CoreFileProvider } from '@services/file';
|
||||||
|
@ -25,14 +25,15 @@ import { CoreMimetypeUtils } from '@services/utils/mimetype';
|
||||||
import { CoreTimeUtils } from '@services/utils/time';
|
import { CoreTimeUtils } from '@services/utils/time';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { CoreWSFile, CoreWSFileUploadOptions, CoreWSUploadFileResult } from '@services/ws';
|
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 { CoreLogger } from '@singletons/logger';
|
||||||
import { CoreEmulatorCaptureMediaComponent } from '@features/emulator/components/capture-media/capture-media';
|
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreError } from '@classes/errors/error';
|
||||||
import { CoreSite } from '@classes/site';
|
import { CoreSite } from '@classes/site';
|
||||||
import { CoreFileEntry, CoreFileHelper } from '@services/file-helper';
|
import { CoreFileEntry, CoreFileHelper } from '@services/file-helper';
|
||||||
import { CorePath } from '@singletons/path';
|
import { CorePath } from '@singletons/path';
|
||||||
import { CorePlatform } from '@services/platform';
|
import { CorePlatform } from '@services/platform';
|
||||||
|
import { CoreFileUploaderAudioRecorderComponent } from '@features/fileuploader/components/audio-recorder/audio-recorder.component';
|
||||||
|
import { CoreModals } from '@services/modals';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File upload options.
|
* File upload options.
|
||||||
|
@ -132,14 +133,21 @@ export class CoreFileUploaderProvider {
|
||||||
/**
|
/**
|
||||||
* Start the audio recorder application and return information about captured audio clip files.
|
* Start the audio recorder application and return information about captured audio clip files.
|
||||||
*
|
*
|
||||||
* @param options Options.
|
|
||||||
* @returns Promise resolved with the result.
|
* @returns Promise resolved with the result.
|
||||||
*/
|
*/
|
||||||
async captureAudio(options: CaptureAudioOptions): Promise<MediaFile[] | CaptureError> {
|
async captureAudio(): Promise<CoreFileUploaderAudioRecording[] | MediaFile[] | CaptureError> {
|
||||||
this.onAudioCapture.next(true);
|
this.onAudioCapture.next(true);
|
||||||
|
|
||||||
try {
|
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 {
|
} finally {
|
||||||
this.onAudioCapture.next(false);
|
this.onAudioCapture.next(false);
|
||||||
}
|
}
|
||||||
|
@ -150,27 +158,14 @@ export class CoreFileUploaderProvider {
|
||||||
*
|
*
|
||||||
* @returns Promise resolved with the file.
|
* @returns Promise resolved with the file.
|
||||||
*/
|
*/
|
||||||
async captureAudioInApp(): Promise<MediaFile> {
|
async captureAudioInApp(): Promise<CoreFileUploaderAudioRecording> {
|
||||||
const params = {
|
const recording = await CoreModals.openSheet(CoreFileUploaderAudioRecorderComponent);
|
||||||
type: 'audio',
|
|
||||||
};
|
|
||||||
|
|
||||||
const modal = await ModalController.create({
|
if (!recording) {
|
||||||
component: CoreEmulatorCaptureMediaComponent,
|
throw new Error('Recording missing from audio capture');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return recording;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -335,7 +330,7 @@ export class CoreFileUploaderProvider {
|
||||||
* @param mediaFile File object to upload.
|
* @param mediaFile File object to upload.
|
||||||
* @returns Options.
|
* @returns Options.
|
||||||
*/
|
*/
|
||||||
getMediaUploadOptions(mediaFile: MediaFile): CoreFileUploaderOptions {
|
getMediaUploadOptions(mediaFile: MediaFile | CoreFileUploaderAudioRecording): CoreFileUploaderOptions {
|
||||||
const options: CoreFileUploaderOptions = {};
|
const options: CoreFileUploaderOptions = {};
|
||||||
let filename = mediaFile.name;
|
let filename = mediaFile.name;
|
||||||
|
|
||||||
|
@ -781,3 +776,9 @@ export type CoreFileUploaderTypeListInfoEntry = {
|
||||||
name?: string;
|
name?: string;
|
||||||
extlist: string;
|
extlist: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CoreFileUploaderAudioRecording = {
|
||||||
|
name: string;
|
||||||
|
fullPath: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -40,7 +40,7 @@ export class CoreSecondsToHMSPipe implements PipeTransform {
|
||||||
* @param seconds Number of seconds.
|
* @param seconds Number of seconds.
|
||||||
* @returns Formatted seconds.
|
* @returns Formatted seconds.
|
||||||
*/
|
*/
|
||||||
transform(seconds: string | number): string {
|
transform(seconds: string | number, showHours: boolean = true): string {
|
||||||
if (!seconds || seconds < 0) {
|
if (!seconds || seconds < 0) {
|
||||||
seconds = 0;
|
seconds = 0;
|
||||||
} else if (typeof seconds == 'string') {
|
} else if (typeof seconds == 'string') {
|
||||||
|
@ -62,8 +62,9 @@ export class CoreSecondsToHMSPipe implements PipeTransform {
|
||||||
const minutes = Math.floor(seconds / CoreConstants.SECONDS_MINUTE);
|
const minutes = Math.floor(seconds / CoreConstants.SECONDS_MINUTE);
|
||||||
seconds -= minutes * CoreConstants.SECONDS_MINUTE;
|
seconds -= minutes * CoreConstants.SECONDS_MINUTE;
|
||||||
|
|
||||||
return CoreTextUtils.twoDigits(hours) + ':' + CoreTextUtils.twoDigits(minutes) + ':' +
|
return showHours
|
||||||
CoreTextUtils.twoDigits(seconds);
|
? CoreTextUtils.twoDigits(hours) + ':' + CoreTextUtils.twoDigits(minutes) + ':' + CoreTextUtils.twoDigits(seconds)
|
||||||
|
: CoreTextUtils.twoDigits(minutes) + ':' + CoreTextUtils.twoDigits(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,6 +80,33 @@ export class CorePlatformService extends Platform {
|
||||||
return this.is('cordova');
|
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);
|
export const CorePlatform = makeSingleton(CorePlatformService);
|
||||||
|
|
|
@ -12,6 +12,12 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper type to infer class instance types.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type Constructor<T> = { new(...args: any[]): T };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper type to flatten complex types.
|
* Helper type to flatten complex types.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/worker",
|
||||||
|
"lib": [
|
||||||
|
"es2018",
|
||||||
|
"webworker"
|
||||||
|
],
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.worker.ts"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue