Merge pull request #3556 from NoelDeMartin/MOBILE-2314

MOBILE-2314: Audio Recorder improvements
main
Dani Palou 2023-02-22 11:52:40 +01:00 committed by GitHub
commit 4cb9a6640c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
52 changed files with 1396 additions and 448 deletions

View File

@ -6,6 +6,8 @@ WORKDIR /app
# Prepare node dependencies
RUN apt-get update && apt-get install libsecret-1-0 -y
COPY package*.json ./
COPY patches ./patches
RUN echo "unsafe-perm=true" > ./.npmrc
RUN npm ci --no-audit
# Build source

View File

@ -42,7 +42,8 @@
"input": "src/theme/theme.scss"
}
],
"scripts": []
"scripts": [],
"webWorkerTsConfig": "tsconfig.worker.json"
},
"configurations": {
"production": {
@ -50,6 +51,10 @@
{
"replace": "src/testing/testing.module.ts",
"with": "src/testing/testing.module.prod.ts"
},
{
"replace": "src/core/features/emulator/emulator.module.ts",
"with": "src/core/features/emulator/emulator.module.prod.ts"
}
],
"optimization": {

View File

@ -196,11 +196,6 @@
<param name="android-package" value="com.adobe.phonegap.push.PushPlugin" />
</feature>
</config-file>
<config-file parent="/*" target="res/xml/config.xml">
<feature name="Media">
<param name="android-package" value="org.apache.cordova.media.AudioHandler" />
</feature>
</config-file>
<config-file parent="/*" target="AndroidManifest.xml">
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
</config-file>

39
package-lock.json generated
View File

@ -4167,21 +4167,6 @@
}
}
},
"@ionic-native/media": {
"version": "5.36.0",
"resolved": "https://registry.npmjs.org/@ionic-native/media/-/media-5.36.0.tgz",
"integrity": "sha512-WIDCeUlX7bCbse/x2Rr7mAIQJnLo18ZWcmsVgSTTBVS7ObU2DBl4ieqRx6y9PAAV+3tNZqMV4JAWDfMiFokpJg==",
"requires": {
"@types/cordova": "^0.0.34"
},
"dependencies": {
"@types/cordova": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz",
"integrity": "sha512-rkiiTuf/z2wTd4RxFOb+clE7PF4AEJU0hsczbUdkHHBtkUmpWQpEddynNfJYKYtZFJKbq4F+brfekt1kx85IZA=="
}
}
},
"@ionic-native/media-capture": {
"version": "5.36.0",
"resolved": "https://registry.npmjs.org/@ionic-native/media-capture/-/media-capture-5.36.0.tgz",
@ -14340,11 +14325,6 @@
"resolved": "https://registry.npmjs.org/cordova-plugin-ionic-keyboard/-/cordova-plugin-ionic-keyboard-2.2.0.tgz",
"integrity": "sha512-yDUG+9ieKVRitq5mGlNxjaZh/MgEhFFIgTIPhqSbUaQ8UuZbawy5mhJAVClqY97q8/rcQtL6dCDa7x2sEtCLcA=="
},
"cordova-plugin-media": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/cordova-plugin-media/-/cordova-plugin-media-5.0.4.tgz",
"integrity": "sha512-mAqincYqOT5gu5LWyfgJu3qmOq+lhLAKhnOZULpG622FvYiHjjfsoJ/fkI55WwI3FIcHeeyhToGvHXBCNJePZg=="
},
"cordova-plugin-media-capture": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/cordova-plugin-media-capture/-/cordova-plugin-media-capture-3.0.3.tgz",
@ -16854,6 +16834,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 +23819,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 +33481,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

@ -24,7 +24,7 @@
"build": "ionic build",
"build:prod": "NODE_ENV=production ionic build --prod",
"build:test": "NODE_ENV=testing ionic build --configuration=testing",
"dev:android": "ionic cordova run android --livereload",
"dev:android": "ionic cordova run android --livereload --external --ssl",
"dev:ios": "ionic cordova run ios",
"prod:android": "NODE_ENV=production ionic cordova run android --prod",
"prod:ios": "NODE_ENV=production ionic cordova run ios --prod",
@ -63,7 +63,6 @@
"@ionic-native/ionic-webview": "5.36.0",
"@ionic-native/keyboard": "5.36.0",
"@ionic-native/local-notifications": "5.36.0",
"@ionic-native/media": "5.36.0",
"@ionic-native/media-capture": "5.36.0",
"@ionic-native/network": "5.36.0",
"@ionic-native/push": "5.36.0",
@ -104,7 +103,6 @@
"cordova-plugin-file": "6.0.2",
"cordova-plugin-geolocation": "4.1.0",
"cordova-plugin-ionic-keyboard": "2.2.0",
"cordova-plugin-media": "5.0.4",
"cordova-plugin-media-capture": "3.0.3",
"cordova-plugin-network-information": "3.0.0",
"cordova-plugin-prevent-override": "1.0.1",
@ -122,6 +120,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",
@ -224,9 +223,6 @@
"ANDROID_SUPPORT_V4_VERSION": "26.+"
},
"cordova-plugin-media-capture": {},
"cordova-plugin-media": {
"KEEP_AVAUDIOSESSION_ALWAYS_ACTIVE": "NO"
},
"cordova-plugin-network-information": {},
"@moodlehq/cordova-plugin-qrscanner": {},
"cordova-plugin-splashscreen": {},

View File

@ -0,0 +1,30 @@
diff --git a/node_modules/event-target-shim/index.d.ts b/node_modules/event-target-shim/index.d.ts
index 7a5bfc7..ba5e7d8 100644
--- a/node_modules/event-target-shim/index.d.ts
+++ b/node_modules/event-target-shim/index.d.ts
@@ -359,7 +359,7 @@ export declare namespace defineCustomEventTarget {
/**
* The interface of CustomEventTarget.
*/
- type CustomEventTarget<TEventMap extends Record<string, Event>, TMode extends "standard" | "strict"> = EventTarget<TEventMap, TMode> & defineEventAttribute.EventAttributes<any, TEventMap>;
+ type CustomEventTarget<TEventMap extends Record<string, Event>, TMode extends "standard" | "strict"> = EventTarget<TEventMap, TMode> & defineEventAttribute.EventAttributes<any>;
}
/**
* Define an event attribute.
@@ -368,14 +368,12 @@ export declare namespace defineCustomEventTarget {
* @param _eventClass Unused, but to infer `Event` class type.
* @deprecated Use `getEventAttributeValue`/`setEventAttributeValue` pair on your derived class instead because of static analysis friendly.
*/
-export declare function defineEventAttribute<TEventTarget extends EventTarget, TEventType extends string, TEventConstrucor extends typeof Event>(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes<TEventTarget, Record<TEventType, InstanceType<TEventConstrucor>>>;
+export declare function defineEventAttribute<TEventTarget extends EventTarget, TEventType extends string, TEventConstrucor extends typeof Event>(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes<TEventTarget>;
export declare namespace defineEventAttribute {
/**
* Definition of event attributes.
*/
- type EventAttributes<TEventTarget extends EventTarget<any, any>, TEventMap extends Record<string, Event>> = {
- [P in string & keyof TEventMap as `on${P}`]: EventTarget.CallbackFunction<TEventTarget, TEventMap[P]> | null;
- };
+ type EventAttributes<TEventTarget extends EventTarget<any, any>> = Record<string, EventTarget.CallbackFunction<TEventTarget, any> | null>;
}
/**
* Set the warning handler.

View File

@ -0,0 +1,91 @@
diff --git a/node_modules/mp3-mediarecorder/dist/index.es.js b/node_modules/mp3-mediarecorder/dist/index.es.js
index 7a96961..82ec4e8 100644
--- a/node_modules/mp3-mediarecorder/dist/index.es.js
+++ b/node_modules/mp3-mediarecorder/dist/index.es.js
@@ -357,8 +357,7 @@ class Event$1 {
InitEventWasCalledWhileDispatching.warn();
return;
}
- internalDataMap.set(this, {
- ...data,
+ internalDataMap.set(this, Object.assign({}, data, {
type: String(type),
bubbles: Boolean(bubbles),
cancelable: Boolean(cancelable),
@@ -366,8 +365,8 @@ class Event$1 {
currentTarget: null,
stopPropagationFlag: false,
stopImmediatePropagationFlag: false,
- canceledFlag: false,
- });
+ canceledFlag: false
+ }));
}
}
//------------------------------------------------------------------------------
diff --git a/node_modules/mp3-mediarecorder/dist/index.es5.js b/node_modules/mp3-mediarecorder/dist/index.es5.js
index 0caa82d..aa46cc2 100644
--- a/node_modules/mp3-mediarecorder/dist/index.es5.js
+++ b/node_modules/mp3-mediarecorder/dist/index.es5.js
@@ -418,7 +418,7 @@ class Event$1 {
return;
}
- internalDataMap.set(this, { ...data,
+ internalDataMap.set(this, Object.assign({}, data, {
type: String(type),
bubbles: Boolean(bubbles),
cancelable: Boolean(cancelable),
@@ -427,7 +427,7 @@ class Event$1 {
stopPropagationFlag: false,
stopImmediatePropagationFlag: false,
canceledFlag: false
- });
+ }));
}
} //------------------------------------------------------------------------------
diff --git a/node_modules/mp3-mediarecorder/dist/index.js b/node_modules/mp3-mediarecorder/dist/index.js
index f7a517e..5f7f415 100644
--- a/node_modules/mp3-mediarecorder/dist/index.js
+++ b/node_modules/mp3-mediarecorder/dist/index.js
@@ -418,7 +418,7 @@ class Event$1 {
return;
}
- internalDataMap.set(this, { ...data,
+ internalDataMap.set(this, Object.assign({}, data, {
type: String(type),
bubbles: Boolean(bubbles),
cancelable: Boolean(cancelable),
@@ -427,7 +427,7 @@ class Event$1 {
stopPropagationFlag: false,
stopImmediatePropagationFlag: false,
canceledFlag: false
- });
+ }));
}
} //------------------------------------------------------------------------------
diff --git a/node_modules/mp3-mediarecorder/dist/index.umd.js b/node_modules/mp3-mediarecorder/dist/index.umd.js
index 3f5f2a2..dd7783d 100644
--- a/node_modules/mp3-mediarecorder/dist/index.umd.js
+++ b/node_modules/mp3-mediarecorder/dist/index.umd.js
@@ -418,7 +418,7 @@ class Event$1 {
return;
}
- internalDataMap.set(this, { ...data,
+ internalDataMap.set(this, Object.assign({}, data, {
type: String(type),
bubbles: Boolean(bubbles),
cancelable: Boolean(cancelable),
@@ -427,7 +427,7 @@ class Event$1 {
stopPropagationFlag: false,
stopImmediatePropagationFlag: false,
canceledFlag: false
- });
+ }));
}
} //------------------------------------------------------------------------------

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

@ -11,6 +11,7 @@
// 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 {
AddonModDataEntryField,
AddonModDataField,

View File

@ -11,6 +11,7 @@
// 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 { Component } from '@angular/core';
import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data';
import { AddonModDataFieldPluginBaseComponent } from '@addons/mod/data/classes/base-field-plugin-component';

View File

@ -11,6 +11,7 @@
// 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 { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data';
import { Component } from '@angular/core';
import { CoreFileEntry, CoreFileHelper } from '@services/file-helper';

View File

@ -11,6 +11,7 @@
// 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 { Component } from '@angular/core';
import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component';

View File

@ -11,6 +11,7 @@
// 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 { AddonModDataEntryField } from '@addons/mod/data/services/data';
import { Component } from '@angular/core';
import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component';

View File

@ -11,6 +11,7 @@
// 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 { AddonQbehaviourAdaptiveModule } from './adaptive/adaptive.module';

View File

@ -11,6 +11,7 @@
// 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 { AddonUserProfileFieldCheckboxModule } from './checkbox/checkbox.module';

View File

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

View File

@ -0,0 +1,45 @@
// (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 { ElementRef } from '@angular/core';
import { CorePromisedValue } from '@classes/promised-value';
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
/**
* Helper class to build modals.
*/
export class CoreModalComponent<T=unknown> {
result: CorePromisedValue<T> = new CorePromisedValue();
constructor({ nativeElement: element }: ElementRef<HTMLElement>) {
CoreDirectivesRegistry.register(element, this);
}
/**
* Close the modal.
*
* @param result Result data, or error instance if the modal was closed with a failure.
*/
async close(result: T | Error): Promise<void> {
if (result instanceof Error) {
this.result.reject(result);
return;
}
this.result.resolve(result);
}
}

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

@ -64,6 +64,7 @@ import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-
import { CoreMessageComponent } from './message/message';
import { CoreGroupSelectorComponent } from './group-selector/group-selector';
import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-button-modal';
import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal';
@NgModule({
declarations: [
@ -110,6 +111,7 @@ import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-
CoreHorizontalScrollControlsComponent,
CoreSwipeNavigationTourComponent,
CoreRefreshButtonModalComponent,
CoreSheetModalComponent,
],
imports: [
CommonModule,
@ -163,6 +165,7 @@ import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-
CoreHorizontalScrollControlsComponent,
CoreSwipeNavigationTourComponent,
CoreRefreshButtonModalComponent,
CoreSheetModalComponent,
],
})
export class CoreComponentsModule {}

View File

@ -0,0 +1,2 @@
<ion-backdrop></ion-backdrop>
<div class="sheet-modal--wrapper" #wrapper></div>

View File

@ -0,0 +1,40 @@
@import "~theme/globals";
:host {
--backdrop-opacity: var(--ion-backdrop-opacity, 0.4);
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
isolation: isolate;
ion-backdrop {
opacity: 0;
transition: opacity 300ms ease-in;
}
.sheet-modal--wrapper {
border-radius: var(--big-radius) var(--big-radius) 0 0;
@include padding(24px, 16px, 24px, 16px);
background-color: var(--ion-overlay-background-color, var(--ion-background-color, #fff));
z-index: 3; // ion-backdrop has z-index 2
transform: translateY(100%);
transition: transform 300ms ease-in;
}
&.active {
ion-backdrop {
opacity: var(--backdrop-opacity);
}
.sheet-modal--wrapper {
transform: translateY(0%);
}
}
}

View File

@ -0,0 +1,93 @@
// (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 { Constructor } from '@/core/utils/types';
import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core';
import { CoreModalComponent } from '@classes/modal-component';
import { CorePromisedValue } from '@classes/promised-value';
import { CoreModals } from '@services/modals';
import { CoreUtils } from '@services/utils/utils';
import { AngularFrameworkDelegate } from '@singletons';
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
@Component({
selector: 'core-sheet-modal',
templateUrl: 'sheet-modal.html',
styleUrls: ['sheet-modal.scss'],
})
export class CoreSheetModalComponent<T extends CoreModalComponent> implements AfterViewInit {
@Input() component!: Constructor<T>;
@Input() componentProps?: Record<string, unknown>;
@ViewChild('wrapper') wrapper?: ElementRef<HTMLElement>;
private element: HTMLElement;
private wrapperElement = new CorePromisedValue<HTMLElement>();
constructor({ nativeElement: element }: ElementRef<HTMLElement>) {
this.element = element;
CoreDirectivesRegistry.register(element, this);
}
/**
* @inheritdoc
*/
ngAfterViewInit(): void {
if (!this.wrapper) {
this.wrapperElement.reject(new Error('CoreSheetModalComponent wasn\'t mounted properly'));
return;
}
this.wrapperElement.resolve(this.wrapper.nativeElement);
}
/**
* Show modal.
*
* @returns Component instance.
*/
async show(): Promise<T> {
const wrapper = await this.wrapperElement;
const element = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {});
await CoreUtils.nextTick();
this.element.classList.add('active');
this.element.style.zIndex = `${20000 + CoreModals.getTopOverlayIndex()}`;
await CoreUtils.nextTick();
await CoreUtils.wait(300);
const instance = CoreDirectivesRegistry.resolve(element, this.component);
if (!instance) {
throw new Error('Modal not mounted properly');
}
return instance;
}
/**
* Hide modal.
*/
async hide(): Promise<void> {
this.element.classList.remove('active');
await CoreUtils.nextTick();
await CoreUtils.wait(300);
}
}

View File

@ -14,7 +14,7 @@
<core-loading [hideUntil]="readyToCapture">
<div class="core-av-wrapper">
<!-- Video stream for image and video. -->
<video *ngIf="!isAudio" [hidden]="hasCaptured" class="core-webcam-stream" autoplay #streamVideo></video>
<video [hidden]="hasCaptured" class="core-webcam-stream" autoplay #streamVideo></video>
<!-- For video recording, use 2 videos and show/hide them because a CSS rule caused problems with the controls. -->
<video *ngIf="isVideo" [hidden]="!hasCaptured" class="core-webcam-video-captured" controls #previewVideo
@ -25,21 +25,6 @@
<canvas *ngIf="isImage" class="core-webcam-image-canvas" #imgCanvas></canvas>
<img *ngIf="isImage" [hidden]="!hasCaptured" class="core-webcam-image" alt="{{ 'core.capturedimage' | translate }}"
#previewImage>
<!-- Recording audio. -->
<div *ngIf="isAudio" class="core-audio-record-container">
<!-- Canvas to show audio waves when recording audio in browser. -->
<canvas [hidden]="hasCaptured || isCordovaAudioCapture" class="core-audio-canvas" #streamAudio></canvas>
<!-- Button to start/stop in mobile devices. -->
<ion-button fill="clear" *ngIf="!hasCaptured && isCordovaAudioCapture" (click)="actionClicked()" [attr.aria-label]="title">
<ion-icon *ngIf="!isCapturing" name="fas-microphone" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon *ngIf="isCapturing" name="fas-square" slot="icon-only" aria-hidden="true"></ion-icon>
</ion-button>
<!-- Audio player to listen to the result. -->
<audio [hidden]="!hasCaptured" class="core-audio-captured" controls #previewAudio controlsList="nodownload"></audio>
</div>
</div>
</core-loading>
</ion-content>
@ -48,8 +33,7 @@
<ion-row>
<ion-col></ion-col>
<ion-col class="ion-text-center">
<ion-button fill="clear" *ngIf="!hasCaptured && !isCordovaAudioCapture" (click)="actionClicked()" [attr.aria-label]="title">
<ion-icon *ngIf="!isCapturing && isAudio" name="fas-microphone" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-button fill="clear" *ngIf="!hasCaptured" (click)="actionClicked()" [attr.aria-label]="title">
<ion-icon *ngIf="!isCapturing && isVideo" name="fas-video" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon *ngIf="isImage" name="fas-camera" slot="icon-only" aria-hidden="true"></ion-icon>
<ion-icon *ngIf="isCapturing" name="fas-square" slot="icon-only" aria-hidden="true"></ion-icon>

View File

@ -21,30 +21,6 @@
width: 100%;
}
.button {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
height: 120px;
width: 120px;
.icon {
font-size: 120px;
width: auto;
}
}
audio {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
margin: auto;
}
}
video, img {

View File

@ -13,23 +13,20 @@
// limitations under the License.
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, ChangeDetectorRef, Input } from '@angular/core';
import { MediaObject } from '@ionic-native/media/ngx';
import { FileEntry } from '@ionic-native/file/ngx';
import { MediaFile } from '@ionic-native/media-capture/ngx';
import { CoreFile, CoreFileProvider } from '@services/file';
import { CoreDomUtils } from '@services/utils/dom';
import { CoreMimetypeUtils } from '@services/utils/mimetype';
import { CoreTimeUtils } from '@services/utils/time';
import { ModalController, Media, Translate } from '@singletons';
import { ModalController, Translate } from '@singletons';
import { CoreError } from '@classes/errors/error';
import { CoreCaptureError } from '@classes/errors/captureerror';
import { CoreCanceledError } from '@classes/errors/cancelederror';
import { CorePath } from '@singletons/path';
import { CorePlatform } from '@services/platform';
/**
* Page to capture media in browser, or to capture audio in mobile devices.
* Page to capture media in browser.
*/
@Component({
selector: 'core-emulator-capture-media',
@ -38,7 +35,7 @@ import { CorePlatform } from '@services/platform';
})
export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
@Input() type?: 'audio' | 'video' | 'image' | 'captureimage';
@Input() type?: 'video' | 'image' | 'captureimage';
@Input() maxTime?: number; // Max time to capture.
@Input() facingMode?: string; // Camera facing mode.
@Input() mimetype?: string;
@ -50,30 +47,20 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
@ViewChild('previewVideo') previewVideo?: ElementRef;
@ViewChild('imgCanvas') imgCanvas?: ElementRef;
@ViewChild('previewImage') previewImage?: ElementRef;
@ViewChild('streamAudio') streamAudio?: ElementRef;
@ViewChild('previewAudio') previewAudio?: ElementRef;
title?: string; // The title of the page.
isAudio?: boolean; // Whether it should capture audio.
isVideo?: boolean; // Whether it should capture video.
isImage?: boolean; // Whether it should capture image.
readyToCapture?: boolean; // Whether it's ready to capture.
hasCaptured?: boolean; // Whether it has captured something.
isCapturing?: boolean; // Whether it's capturing.
resetChrono?: boolean; // Boolean to reset the chrono.
isCordovaAudioCapture?: boolean; // Whether it's capturing audio using Cordova plugin.
protected isCaptureImage?: boolean; // To identify if it's capturing an image using media capture plugin (instead of camera).
protected mediaRecorder?: MediaRecorder; // To record video/audio.
protected previewMedia?: HTMLAudioElement | HTMLVideoElement; // The element to preview the audio/video captured.
protected mediaRecorder?: MediaRecorder; // To record video.
protected previewMedia?: HTMLVideoElement; // The element to preview the video captured.
protected mediaBlob?: Blob; // A Blob where the captured data is stored.
protected localMediaStream?: MediaStream;
protected audioDrawer?: {start: () => void; stop: () => void }; // To start/stop the display of audio sound.
// Variables for Cordova Media capture.
protected mediaFile?: MediaObject;
protected filePath?: string;
protected fileEntry?: FileEntry;
constructor(
protected changeDetectorRef: ChangeDetectorRef,
@ -84,12 +71,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
*/
ngOnInit(): void {
this.initVariables();
if (this.isCordovaAudioCapture) {
this.initCordovaMediaPlugin();
} else {
this.initHtmlCapture();
}
this.initHtmlCapture();
}
/**
@ -108,71 +90,10 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
if (this.type == 'video') {
this.isVideo = true;
this.title = 'core.capturevideo';
} else if (this.type == 'audio') {
this.isAudio = true;
this.title = 'core.captureaudio';
} else if (this.type == 'image') {
this.isImage = true;
this.title = 'core.captureimage';
}
this.isCordovaAudioCapture = CorePlatform.isMobile() && this.isAudio;
if (this.isCordovaAudioCapture) {
this.extension = CorePlatform.is('ios') ? 'wav' : 'aac';
this.returnDataUrl = false;
}
}
/**
* Init recording with Cordova media plugin.
*
* @returns Promise resolved when ready.
*/
protected async initCordovaMediaPlugin(): Promise<void> {
try {
await this.createFileAndMediaInstance();
this.readyToCapture = true;
this.previewMedia = this.previewAudio?.nativeElement;
} catch (error) {
this.dismissWithError(-1, error.message || error);
}
}
/**
* Create a file entry and the cordova media instance.
*/
protected async createFileAndMediaInstance(): Promise<void> {
this.filePath = this.getFilePath();
// First create the file.
this.fileEntry = await CoreFile.createFile(this.filePath);
// Now create the media instance.
let absolutePath = CorePath.concatenatePaths(CoreFile.getBasePathInstant(), this.filePath);
if (CorePlatform.is('ios')) {
// In iOS we need to remove the file:// part.
absolutePath = absolutePath.replace(/^file:\/\//, '');
}
this.mediaFile = Media.create(absolutePath);
}
/**
* Reset the file and the cordova media instance.
*/
protected async resetCordovaMediaCapture(): Promise<void> {
if (this.filePath) {
// Remove old file, don't block the user for this.
CoreFile.removeFile(this.filePath);
}
this.mediaFile?.release();
await this.createFileAndMediaInstance();
}
/**
@ -183,8 +104,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
*/
protected async initHtmlCapture(): Promise<void> {
const constraints = {
video: this.isAudio ? false : { facingMode: this.facingMode },
audio: !this.isImage,
video: { facingMode: this.facingMode },
};
try {
@ -196,22 +116,18 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
if (!this.isImage) {
if (this.isVideo) {
this.previewMedia = this.previewVideo?.nativeElement;
} else {
this.previewMedia = this.previewAudio?.nativeElement;
this.initAudioDrawer(this.localMediaStream);
this.audioDrawer?.start();
}
this.mediaRecorder = new MediaRecorder(this.localMediaStream, { mimeType: this.mimetype });
// When video or audio is recorded, add it to the list of chunks.
// When video is recorded, add it to the list of chunks.
this.mediaRecorder.ondataavailable = (e): void => {
if (e.data.size > 0) {
chunks.push(e.data);
}
};
// When recording stops, create a Blob element with the recording and set it to the video or audio.
// When recording stops, create a Blob element with the recording and set it to the video.
this.mediaRecorder.onstop = (): void => {
this.mediaBlob = new Blob(chunks);
chunks = [];
@ -267,91 +183,6 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
}
}
/**
* Initialize the audio drawer. This code has been extracted from MDN's example on MediaStream Recording:
* https://github.com/mdn/web-dictaphone
*
* @param stream Stream returned by getUserMedia.
*/
protected initAudioDrawer(stream: MediaStream): void {
if (!this.streamAudio) {
return;
}
let skip = true;
let running = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const audioCtx = new (window.AudioContext || (<any> window).webkitAudioContext)();
const canvasCtx = this.streamAudio.nativeElement.getContext('2d');
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const width = this.streamAudio.nativeElement.width;
const height = this.streamAudio.nativeElement.height;
const drawAudio = (): void => {
if (!running) {
return;
}
// Update the draw every animation frame.
requestAnimationFrame(drawAudio);
// Skip half of the frames to improve performance, shouldn't affect the smoothness.
skip = !skip;
if (skip) {
return;
}
const sliceWidth = width / bufferLength;
let x = 0;
analyser.getByteTimeDomainData(dataArray);
canvasCtx.fillStyle = 'rgb(200, 200, 200)';
canvasCtx.fillRect(0, 0, width, height);
canvasCtx.lineWidth = 1;
canvasCtx.strokeStyle = 'rgb(0, 0, 0)';
canvasCtx.beginPath();
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = v * height / 2;
if (i === 0) {
canvasCtx.moveTo(x, y);
} else {
canvasCtx.lineTo(x, y);
}
x += sliceWidth;
}
canvasCtx.lineTo(width, height / 2);
canvasCtx.stroke();
};
analyser.fftSize = 2048;
source.connect(analyser);
this.audioDrawer = {
start: (): void => {
if (running) {
return;
}
running = true;
drawAudio();
},
stop: (): void => {
running = false;
},
};
}
/**
* Main action clicked: record or stop recording.
*/
@ -369,15 +200,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
this.isCapturing = true;
this.resetChrono = false;
if (this.isCordovaAudioCapture) {
this.mediaFile?.startRecord();
if (this.previewMedia) {
this.previewMedia.src = '';
}
} else {
this.mediaRecorder?.start();
}
this.mediaRecorder?.start();
this.changeDetectorRef.detectChanges();
} else {
if (!this.imgCanvas) {
@ -419,11 +242,6 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
// Send a "cancelled" error like the Cordova plugin does.
this.dismissWithCanceledError('Canceled.', 'Camera cancelled');
if (this.isCordovaAudioCapture && this.filePath) {
// Delete the tmp file.
CoreFile.removeFile(this.filePath);
}
}
/**
@ -432,11 +250,6 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
async discard(): Promise<void> {
this.previewMedia?.pause();
this.streamVideo?.nativeElement.play();
this.audioDrawer?.start();
if (this.isCordovaAudioCapture) {
await this.resetCordovaMediaCapture();
}
this.hasCaptured = false;
this.isCapturing = false;
@ -492,30 +305,23 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
return;
}
if (!this.mediaBlob && !this.isCordovaAudioCapture) {
if (!this.mediaBlob) {
// Shouldn't happen.
CoreDomUtils.showErrorModal('Please capture the media first.');
return;
}
let fileEntry = this.fileEntry;
const loadingModal = await CoreDomUtils.showModalLoading();
try {
if (!this.isCordovaAudioCapture) {
// Capturing in browser. Write the blob in a file.
if (!this.mediaBlob) {
// Shouldn't happen.
throw new Error('Please capture the media first.');
}
fileEntry = await CoreFile.writeFile(this.getFilePath(), this.mediaBlob);
// Capturing in browser. Write the blob in a file.
if (!this.mediaBlob) {
// Shouldn't happen.
throw new Error('Please capture the media first.');
}
if (!fileEntry) {
throw new CoreError('File not found.');
}
const fileEntry = await CoreFile.writeFile(this.getFilePath(), this.mediaBlob);
if (this.isImage && !this.isCaptureImage) {
this.dismissWithData(fileEntry.toURL());
@ -560,30 +366,20 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
}
/**
* Stop capturing. Only for video and audio.
* Stop capturing. Only for video.
*/
stopCapturing(): void {
this.isCapturing = false;
this.hasCaptured = true;
if (this.isCordovaAudioCapture) {
this.mediaFile?.stopRecord();
if (this.previewMedia && this.fileEntry) {
this.previewMedia.src = CoreFile.convertFileSrc(this.fileEntry.toURL());
}
} else {
this.streamVideo && this.streamVideo.nativeElement.pause();
this.audioDrawer && this.audioDrawer.stop();
this.mediaRecorder && this.mediaRecorder.stop();
}
this.streamVideo && this.streamVideo.nativeElement.pause();
this.mediaRecorder && this.mediaRecorder.stop();
}
/**
* Page destroyed.
*/
ngOnDestroy(): void {
this.mediaFile?.release();
if (this.localMediaStream) {
const tracks = this.localMediaStream.getTracks();
tracks.forEach((track) => {
@ -592,14 +388,13 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
}
this.streamVideo?.nativeElement.pause();
this.previewMedia?.pause();
this.audioDrawer?.stop();
delete this.mediaBlob;
}
}
export type CaptureMediaComponentInputs = {
type: 'audio' | 'video' | 'image' | 'captureimage';
type: 'video' | 'image' | 'captureimage';
maxTime?: number; // Max time to capture.
facingMode?: string; // Camera facing mode.
mimetype?: string;

View File

@ -0,0 +1,21 @@
// (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';
/**
* Stub used in production to avoid including emulator code in production bundles.
*/
@NgModule({})
export class CoreEmulatorModule {}

View File

@ -14,23 +14,18 @@
import { Injectable } from '@angular/core';
import { CameraOptions } from '@ionic-native/camera/ngx';
import { CaptureAudioOptions, CaptureImageOptions, CaptureVideoOptions, MediaFile } from '@ionic-native/media-capture/ngx';
import { CaptureImageOptions, CaptureVideoOptions, MediaFile } from '@ionic-native/media-capture/ngx';
import { CoreMimetypeUtils } from '@services/utils/mimetype';
import { makeSingleton, ModalController } from '@singletons';
import { CaptureMediaComponentInputs, CoreEmulatorCaptureMediaComponent } from '../components/capture-media/capture-media';
/**
* Helper service with some features to capture media (image, audio, video).
* Helper service with some features to capture media (image, video).
*/
@Injectable({ providedIn: 'root' })
export class CoreEmulatorCaptureHelperProvider {
protected possibleAudioMimeTypes = {
'audio/webm': 'weba',
'audio/ogg': 'ogg',
};
protected possibleVideoMimeTypes = {
'video/webm;codecs=vp9': 'webm',
'video/webm;codecs=vp8': 'webm',
@ -38,22 +33,20 @@ export class CoreEmulatorCaptureHelperProvider {
};
videoMimeType?: string;
audioMimeType?: string;
/**
* Capture media (image, audio, video).
* Capture media (image, video).
*
* @param type Type of media: image, audio, video.
* @param type Type of media: image, video.
* @param options Optional options.
* @returns Promise resolved when captured, rejected if error.
*/
captureMedia(type: 'image', options?: MockCameraOptions): Promise<string>;
captureMedia(type: 'captureimage', options?: MockCaptureImageOptions): Promise<MediaFile[]>;
captureMedia(type: 'audio', options?: MockCaptureAudioOptions): Promise<MediaFile[]>;
captureMedia(type: 'video', options?: MockCaptureVideoOptions): Promise<MediaFile[]>;
async captureMedia(
type: 'image' | 'captureimage' | 'audio' | 'video',
options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureAudioOptions | MockCaptureVideoOptions,
type: 'image' | 'captureimage' | 'video',
options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureVideoOptions,
): Promise<MediaFile[] | string> {
options = options || {};
@ -67,10 +60,6 @@ export class CoreEmulatorCaptureHelperProvider {
const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes);
params.mimetype = mimeAndExt.mimetype;
params.extension = mimeAndExt.extension;
} else if (type == 'audio') {
const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes);
params.mimetype = mimeAndExt.mimetype;
params.extension = mimeAndExt.extension;
} else if (type == 'image') {
if ('sourceType' in options && options.sourceType !== undefined && options.sourceType != 1) {
return Promise.reject('This source type is not supported in browser.');
@ -121,7 +110,7 @@ export class CoreEmulatorCaptureHelperProvider {
/**
* Get the mimetype and extension to capture media.
*
* @param type Type of media: image, audio, video.
* @param type Type of media: image, video.
* @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported.
* @returns An object with mimetype and extension to use.
*/
@ -148,10 +137,6 @@ export class CoreEmulatorCaptureHelperProvider {
// No mimetype found, use default extension.
result.mimetype = this.videoMimeType;
result.extension = this.possibleVideoMimeTypes[result.mimetype!];
} else if (type == 'audio') {
// No mimetype found, use default extension.
result.mimetype = this.audioMimeType;
result.extension = this.possibleAudioMimeTypes[result.mimetype!];
}
return result;
@ -170,20 +155,12 @@ export class CoreEmulatorCaptureHelperProvider {
* Initialize the mimetypes to use when capturing.
*/
protected initMimeTypes(): void {
// Determine video and audio mimetype to use.
for (const mimeType in this.possibleVideoMimeTypes) {
if (window.MediaRecorder.isTypeSupported(mimeType)) {
this.videoMimeType = mimeType;
break;
}
}
for (const mimeType in this.possibleAudioMimeTypes) {
if (window.MediaRecorder.isTypeSupported(mimeType)) {
this.audioMimeType = mimeType;
break;
}
}
}
/**
@ -209,9 +186,6 @@ export interface MockCameraOptions extends CameraOptions {
export interface MockCaptureImageOptions extends CaptureImageOptions {
mimetypes?: string[]; // Allowed mimetypes.
}
export interface MockCaptureAudioOptions extends CaptureAudioOptions {
mimetypes?: string[]; // Allowed mimetypes.
}
export interface MockCaptureVideoOptions extends CaptureVideoOptions {
mimetypes?: string[]; // Allowed mimetypes.
}

View File

@ -15,7 +15,6 @@
import { Injectable } from '@angular/core';
import {
MediaCapture,
CaptureAudioOptions,
CaptureImageOptions,
CaptureVideoOptions,
MediaFile,
@ -29,16 +28,6 @@ import { CoreEmulatorCaptureHelper } from './capture-helper';
@Injectable()
export class MediaCaptureMock extends MediaCapture {
/**
* Start the audio recorder application and return information about captured audio clip files.
*
* @param options Options.
* @returns Promise resolved when captured.
*/
captureAudio(options: CaptureAudioOptions): Promise<MediaFile[]> {
return CoreEmulatorCaptureHelper.captureMedia('audio', options);
}
/**
* Start the camera application and return information about captured image files.
*

View File

@ -0,0 +1 @@
<canvas #canvas></canvas>

View File

@ -0,0 +1,10 @@
:host {
--background-color: var(--ion-background-color, #fff);
--bars-color: var(--ion-text-color, #000);
canvas {
width: 100%;
height: 100%;
}
}

View File

@ -0,0 +1,160 @@
// (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 { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core';
@Component({
selector: 'core-audio-histogram',
templateUrl: 'audio-histogram.html',
styleUrls: ['audio-histogram.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CoreFileUploaderAudioHistogramComponent implements AfterViewInit, OnDestroy {
private static readonly BARS_WIDTH = 2;
private static readonly BARS_MIN_HEIGHT = 4;
private static readonly BARS_GUTTER = 4;
@Input() analyser!: AnalyserNode;
@Input() paused?: boolean;
@ViewChild('canvas') canvasRef?: ElementRef<HTMLCanvasElement>;
private element: HTMLElement;
private canvas?: HTMLCanvasElement;
private context?: CanvasRenderingContext2D | null;
private buffer?: Uint8Array;
private destroyed = false;
constructor({ nativeElement }: ElementRef<HTMLElement>) {
this.element = nativeElement;
}
/**
* @inheritdoc
*/
ngAfterViewInit(): void {
this.canvas = this.canvasRef?.nativeElement;
this.context = this.canvas?.getContext('2d');
this.buffer = new Uint8Array(this.analyser.fftSize);
if (this.context && this.canvas) {
const styles = getComputedStyle(this.element);
this.canvas.width = this.canvas.clientWidth;
this.canvas.height = this.canvas.clientHeight;
this.context.fillStyle = styles.getPropertyValue('--background-color');
this.context.lineCap = 'round';
this.context.lineWidth = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH;
this.context.strokeStyle = styles.getPropertyValue('--bars-color');
}
this.draw();
}
/**
* @inheritdoc
*/
ngOnDestroy(): void {
this.destroyed = true;
}
/**
* Draw histogram.
*/
private draw(): void {
if (this.destroyed || !this.canvas || !this.context || !this.buffer) {
return;
}
const width = this.canvas.width;
const height = this.canvas.height;
const barsWidth = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH;
const barsGutter = CoreFileUploaderAudioHistogramComponent.BARS_GUTTER;
const chunkLength = Math.floor(this.buffer.length / ((width - barsWidth - 1) / (barsWidth + barsGutter)));
const barsCount = Math.floor(this.buffer.length / chunkLength);
// Reset canvas.
this.context.fillRect(0, 0, width, height);
// Draw bars.
const startX = Math.floor((width - (barsWidth + barsGutter)*barsCount - barsWidth - 1)/2);
this.context.beginPath();
this.paused ? this.drawPausedBars(startX) : this.drawActiveBars(startX);
this.context.stroke();
// Schedule next frame.
requestAnimationFrame(() => this.draw());
}
/**
* Draws bars on the histogram when it is active.
*
* @param x Starting x position.
*/
private drawActiveBars(x: number): void {
if (!this.canvas || !this.context || !this.buffer) {
return;
}
let bufferX = 0;
const width = this.canvas.width;
const halfHeight = this.canvas.height / 2;
const halfMinHeight = CoreFileUploaderAudioHistogramComponent.BARS_MIN_HEIGHT / 2;
const barsWidth = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH;
const barsGutter = CoreFileUploaderAudioHistogramComponent.BARS_GUTTER;
const bufferLength = this.buffer.length;
const barsBufferWidth = Math.floor(bufferLength / ((width - barsWidth - 1) / (barsWidth + barsGutter)));
this.analyser.getByteTimeDomainData(this.buffer);
while (bufferX < bufferLength) {
let maxLevel = halfMinHeight;
do {
maxLevel = Math.max(maxLevel, halfHeight * (1 - (this.buffer[bufferX] / 128)));
bufferX++;
} while (bufferX % barsBufferWidth !== 0 && bufferX < bufferLength);
this.context.moveTo(x, halfHeight - maxLevel);
this.context.lineTo(x, halfHeight + maxLevel);
x += barsWidth + barsGutter;
}
}
/**
* Draws bars on the histogram when it is paused.
*
* @param x Starting x position.
*/
private drawPausedBars(x: number): void {
if (!this.canvas || !this.context) {
return;
}
const width = this.canvas.width;
const halfHeight = this.canvas.height / 2;
const halfMinHeight = CoreFileUploaderAudioHistogramComponent.BARS_MIN_HEIGHT / 2;
const xStep = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH + CoreFileUploaderAudioHistogramComponent.BARS_GUTTER;
while (x < width) {
this.context.moveTo(x, halfHeight - halfMinHeight);
this.context.lineTo(x, halfHeight + halfMinHeight);
x += xStep;
}
}
}

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,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 { CoreSharedModule } from '@/core/shared.module';
import { NgModule } from '@angular/core';
import { CoreFileUploaderAudioHistogramComponent } from '@features/fileuploader/components/audio-histogram/audio-histogram';
import { CoreFileUploaderAudioRecorderComponent } from './audio-recorder.component';
export { CoreFileUploaderAudioRecorderComponent };
@NgModule({
imports: [
CoreSharedModule,
],
declarations: [
CoreFileUploaderAudioRecorderComponent,
CoreFileUploaderAudioHistogramComponent,
],
})
export class CoreFileUploaderAudioRecorderComponentModule {}

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

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

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,14 @@ 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 { CoreModals } from '@services/modals';
/**
* File upload options.
@ -132,14 +132,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 +157,17 @@ export class CoreFileUploaderProvider {
*
* @returns Promise resolved with the file.
*/
async captureAudioInApp(): Promise<MediaFile> {
const params = {
type: 'audio',
};
async captureAudioInApp(): Promise<CoreFileUploaderAudioRecording> {
const { CoreFileUploaderAudioRecorderComponent } =
await import('@features/fileuploader/components/audio-recorder/audio-recorder.module');
const modal = await ModalController.create({
component: CoreEmulatorCaptureMediaComponent,
cssClass: 'core-modal-fullscreen',
componentProps: params,
backdropDismiss: false,
});
const recording = await CoreModals.openSheet(CoreFileUploaderAudioRecorderComponent);
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 +332,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 +778,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

@ -11,6 +11,7 @@
// 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 { Component, OnInit } from '@angular/core';
import { CoreLoginHelper, CoreLoginMethod } from '@features/login/services/login-helper';
import { CoreSites } from '@services/sites';

View File

@ -29,7 +29,6 @@ import { InAppBrowser } from '@ionic-native/in-app-browser/ngx';
import { WebView } from '@ionic-native/ionic-webview/ngx';
import { Keyboard } from '@ionic-native/keyboard/ngx';
import { LocalNotifications } from '@ionic-native/local-notifications/ngx';
import { Media } from '@ionic-native/media/ngx';
import { MediaCapture } from '@ionic-native/media-capture/ngx';
import { Push } from '@ionic-native/push/ngx';
import { QRScanner } from '@ionic-native/qr-scanner/ngx';
@ -54,7 +53,6 @@ export const CORE_NATIVE_SERVICES = [
InAppBrowser,
Keyboard,
LocalNotifications,
Media,
MediaCapture,
Push,
QRScanner,
@ -82,7 +80,6 @@ export const CORE_NATIVE_SERVICES = [
InAppBrowser,
Keyboard,
LocalNotifications,
Media,
MediaCapture,
Push,
QRScanner,

View File

@ -11,8 +11,6 @@
// 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.
// (C) Copyright 2015 Moodle Pty Ltd.
//
import { Injectable } from '@angular/core';
import { CoreError } from '@classes/errors/error';

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

@ -0,0 +1,89 @@
// (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 { Constructor } from '@/core/utils/types';
import { Injectable } from '@angular/core';
import { CoreModalComponent } from '@classes/modal-component';
import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal';
import { AngularFrameworkDelegate, makeSingleton } from '@singletons';
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
/**
* Handles application modals.
*/
@Injectable({ providedIn: 'root' })
export class CoreModalsService {
/**
* Get index of the overlay on top of the stack.
*
* @returns Z-index of the overlay on top.
*/
getTopOverlayIndex(): number {
// This has to be done manually because Ionic's overlay mechanisms are not exposed externally, thus making it more difficult
// to implement custom overlays.
//
// eslint-disable-next-line max-len
// See https://github.com/ionic-team/ionic-framework/blob/a9b12a5aa4c150a1f8a80a826dda0df350bc0092/core/src/utils/overlays.ts#L39
const overlays = document.querySelectorAll<HTMLElement>(
'ion-action-sheet, ion-alert, ion-loading, ion-modal, ion-picker, ion-popover, ion-toast',
);
return Array.from(overlays).reduce((maxIndex, element) => {
const index = parseInt(element.style.zIndex);
if (isNaN(index)) {
return maxIndex;
}
return Math.max(maxIndex, index % 10000);
}, 0);
}
/**
* Open a sheet modal component.
*
* @param component Component to render inside the modal.
* @returns Modal result once it's been closed.
*/
async openSheet<T>(component: Constructor<CoreModalComponent<T>>): Promise<T> {
const container = document.querySelector('ion-app') ?? document.body;
const viewContainer = container.querySelector('ion-router-outlet, ion-nav, #ion-view-container-root');
const element = await AngularFrameworkDelegate.attachViewToDom(
container,
CoreSheetModalComponent,
{ component },
);
const sheetModal = CoreDirectivesRegistry.require<CoreSheetModalComponent<CoreModalComponent<T>>>(
element,
CoreSheetModalComponent,
);
const modal = await sheetModal.show();
viewContainer?.setAttribute('aria-hidden', 'true');
modal.result.finally(async () => {
await sheetModal.hide();
element.remove();
viewContainer?.removeAttribute('aria-hidden');
});
return modal.result;
}
}
export const CoreModals = makeSingleton(CoreModalsService);

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

@ -14,8 +14,7 @@
import { Component } from '@angular/core';
import { AsyncDirective } from '@classes/async-directive';
import { CoreUtils } from '@services/utils/utils';
import { CoreLogger } from './logger';
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
/**
* Registry to keep track of component instances.
@ -24,9 +23,6 @@ import { CoreLogger } from './logger';
*/
export class CoreComponentsRegistry {
private static instances: WeakMap<Element, unknown> = new WeakMap();
protected static logger = CoreLogger.getInstance('CoreComponentsRegistry');
/**
* Register a component instance.
*
@ -34,7 +30,7 @@ export class CoreComponentsRegistry {
* @param instance Component instance.
*/
static register(element: Element, instance: unknown): void {
this.instances.set(element, instance);
CoreDirectivesRegistry.register(element, instance);
}
/**
@ -45,11 +41,7 @@ export class CoreComponentsRegistry {
* @returns Component instance.
*/
static resolve<T>(element?: Element | null, componentClass?: ComponentConstructor<T>): T | null {
const instance = (element && this.instances.get(element) as T) ?? null;
return instance && (!componentClass || instance instanceof componentClass)
? instance
: null;
return CoreDirectivesRegistry.resolve(element, componentClass);
}
/**
@ -60,13 +52,7 @@ export class CoreComponentsRegistry {
* @returns Component instance.
*/
static require<T>(element: Element, componentClass?: ComponentConstructor<T>): T {
const instance = this.resolve(element, componentClass);
if (!instance) {
throw new Error('Couldn\'t resolve component instance');
}
return instance;
return CoreDirectivesRegistry.require(element, componentClass);
}
/**
@ -80,14 +66,7 @@ export class CoreComponentsRegistry {
element: Element | null,
componentClass?: ComponentConstructor<T>,
): Promise<void> {
const instance = this.resolve(element, componentClass);
if (!instance) {
this.logger.error('No instance registered for element ' + componentClass, element);
return;
}
await instance.ready();
return CoreDirectivesRegistry.waitDirectiveReady(element, componentClass);
}
/**
@ -103,23 +82,7 @@ export class CoreComponentsRegistry {
selector: string,
componentClass?: ComponentConstructor<T>,
): Promise<void> {
let elements: Element[] = [];
if (element.matches(selector)) {
// Element to wait is myself.
elements = [element];
} else {
elements = Array.from(element.querySelectorAll(selector));
}
if (!elements.length) {
return;
}
await Promise.all(elements.map(element => CoreComponentsRegistry.waitComponentReady<T>(element, componentClass)));
// Wait for next tick to ensure components are completely rendered.
await CoreUtils.nextTick();
return CoreDirectivesRegistry.waitDirectivesReady(element, selector, componentClass);
}
}

View File

@ -52,7 +52,6 @@ import { InAppBrowser as InAppBrowserService } from '@ionic-native/in-app-browse
import { WebView as WebViewService } from '@ionic-native/ionic-webview/ngx';
import { Keyboard as KeyboardService } from '@ionic-native/keyboard/ngx';
import { LocalNotifications as LocalNotificationsService } from '@ionic-native/local-notifications/ngx';
import { Media as MediaService } from '@ionic-native/media/ngx';
import { MediaCapture as MediaCaptureService } from '@ionic-native/media-capture/ngx';
import { Push as PushService } from '@ionic-native/push/ngx';
import { QRScanner as QRScannerService } from '@ionic-native/qr-scanner/ngx';
@ -184,7 +183,6 @@ export const Geolocation = makeSingleton(GeolocationService);
export const InAppBrowser = makeSingleton(InAppBrowserService);
export const Keyboard = makeSingleton(KeyboardService);
export const LocalNotifications = makeSingleton(LocalNotificationsService);
export const Media = makeSingleton(MediaService);
export const MediaCapture = makeSingleton(MediaCaptureService);
export const NativeHttp = makeSingleton(HTTP);
export const Push = makeSingleton(PushService);

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"
]
}