MOBILE-2314 fileuploader: Refactor audio recording

main
Noel De Martin 2023-02-21 12:18:43 +01:00
parent bb64922a14
commit d91f2ed51a
22 changed files with 765 additions and 57 deletions

View File

@ -42,7 +42,8 @@
"input": "src/theme/theme.scss"
}
],
"scripts": []
"scripts": [],
"webWorkerTsConfig": "tsconfig.worker.json"
},
"configurations": {
"production": {

19
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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',

View File

@ -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",

View File

@ -14,6 +14,8 @@
import { CoreError } from './error';
export const CAPTURE_ERROR_NO_MEDIA_FILES = 3;
/**
* Capture error.
*/

View File

@ -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<void>; // Will emit an event when the endTime is reached.
time = 0;

View File

@ -1 +1 @@
<span role="timer">{{ time / 1000 | coreSecondsToHMS }}</span>
<span role="timer">{{ time / 1000 | coreSecondsToHMS:hours }}</span>

View File

@ -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);
};
});
}

View File

@ -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>

View File

@ -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);
}
}
}
}
}

View File

@ -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);

View File

@ -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 {}

View File

@ -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<unknown>[] = [
];
@NgModule({
imports: [
CoreFileUploaderComponentsModule,
],
providers: [
{
provide: APP_INITIALIZER,

View File

@ -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,11 +19,18 @@
"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}}%",

View File

@ -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<CoreWSUploadFileResult | FileEntry> {
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<CoreWSUploadFileResult> {
const errorStr = Translate.instant('core.error');
const retryStr = Translate.instant('core.retry');
const uploadingStr = Translate.instant('core.fileuploader.uploading');

View File

@ -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<MediaFile[] | CaptureError> {
async captureAudio(): Promise<CoreFileUploaderAudioRecording[] | MediaFile[] | CaptureError> {
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<MediaFile> {
const params = {
type: 'audio',
};
async captureAudioInApp(): Promise<CoreFileUploaderAudioRecording> {
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;
};

View File

@ -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,
};
}

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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<T> = { new(...args: any[]): T };
/**
* Helper type to flatten complex types.
*/

View File

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/worker",
"lib": [
"es2018",
"webworker"
],
"types": []
},
"include": [
"src/**/*.worker.ts"
]
}