Merge pull request #3556 from NoelDeMartin/MOBILE-2314
MOBILE-2314: Audio Recorder improvementsmain
commit
4cb9a6640c
|
@ -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
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": {},
|
||||
|
|
|
@ -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.
|
|
@ -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
|
||||
- });
|
||||
+ }));
|
||||
}
|
||||
|
||||
} //------------------------------------------------------------------------------
|
|
@ -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',
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -14,6 +14,8 @@
|
|||
|
||||
import { CoreError } from './error';
|
||||
|
||||
export const CAPTURE_ERROR_NO_MEDIA_FILES = 3;
|
||||
|
||||
/**
|
||||
* Capture error.
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -1 +1 @@
|
|||
<span role="timer">{{ time / 1000 | coreSecondsToHMS }}</span>
|
||||
<span role="timer">{{ time / 1000 | coreSecondsToHMS:hours }}</span>
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
<ion-backdrop></ion-backdrop>
|
||||
<div class="sheet-modal--wrapper" #wrapper></div>
|
|
@ -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%);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {}
|
|
@ -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.
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<canvas #canvas></canvas>
|
|
@ -0,0 +1,10 @@
|
|||
:host {
|
||||
--background-color: var(--ion-background-color, #fff);
|
||||
--bars-color: var(--ion-text-color, #000);
|
||||
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,316 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { ChangeDetectionStrategy, Component, ElementRef, OnDestroy } from '@angular/core';
|
||||
import { CoreModalComponent } from '@classes/modal-component';
|
||||
import { CorePlatform } from '@services/platform';
|
||||
import { Diagnostic, DomSanitizer, Translate } from '@singletons';
|
||||
import { BehaviorSubject, combineLatest, Observable, OperatorFunction } from 'rxjs';
|
||||
import { Mp3MediaRecorder } from 'mp3-mediarecorder';
|
||||
import { map, shareReplay, tap } from 'rxjs/operators';
|
||||
import { initAudioEncoderMessage } from '@features/fileuploader/utils/worker-messages';
|
||||
import { SafeUrl } from '@angular/platform-browser';
|
||||
import { CoreDomUtils } from '@services/utils/dom';
|
||||
import { CAPTURE_ERROR_NO_MEDIA_FILES, CoreCaptureError } from '@classes/errors/captureerror';
|
||||
import { CoreFileUploaderAudioRecording } from '@features/fileuploader/services/fileuploader';
|
||||
import { CoreFile, CoreFileProvider } from '@services/file';
|
||||
import { CorePath } from '@singletons/path';
|
||||
|
||||
@Component({
|
||||
selector: 'core-fileuploader-audio-recorder',
|
||||
styleUrls: ['./audio-recorder.scss'],
|
||||
templateUrl: 'audio-recorder.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class CoreFileUploaderAudioRecorderComponent extends CoreModalComponent<CoreFileUploaderAudioRecording>
|
||||
implements OnDestroy {
|
||||
|
||||
recordingUrl$: Observable<SafeUrl | null>;
|
||||
histogramAnalyzer$: Observable<AnalyserNode | null>;
|
||||
status$: Observable<'recording-ongoing' | 'recording-paused' | 'done' | 'empty'>;
|
||||
|
||||
protected recording: AudioRecording | null;
|
||||
protected media$: BehaviorSubject<AudioRecorderMedia | null>;
|
||||
protected recording$: Observable<AudioRecording | null>;
|
||||
|
||||
constructor(elementRef: ElementRef<HTMLElement>) {
|
||||
super(elementRef);
|
||||
|
||||
this.recording = null;
|
||||
this.media$ = new BehaviorSubject(null);
|
||||
this.recording$ = this.media$.pipe(
|
||||
recorderAudioRecording(),
|
||||
shareReplay(),
|
||||
tap(recording => this.recording = recording),
|
||||
);
|
||||
this.recordingUrl$ = this.recording$.pipe(
|
||||
map(recording => recording && DomSanitizer.bypassSecurityTrustUrl(recording.url)),
|
||||
);
|
||||
this.histogramAnalyzer$ = this.media$.pipe(map(media => {
|
||||
if (!media?.analyser || CorePlatform.prefersReducedMotion()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return media.analyser;
|
||||
}));
|
||||
this.status$ = combineLatest([this.media$.pipe(recorderStatus(), shareReplay()), this.recording$])
|
||||
.pipe(map(([recordingStatus, recording]) => {
|
||||
if (recordingStatus === 'recording') {
|
||||
return 'recording-ongoing';
|
||||
}
|
||||
|
||||
if (recordingStatus === 'paused') {
|
||||
return 'recording-paused';
|
||||
}
|
||||
|
||||
if (recording) {
|
||||
return 'done';
|
||||
}
|
||||
|
||||
return 'empty';
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
*/
|
||||
ngOnDestroy(): void {
|
||||
this.media$.value?.recorder.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start recording.
|
||||
*/
|
||||
async startRecording(): Promise<void> {
|
||||
const media = await this.createMedia();
|
||||
|
||||
this.media$.next(media);
|
||||
|
||||
media.recorder.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording.
|
||||
*/
|
||||
stopRecording(): void {
|
||||
this.media$.value?.recorder.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording.
|
||||
*/
|
||||
pauseRecording(): void {
|
||||
this.media$.value?.recorder.pause();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording.
|
||||
*/
|
||||
resumeRecording(): void {
|
||||
this.media$.value?.recorder.resume();
|
||||
}
|
||||
|
||||
/**
|
||||
* Discard recording.
|
||||
*/
|
||||
discardRecording(): void {
|
||||
this.media$.next(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss modal without a result.
|
||||
*/
|
||||
async cancel(): Promise<void> {
|
||||
this.close(new CoreCaptureError(CAPTURE_ERROR_NO_MEDIA_FILES));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the modal with the current recording as a result.
|
||||
*/
|
||||
async submit(): Promise<void> {
|
||||
if (!this.recording) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = await CoreFile.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, 'recording.mp3');
|
||||
const filePath = CorePath.concatenatePaths(CoreFileProvider.TMPFOLDER, fileName);
|
||||
const fileEntry = await CoreFile.writeFile(filePath, this.recording.blob);
|
||||
|
||||
this.close({
|
||||
name: fileEntry.name,
|
||||
fullPath: fileEntry.toURL(),
|
||||
type: 'audio/mpeg',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create media instances.
|
||||
*
|
||||
* @returns Media instances.
|
||||
*/
|
||||
protected async createMedia(): Promise<AudioRecorderMedia> {
|
||||
await this.prepareMicrophoneAuthorization();
|
||||
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const audioContext = new window.AudioContext();
|
||||
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||
const analyser = audioContext.createAnalyser();
|
||||
|
||||
analyser.fftSize = 2048;
|
||||
source.connect(analyser);
|
||||
|
||||
return {
|
||||
analyser,
|
||||
recorder: new Mp3MediaRecorder(mediaStream, { worker: this.startWorker(), audioContext }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure that microphone usage has been authorized.
|
||||
*/
|
||||
protected async prepareMicrophoneAuthorization(): Promise<void> {
|
||||
if (!CorePlatform.isMobile()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await Diagnostic.requestMicrophoneAuthorization();
|
||||
|
||||
switch (status) {
|
||||
case Diagnostic.permissionStatus.DENIED_ONCE:
|
||||
case Diagnostic.permissionStatus.DENIED_ALWAYS:
|
||||
throw new Error(Translate.instant('core.fileuploader.microphonepermissiondenied'));
|
||||
case Diagnostic.permissionStatus.RESTRICTED:
|
||||
throw new Error(Translate.instant('core.fileuploader.microphonepermissionrestricted'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start worker script.
|
||||
*
|
||||
* @returns Worker.
|
||||
*/
|
||||
protected startWorker(): Worker {
|
||||
const worker = new Worker('./audio-recorder.worker', { type: 'module' });
|
||||
|
||||
worker.postMessage(
|
||||
initAudioEncoderMessage({ vmsgWasmUrl: `${document.head.baseURI}assets/lib/vmsg/vmsg.wasm` }),
|
||||
);
|
||||
|
||||
return worker;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Audio recording data.
|
||||
*/
|
||||
interface AudioRecording {
|
||||
url: string;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
/**
|
||||
* Media instances.
|
||||
*/
|
||||
interface AudioRecorderMedia {
|
||||
recorder: Mp3MediaRecorder;
|
||||
analyser: AnalyserNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable operator that listens to a recorder and emits a recording file.
|
||||
*
|
||||
* @returns Operator.
|
||||
*/
|
||||
function recorderAudioRecording(): OperatorFunction<AudioRecorderMedia | null, AudioRecording | null> {
|
||||
return source => new Observable(subscriber => {
|
||||
let audioChunks: Blob[] = [];
|
||||
let previousRecorder: Mp3MediaRecorder | undefined;
|
||||
const onDataAvailable = event => audioChunks.push(event.data);
|
||||
const onError = event => CoreDomUtils.showErrorModal(event.error);
|
||||
const onStop = () => {
|
||||
const blob = new Blob(audioChunks, { type: 'audio/mpeg' });
|
||||
|
||||
subscriber.next({
|
||||
url: URL.createObjectURL(blob),
|
||||
blob,
|
||||
});
|
||||
};
|
||||
const subscription = source.subscribe(media => {
|
||||
previousRecorder?.removeEventListener('dataavailable', onDataAvailable);
|
||||
previousRecorder?.removeEventListener('error', onError);
|
||||
previousRecorder?.removeEventListener('stop', onStop);
|
||||
|
||||
media?.recorder.addEventListener('dataavailable', onDataAvailable);
|
||||
media?.recorder.addEventListener('error', onError);
|
||||
media?.recorder.addEventListener('stop', onStop);
|
||||
|
||||
audioChunks = [];
|
||||
previousRecorder = media?.recorder;
|
||||
|
||||
subscriber.next(null);
|
||||
});
|
||||
|
||||
subscriber.next(null);
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
|
||||
previousRecorder?.removeEventListener('dataavailable', onDataAvailable);
|
||||
previousRecorder?.removeEventListener('error', onError);
|
||||
previousRecorder?.removeEventListener('stop', onStop);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Observable operator that listens to a recorder and emits its recording status.
|
||||
*
|
||||
* @returns Operator.
|
||||
*/
|
||||
function recorderStatus(): OperatorFunction<AudioRecorderMedia | null, RecordingState> {
|
||||
return source => new Observable(subscriber => {
|
||||
let previousRecorder: Mp3MediaRecorder | undefined;
|
||||
const onStart = () => subscriber.next('recording');
|
||||
const onPause = () => subscriber.next('paused');
|
||||
const onResume = () => subscriber.next('recording');
|
||||
const onStop = () => subscriber.next('inactive');
|
||||
const subscription = source.subscribe(media => {
|
||||
previousRecorder?.removeEventListener('start', onStart);
|
||||
previousRecorder?.removeEventListener('pause', onPause);
|
||||
previousRecorder?.removeEventListener('resume', onResume);
|
||||
previousRecorder?.removeEventListener('stop', onStop);
|
||||
|
||||
media?.recorder.addEventListener('start', onStart);
|
||||
media?.recorder.addEventListener('pause', onPause);
|
||||
media?.recorder.addEventListener('resume', onResume);
|
||||
media?.recorder.addEventListener('stop', onStop);
|
||||
|
||||
previousRecorder = media?.recorder;
|
||||
|
||||
subscriber.next(media?.recorder.state ?? 'inactive');
|
||||
});
|
||||
|
||||
subscriber.next('inactive');
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
|
||||
previousRecorder?.removeEventListener('start', onStart);
|
||||
previousRecorder?.removeEventListener('pause', onPause);
|
||||
previousRecorder?.removeEventListener('resume', onResume);
|
||||
previousRecorder?.removeEventListener('stop', onStop);
|
||||
};
|
||||
});
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
<header>
|
||||
<h1>{{ 'core.fileuploader.audiotitle' | translate }}</h1>
|
||||
|
||||
<ion-button shape="round" fill="clear" [attr.aria-label]="'core.close' | translate" (click)="cancel()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</header>
|
||||
|
||||
<hr>
|
||||
|
||||
<ng-container *ngIf="(status$ | async) as status">
|
||||
<div *ngIf="status === 'empty'" class="core-audio-recorder--wrapper">
|
||||
<p>{{ 'core.fileuploader.startrecordinginstructions' | translate }}</p>
|
||||
|
||||
<ion-button shape="round" color="danger" [attr.aria-label]="'core.fileuploader.startrecording' | translate"
|
||||
(click)="startRecording()">
|
||||
<ion-icon slot="icon-only" name="fas-microphone"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<div *ngIf="status.startsWith('recording')" class="core-audio-recorder--wrapper">
|
||||
<core-audio-histogram *ngIf="(histogramAnalyzer$ | async) as analyser" [analyser]="analyser"
|
||||
[paused]="status !== 'recording-ongoing'">
|
||||
</core-audio-histogram>
|
||||
|
||||
<div class="core-audio-recorder--controls">
|
||||
<div class="core-audio-recorder--control chrono">
|
||||
<div *ngIf="status === 'recording-ongoing'" class="core-audio-recorder--recording-marker"></div>
|
||||
<core-chrono [class.recording]="status === 'recording-ongoing'" [running]="status === 'recording-ongoing'" [hours]="false">
|
||||
</core-chrono>
|
||||
</div>
|
||||
|
||||
<div class="core-audio-recorder--control">
|
||||
<ion-button *ngIf="status === 'recording-ongoing'" shape="round" fill="clear"
|
||||
[attr.aria-label]="'core.fileuploader.pauserecording' | translate" (click)="pauseRecording()">
|
||||
<ion-icon slot="icon-only" name="fas-pause-circle"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<ion-button *ngIf="status === 'recording-paused'" [attr.aria-label]="'core.fileuploader.resumerecording' | translate"
|
||||
shape="round" fill="clear" color="danger" (click)="resumeRecording()">
|
||||
<ion-icon slot="icon-only" name="fas-microphone"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<div class="core-audio-recorder--control">
|
||||
<ion-button shape="round" fill="clear" [attr.aria-label]="'core.fileuploader.stoprecording' | translate"
|
||||
(click)="stopRecording()">
|
||||
<ion-icon slot="icon-only" name="fa-check"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="status === 'done'" class="core-audio-recorder--wrapper">
|
||||
<audio *ngIf="(recordingUrl$ | async) as recordingUrl" controls controlsList="nodownload">
|
||||
<source [src]="recordingUrl" />
|
||||
</audio>
|
||||
|
||||
<div class="core-audio-recorder--controls">
|
||||
<div class="core-audio-recorder--control">
|
||||
<ion-button shape="round" fill="clear" color="danger" [attr.aria-label]="'core.fileuploader.discardrecording' | translate"
|
||||
(click)="discardRecording()">
|
||||
<ion-icon slot="icon-only" name="fas-trash"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<div class="core-audio-recorder--control">
|
||||
<ion-button (click)="submit()">
|
||||
{{ 'core.save' | translate }}
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
|
@ -0,0 +1,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 {}
|
|
@ -0,0 +1,119 @@
|
|||
:host {
|
||||
color: var(--ion-text-color, #000);
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: 0.15px;
|
||||
}
|
||||
|
||||
ion-button {
|
||||
--padding-start: 0;
|
||||
--padding-end: 0;
|
||||
--icon-size: 1.8em;
|
||||
|
||||
// Offset padding for visual alignment.
|
||||
margin: calc((var(--icon-size) - var(--a11y-min-target-size)) / 2);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
hr {
|
||||
background: var(--gray-300);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.core-audio-recorder--wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.25px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
ion-button[shape="round"] {
|
||||
--border-radius: 99px;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
--padding-top: 16px;
|
||||
--padding-bottom: 16px;
|
||||
|
||||
height: max-content;
|
||||
}
|
||||
|
||||
core-audio-histogram {
|
||||
width: 100%;
|
||||
height: 35px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
audio {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.core-audio-recorder--controls {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.core-audio-recorder--control {
|
||||
width: 33%;
|
||||
text-align: center;
|
||||
|
||||
&:first-child {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
&.chrono {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
ion-button {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.core-audio-recorder--recording-marker {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-inline-end: 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
core-chrono.recording {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
import { isInitAudioEncoderMessage } from '@features/fileuploader/utils/worker-messages';
|
||||
import { initMp3MediaEncoder } from 'mp3-mediarecorder/worker';
|
||||
|
||||
/**
|
||||
* Handle worker message.
|
||||
*
|
||||
* @param event Worker message event.
|
||||
*/
|
||||
function onMessage(event: MessageEvent): void {
|
||||
if (!isInitAudioEncoderMessage(event.data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeEventListener('message', onMessage);
|
||||
initMp3MediaEncoder(event.data.config);
|
||||
}
|
||||
|
||||
addEventListener('message', onMessage);
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/worker",
|
||||
"lib": [
|
||||
"es2018",
|
||||
"webworker"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.worker.ts"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue