Merge pull request #3556 from NoelDeMartin/MOBILE-2314
MOBILE-2314: Audio Recorder improvementsmain
commit
4cb9a6640c
|
@ -6,6 +6,8 @@ WORKDIR /app
|
||||||
# Prepare node dependencies
|
# Prepare node dependencies
|
||||||
RUN apt-get update && apt-get install libsecret-1-0 -y
|
RUN apt-get update && apt-get install libsecret-1-0 -y
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
COPY patches ./patches
|
||||||
|
RUN echo "unsafe-perm=true" > ./.npmrc
|
||||||
RUN npm ci --no-audit
|
RUN npm ci --no-audit
|
||||||
|
|
||||||
# Build source
|
# Build source
|
||||||
|
|
|
@ -42,7 +42,8 @@
|
||||||
"input": "src/theme/theme.scss"
|
"input": "src/theme/theme.scss"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": [],
|
||||||
|
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
|
@ -50,6 +51,10 @@
|
||||||
{
|
{
|
||||||
"replace": "src/testing/testing.module.ts",
|
"replace": "src/testing/testing.module.ts",
|
||||||
"with": "src/testing/testing.module.prod.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": {
|
"optimization": {
|
||||||
|
|
|
@ -196,11 +196,6 @@
|
||||||
<param name="android-package" value="com.adobe.phonegap.push.PushPlugin" />
|
<param name="android-package" value="com.adobe.phonegap.push.PushPlugin" />
|
||||||
</feature>
|
</feature>
|
||||||
</config-file>
|
</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">
|
<config-file parent="/*" target="AndroidManifest.xml">
|
||||||
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
|
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
|
||||||
</config-file>
|
</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": {
|
"@ionic-native/media-capture": {
|
||||||
"version": "5.36.0",
|
"version": "5.36.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ionic-native/media-capture/-/media-capture-5.36.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/cordova-plugin-ionic-keyboard/-/cordova-plugin-ionic-keyboard-2.2.0.tgz",
|
||||||
"integrity": "sha512-yDUG+9ieKVRitq5mGlNxjaZh/MgEhFFIgTIPhqSbUaQ8UuZbawy5mhJAVClqY97q8/rcQtL6dCDa7x2sEtCLcA=="
|
"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": {
|
"cordova-plugin-media-capture": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/cordova-plugin-media-capture/-/cordova-plugin-media-capture-3.0.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
|
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
|
||||||
},
|
},
|
||||||
|
"event-target-shim": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA=="
|
||||||
|
},
|
||||||
"eventemitter3": {
|
"eventemitter3": {
|
||||||
"version": "4.0.7",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
@ -23834,6 +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": {
|
"mpd-parser": {
|
||||||
"version": "0.22.1",
|
"version": "0.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.22.1.tgz",
|
||||||
|
@ -33487,6 +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": {
|
"w3c-hr-time": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
"build": "ionic build",
|
"build": "ionic build",
|
||||||
"build:prod": "NODE_ENV=production ionic build --prod",
|
"build:prod": "NODE_ENV=production ionic build --prod",
|
||||||
"build:test": "NODE_ENV=testing ionic build --configuration=testing",
|
"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",
|
"dev:ios": "ionic cordova run ios",
|
||||||
"prod:android": "NODE_ENV=production ionic cordova run android --prod",
|
"prod:android": "NODE_ENV=production ionic cordova run android --prod",
|
||||||
"prod:ios": "NODE_ENV=production ionic cordova run ios --prod",
|
"prod:ios": "NODE_ENV=production ionic cordova run ios --prod",
|
||||||
|
@ -63,7 +63,6 @@
|
||||||
"@ionic-native/ionic-webview": "5.36.0",
|
"@ionic-native/ionic-webview": "5.36.0",
|
||||||
"@ionic-native/keyboard": "5.36.0",
|
"@ionic-native/keyboard": "5.36.0",
|
||||||
"@ionic-native/local-notifications": "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/media-capture": "5.36.0",
|
||||||
"@ionic-native/network": "5.36.0",
|
"@ionic-native/network": "5.36.0",
|
||||||
"@ionic-native/push": "5.36.0",
|
"@ionic-native/push": "5.36.0",
|
||||||
|
@ -104,7 +103,6 @@
|
||||||
"cordova-plugin-file": "6.0.2",
|
"cordova-plugin-file": "6.0.2",
|
||||||
"cordova-plugin-geolocation": "4.1.0",
|
"cordova-plugin-geolocation": "4.1.0",
|
||||||
"cordova-plugin-ionic-keyboard": "2.2.0",
|
"cordova-plugin-ionic-keyboard": "2.2.0",
|
||||||
"cordova-plugin-media": "5.0.4",
|
|
||||||
"cordova-plugin-media-capture": "3.0.3",
|
"cordova-plugin-media-capture": "3.0.3",
|
||||||
"cordova-plugin-network-information": "3.0.0",
|
"cordova-plugin-network-information": "3.0.0",
|
||||||
"cordova-plugin-prevent-override": "1.0.1",
|
"cordova-plugin-prevent-override": "1.0.1",
|
||||||
|
@ -122,6 +120,7 @@
|
||||||
"mathjax": "2.7.9",
|
"mathjax": "2.7.9",
|
||||||
"moment": "2.29.4",
|
"moment": "2.29.4",
|
||||||
"moment-timezone": "0.5.38",
|
"moment-timezone": "0.5.38",
|
||||||
|
"mp3-mediarecorder": "^4.0.5",
|
||||||
"nl.kingsquare.cordova.background-audio": "1.0.1",
|
"nl.kingsquare.cordova.background-audio": "1.0.1",
|
||||||
"ogv": "1.8.9",
|
"ogv": "1.8.9",
|
||||||
"rxjs": "6.5.5",
|
"rxjs": "6.5.5",
|
||||||
|
@ -224,9 +223,6 @@
|
||||||
"ANDROID_SUPPORT_V4_VERSION": "26.+"
|
"ANDROID_SUPPORT_V4_VERSION": "26.+"
|
||||||
},
|
},
|
||||||
"cordova-plugin-media-capture": {},
|
"cordova-plugin-media-capture": {},
|
||||||
"cordova-plugin-media": {
|
|
||||||
"KEEP_AVAUDIOSESSION_ALWAYS_ACTIVE": "NO"
|
|
||||||
},
|
|
||||||
"cordova-plugin-network-information": {},
|
"cordova-plugin-network-information": {},
|
||||||
"@moodlehq/cordova-plugin-qrscanner": {},
|
"@moodlehq/cordova-plugin-qrscanner": {},
|
||||||
"cordova-plugin-splashscreen": {},
|
"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/SVG': '/lib/mathjax/jax/output/SVG',
|
||||||
'/node_modules/mathjax/jax/output/PreviewHTML': '/lib/mathjax/jax/output/PreviewHTML',
|
'/node_modules/mathjax/jax/output/PreviewHTML': '/lib/mathjax/jax/output/PreviewHTML',
|
||||||
'/node_modules/mathjax/localization': '/lib/mathjax/localization',
|
'/node_modules/mathjax/localization': '/lib/mathjax/localization',
|
||||||
|
'/node_modules/mp3-mediarecorder/dist/vmsg.wasm': '/lib/vmsg/vmsg.wasm',
|
||||||
'/src/core/features/h5p/assets': '/lib/h5p',
|
'/src/core/features/h5p/assets': '/lib/h5p',
|
||||||
'/node_modules/ogv/dist': '/lib/ogv',
|
'/node_modules/ogv/dist': '/lib/ogv',
|
||||||
'/node_modules/video.js/dist/video-js.min.css': '/lib/video.js/video-js.min.css',
|
'/node_modules/video.js/dist/video-js.min.css': '/lib/video.js/video-js.min.css',
|
||||||
|
|
|
@ -1742,9 +1742,11 @@
|
||||||
"core.filenotfound": "resource",
|
"core.filenotfound": "resource",
|
||||||
"core.fileuploader.addfiletext": "repository",
|
"core.fileuploader.addfiletext": "repository",
|
||||||
"core.fileuploader.audio": "local_moodlemobileapp",
|
"core.fileuploader.audio": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.audiotitle": "tiny_recordrtc",
|
||||||
"core.fileuploader.camera": "local_moodlemobileapp",
|
"core.fileuploader.camera": "local_moodlemobileapp",
|
||||||
"core.fileuploader.confirmuploadfile": "local_moodlemobileapp",
|
"core.fileuploader.confirmuploadfile": "local_moodlemobileapp",
|
||||||
"core.fileuploader.confirmuploadunknownsize": "local_moodlemobileapp",
|
"core.fileuploader.confirmuploadunknownsize": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.discardrecording": "local_moodlemobileapp",
|
||||||
"core.fileuploader.errorcapturingaudio": "local_moodlemobileapp",
|
"core.fileuploader.errorcapturingaudio": "local_moodlemobileapp",
|
||||||
"core.fileuploader.errorcapturingimage": "local_moodlemobileapp",
|
"core.fileuploader.errorcapturingimage": "local_moodlemobileapp",
|
||||||
"core.fileuploader.errorcapturingvideo": "local_moodlemobileapp",
|
"core.fileuploader.errorcapturingvideo": "local_moodlemobileapp",
|
||||||
|
@ -1758,11 +1760,18 @@
|
||||||
"core.fileuploader.fileuploaded": "local_moodlemobileapp",
|
"core.fileuploader.fileuploaded": "local_moodlemobileapp",
|
||||||
"core.fileuploader.invalidfiletype": "repository",
|
"core.fileuploader.invalidfiletype": "repository",
|
||||||
"core.fileuploader.maxbytesfile": "local_moodlemobileapp",
|
"core.fileuploader.maxbytesfile": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.microphonepermissiondenied": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.microphonepermissionrestricted": "local_moodlemobileapp",
|
||||||
"core.fileuploader.more": "data",
|
"core.fileuploader.more": "data",
|
||||||
|
"core.fileuploader.pauserecording": "local_moodlemobileapp",
|
||||||
"core.fileuploader.photoalbums": "local_moodlemobileapp",
|
"core.fileuploader.photoalbums": "local_moodlemobileapp",
|
||||||
"core.fileuploader.readingfile": "local_moodlemobileapp",
|
"core.fileuploader.readingfile": "local_moodlemobileapp",
|
||||||
"core.fileuploader.readingfileperc": "local_moodlemobileapp",
|
"core.fileuploader.readingfileperc": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.resumerecording": "local_moodlemobileapp",
|
||||||
"core.fileuploader.selectafile": "local_moodlemobileapp",
|
"core.fileuploader.selectafile": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.startrecording": "tiny_recordrtc",
|
||||||
|
"core.fileuploader.startrecordinginstructions": "local_moodlemobileapp",
|
||||||
|
"core.fileuploader.stoprecording": "tiny_recordrtc",
|
||||||
"core.fileuploader.uploadafile": "local_moodlemobileapp",
|
"core.fileuploader.uploadafile": "local_moodlemobileapp",
|
||||||
"core.fileuploader.uploading": "local_moodlemobileapp",
|
"core.fileuploader.uploading": "local_moodlemobileapp",
|
||||||
"core.fileuploader.uploadingperc": "local_moodlemobileapp",
|
"core.fileuploader.uploadingperc": "local_moodlemobileapp",
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AddonModDataEntryField,
|
AddonModDataEntryField,
|
||||||
AddonModDataField,
|
AddonModDataField,
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data';
|
import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data';
|
||||||
import { AddonModDataFieldPluginBaseComponent } from '@addons/mod/data/classes/base-field-plugin-component';
|
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.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data';
|
import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data';
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { CoreFileEntry, CoreFileHelper } from '@services/file-helper';
|
import { CoreFileEntry, CoreFileHelper } from '@services/file-helper';
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component';
|
import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component';
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { AddonModDataEntryField } from '@addons/mod/data/services/data';
|
import { AddonModDataEntryField } from '@addons/mod/data/services/data';
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component';
|
import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component';
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
import { AddonQbehaviourAdaptiveModule } from './adaptive/adaptive.module';
|
import { AddonQbehaviourAdaptiveModule } from './adaptive/adaptive.module';
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
import { AddonUserProfileFieldCheckboxModule } from './checkbox/checkbox.module';
|
import { AddonUserProfileFieldCheckboxModule } from './checkbox/checkbox.module';
|
||||||
|
|
|
@ -14,6 +14,8 @@
|
||||||
|
|
||||||
import { CoreError } from './error';
|
import { CoreError } from './error';
|
||||||
|
|
||||||
|
export const CAPTURE_ERROR_NO_MEDIA_FILES = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capture error.
|
* 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() startTime = 0; // Number of milliseconds to put in the chrono before starting.
|
||||||
@Input() endTime?: number; // Number of milliseconds to stop the chrono.
|
@Input() endTime?: number; // Number of milliseconds to stop the chrono.
|
||||||
@Input() reset?: boolean; // Set it to true to reset the chrono.
|
@Input() reset?: boolean; // Set it to true to reset the chrono.
|
||||||
|
@Input() hours = true;
|
||||||
@Output() onEnd: EventEmitter<void>; // Will emit an event when the endTime is reached.
|
@Output() onEnd: EventEmitter<void>; // Will emit an event when the endTime is reached.
|
||||||
|
|
||||||
time = 0;
|
time = 0;
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
<span role="timer">{{ time / 1000 | coreSecondsToHMS }}</span>
|
<span role="timer">{{ time / 1000 | coreSecondsToHMS:hours }}</span>
|
||||||
|
|
|
@ -64,6 +64,7 @@ import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe-
|
||||||
import { CoreMessageComponent } from './message/message';
|
import { CoreMessageComponent } from './message/message';
|
||||||
import { CoreGroupSelectorComponent } from './group-selector/group-selector';
|
import { CoreGroupSelectorComponent } from './group-selector/group-selector';
|
||||||
import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-button-modal';
|
import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-button-modal';
|
||||||
|
import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
|
@ -110,6 +111,7 @@ import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-
|
||||||
CoreHorizontalScrollControlsComponent,
|
CoreHorizontalScrollControlsComponent,
|
||||||
CoreSwipeNavigationTourComponent,
|
CoreSwipeNavigationTourComponent,
|
||||||
CoreRefreshButtonModalComponent,
|
CoreRefreshButtonModalComponent,
|
||||||
|
CoreSheetModalComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
@ -163,6 +165,7 @@ import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-
|
||||||
CoreHorizontalScrollControlsComponent,
|
CoreHorizontalScrollControlsComponent,
|
||||||
CoreSwipeNavigationTourComponent,
|
CoreSwipeNavigationTourComponent,
|
||||||
CoreRefreshButtonModalComponent,
|
CoreRefreshButtonModalComponent,
|
||||||
|
CoreSheetModalComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CoreComponentsModule {}
|
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">
|
<core-loading [hideUntil]="readyToCapture">
|
||||||
<div class="core-av-wrapper">
|
<div class="core-av-wrapper">
|
||||||
<!-- Video stream for image and video. -->
|
<!-- 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. -->
|
<!-- 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
|
<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>
|
<canvas *ngIf="isImage" class="core-webcam-image-canvas" #imgCanvas></canvas>
|
||||||
<img *ngIf="isImage" [hidden]="!hasCaptured" class="core-webcam-image" alt="{{ 'core.capturedimage' | translate }}"
|
<img *ngIf="isImage" [hidden]="!hasCaptured" class="core-webcam-image" alt="{{ 'core.capturedimage' | translate }}"
|
||||||
#previewImage>
|
#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>
|
</div>
|
||||||
</core-loading>
|
</core-loading>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
@ -48,8 +33,7 @@
|
||||||
<ion-row>
|
<ion-row>
|
||||||
<ion-col></ion-col>
|
<ion-col></ion-col>
|
||||||
<ion-col class="ion-text-center">
|
<ion-col class="ion-text-center">
|
||||||
<ion-button fill="clear" *ngIf="!hasCaptured && !isCordovaAudioCapture" (click)="actionClicked()" [attr.aria-label]="title">
|
<ion-button fill="clear" *ngIf="!hasCaptured" (click)="actionClicked()" [attr.aria-label]="title">
|
||||||
<ion-icon *ngIf="!isCapturing && isAudio" name="fas-microphone" slot="icon-only" aria-hidden="true"></ion-icon>
|
|
||||||
<ion-icon *ngIf="!isCapturing && isVideo" name="fas-video" slot="icon-only" aria-hidden="true"></ion-icon>
|
<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="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>
|
<ion-icon *ngIf="isCapturing" name="fas-square" slot="icon-only" aria-hidden="true"></ion-icon>
|
||||||
|
|
|
@ -21,30 +21,6 @@
|
||||||
width: 100%;
|
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 {
|
video, img {
|
||||||
|
|
|
@ -13,23 +13,20 @@
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, ChangeDetectorRef, Input } from '@angular/core';
|
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 { MediaFile } from '@ionic-native/media-capture/ngx';
|
||||||
|
|
||||||
import { CoreFile, CoreFileProvider } from '@services/file';
|
import { CoreFile, CoreFileProvider } from '@services/file';
|
||||||
import { CoreDomUtils } from '@services/utils/dom';
|
import { CoreDomUtils } from '@services/utils/dom';
|
||||||
import { CoreMimetypeUtils } from '@services/utils/mimetype';
|
import { CoreMimetypeUtils } from '@services/utils/mimetype';
|
||||||
import { CoreTimeUtils } from '@services/utils/time';
|
import { CoreTimeUtils } from '@services/utils/time';
|
||||||
import { ModalController, Media, Translate } from '@singletons';
|
import { ModalController, Translate } from '@singletons';
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreError } from '@classes/errors/error';
|
||||||
import { CoreCaptureError } from '@classes/errors/captureerror';
|
import { CoreCaptureError } from '@classes/errors/captureerror';
|
||||||
import { CoreCanceledError } from '@classes/errors/cancelederror';
|
import { CoreCanceledError } from '@classes/errors/cancelederror';
|
||||||
import { CorePath } from '@singletons/path';
|
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({
|
@Component({
|
||||||
selector: 'core-emulator-capture-media',
|
selector: 'core-emulator-capture-media',
|
||||||
|
@ -38,7 +35,7 @@ import { CorePlatform } from '@services/platform';
|
||||||
})
|
})
|
||||||
export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
|
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() maxTime?: number; // Max time to capture.
|
||||||
@Input() facingMode?: string; // Camera facing mode.
|
@Input() facingMode?: string; // Camera facing mode.
|
||||||
@Input() mimetype?: string;
|
@Input() mimetype?: string;
|
||||||
|
@ -50,30 +47,20 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
|
||||||
@ViewChild('previewVideo') previewVideo?: ElementRef;
|
@ViewChild('previewVideo') previewVideo?: ElementRef;
|
||||||
@ViewChild('imgCanvas') imgCanvas?: ElementRef;
|
@ViewChild('imgCanvas') imgCanvas?: ElementRef;
|
||||||
@ViewChild('previewImage') previewImage?: ElementRef;
|
@ViewChild('previewImage') previewImage?: ElementRef;
|
||||||
@ViewChild('streamAudio') streamAudio?: ElementRef;
|
|
||||||
@ViewChild('previewAudio') previewAudio?: ElementRef;
|
|
||||||
|
|
||||||
title?: string; // The title of the page.
|
title?: string; // The title of the page.
|
||||||
isAudio?: boolean; // Whether it should capture audio.
|
|
||||||
isVideo?: boolean; // Whether it should capture video.
|
isVideo?: boolean; // Whether it should capture video.
|
||||||
isImage?: boolean; // Whether it should capture image.
|
isImage?: boolean; // Whether it should capture image.
|
||||||
readyToCapture?: boolean; // Whether it's ready to capture.
|
readyToCapture?: boolean; // Whether it's ready to capture.
|
||||||
hasCaptured?: boolean; // Whether it has captured something.
|
hasCaptured?: boolean; // Whether it has captured something.
|
||||||
isCapturing?: boolean; // Whether it's capturing.
|
isCapturing?: boolean; // Whether it's capturing.
|
||||||
resetChrono?: boolean; // Boolean to reset the chrono.
|
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 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 mediaRecorder?: MediaRecorder; // To record video.
|
||||||
protected previewMedia?: HTMLAudioElement | HTMLVideoElement; // The element to preview the audio/video captured.
|
protected previewMedia?: HTMLVideoElement; // The element to preview the video captured.
|
||||||
protected mediaBlob?: Blob; // A Blob where the captured data is stored.
|
protected mediaBlob?: Blob; // A Blob where the captured data is stored.
|
||||||
protected localMediaStream?: MediaStream;
|
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(
|
constructor(
|
||||||
protected changeDetectorRef: ChangeDetectorRef,
|
protected changeDetectorRef: ChangeDetectorRef,
|
||||||
|
@ -84,12 +71,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
|
||||||
*/
|
*/
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.initVariables();
|
this.initVariables();
|
||||||
|
this.initHtmlCapture();
|
||||||
if (this.isCordovaAudioCapture) {
|
|
||||||
this.initCordovaMediaPlugin();
|
|
||||||
} else {
|
|
||||||
this.initHtmlCapture();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -108,71 +90,10 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
|
||||||
if (this.type == 'video') {
|
if (this.type == 'video') {
|
||||||
this.isVideo = true;
|
this.isVideo = true;
|
||||||
this.title = 'core.capturevideo';
|
this.title = 'core.capturevideo';
|
||||||
} else if (this.type == 'audio') {
|
|
||||||
this.isAudio = true;
|
|
||||||
this.title = 'core.captureaudio';
|
|
||||||
} else if (this.type == 'image') {
|
} else if (this.type == 'image') {
|
||||||
this.isImage = true;
|
this.isImage = true;
|
||||||
this.title = 'core.captureimage';
|
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> {
|
protected async initHtmlCapture(): Promise<void> {
|
||||||
const constraints = {
|
const constraints = {
|
||||||
video: this.isAudio ? false : { facingMode: this.facingMode },
|
video: { facingMode: this.facingMode },
|
||||||
audio: !this.isImage,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -196,22 +116,18 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
|
||||||
if (!this.isImage) {
|
if (!this.isImage) {
|
||||||
if (this.isVideo) {
|
if (this.isVideo) {
|
||||||
this.previewMedia = this.previewVideo?.nativeElement;
|
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 });
|
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 => {
|
this.mediaRecorder.ondataavailable = (e): void => {
|
||||||
if (e.data.size > 0) {
|
if (e.data.size > 0) {
|
||||||
chunks.push(e.data);
|
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.mediaRecorder.onstop = (): void => {
|
||||||
this.mediaBlob = new Blob(chunks);
|
this.mediaBlob = new Blob(chunks);
|
||||||
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.
|
* Main action clicked: record or stop recording.
|
||||||
*/
|
*/
|
||||||
|
@ -369,15 +200,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
|
||||||
this.isCapturing = true;
|
this.isCapturing = true;
|
||||||
this.resetChrono = false;
|
this.resetChrono = false;
|
||||||
|
|
||||||
if (this.isCordovaAudioCapture) {
|
this.mediaRecorder?.start();
|
||||||
this.mediaFile?.startRecord();
|
|
||||||
if (this.previewMedia) {
|
|
||||||
this.previewMedia.src = '';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.mediaRecorder?.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.changeDetectorRef.detectChanges();
|
this.changeDetectorRef.detectChanges();
|
||||||
} else {
|
} else {
|
||||||
if (!this.imgCanvas) {
|
if (!this.imgCanvas) {
|
||||||
|
@ -419,11 +242,6 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
|
||||||
|
|
||||||
// Send a "cancelled" error like the Cordova plugin does.
|
// Send a "cancelled" error like the Cordova plugin does.
|
||||||
this.dismissWithCanceledError('Canceled.', 'Camera cancelled');
|
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> {
|
async discard(): Promise<void> {
|
||||||
this.previewMedia?.pause();
|
this.previewMedia?.pause();
|
||||||
this.streamVideo?.nativeElement.play();
|
this.streamVideo?.nativeElement.play();
|
||||||
this.audioDrawer?.start();
|
|
||||||
|
|
||||||
if (this.isCordovaAudioCapture) {
|
|
||||||
await this.resetCordovaMediaCapture();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hasCaptured = false;
|
this.hasCaptured = false;
|
||||||
this.isCapturing = false;
|
this.isCapturing = false;
|
||||||
|
@ -492,30 +305,23 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.mediaBlob && !this.isCordovaAudioCapture) {
|
if (!this.mediaBlob) {
|
||||||
// Shouldn't happen.
|
// Shouldn't happen.
|
||||||
CoreDomUtils.showErrorModal('Please capture the media first.');
|
CoreDomUtils.showErrorModal('Please capture the media first.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileEntry = this.fileEntry;
|
|
||||||
const loadingModal = await CoreDomUtils.showModalLoading();
|
const loadingModal = await CoreDomUtils.showModalLoading();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!this.isCordovaAudioCapture) {
|
// Capturing in browser. Write the blob in a file.
|
||||||
// Capturing in browser. Write the blob in a file.
|
if (!this.mediaBlob) {
|
||||||
if (!this.mediaBlob) {
|
// Shouldn't happen.
|
||||||
// Shouldn't happen.
|
throw new Error('Please capture the media first.');
|
||||||
throw new Error('Please capture the media first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
fileEntry = await CoreFile.writeFile(this.getFilePath(), this.mediaBlob);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fileEntry) {
|
const fileEntry = await CoreFile.writeFile(this.getFilePath(), this.mediaBlob);
|
||||||
throw new CoreError('File not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isImage && !this.isCaptureImage) {
|
if (this.isImage && !this.isCaptureImage) {
|
||||||
this.dismissWithData(fileEntry.toURL());
|
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 {
|
stopCapturing(): void {
|
||||||
this.isCapturing = false;
|
this.isCapturing = false;
|
||||||
this.hasCaptured = true;
|
this.hasCaptured = true;
|
||||||
|
|
||||||
if (this.isCordovaAudioCapture) {
|
this.streamVideo && this.streamVideo.nativeElement.pause();
|
||||||
this.mediaFile?.stopRecord();
|
this.mediaRecorder && this.mediaRecorder.stop();
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page destroyed.
|
* Page destroyed.
|
||||||
*/
|
*/
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.mediaFile?.release();
|
|
||||||
|
|
||||||
if (this.localMediaStream) {
|
if (this.localMediaStream) {
|
||||||
const tracks = this.localMediaStream.getTracks();
|
const tracks = this.localMediaStream.getTracks();
|
||||||
tracks.forEach((track) => {
|
tracks.forEach((track) => {
|
||||||
|
@ -592,14 +388,13 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy {
|
||||||
}
|
}
|
||||||
this.streamVideo?.nativeElement.pause();
|
this.streamVideo?.nativeElement.pause();
|
||||||
this.previewMedia?.pause();
|
this.previewMedia?.pause();
|
||||||
this.audioDrawer?.stop();
|
|
||||||
delete this.mediaBlob;
|
delete this.mediaBlob;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CaptureMediaComponentInputs = {
|
export type CaptureMediaComponentInputs = {
|
||||||
type: 'audio' | 'video' | 'image' | 'captureimage';
|
type: 'video' | 'image' | 'captureimage';
|
||||||
maxTime?: number; // Max time to capture.
|
maxTime?: number; // Max time to capture.
|
||||||
facingMode?: string; // Camera facing mode.
|
facingMode?: string; // Camera facing mode.
|
||||||
mimetype?: string;
|
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 { Injectable } from '@angular/core';
|
||||||
import { CameraOptions } from '@ionic-native/camera/ngx';
|
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 { CoreMimetypeUtils } from '@services/utils/mimetype';
|
||||||
import { makeSingleton, ModalController } from '@singletons';
|
import { makeSingleton, ModalController } from '@singletons';
|
||||||
import { CaptureMediaComponentInputs, CoreEmulatorCaptureMediaComponent } from '../components/capture-media/capture-media';
|
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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class CoreEmulatorCaptureHelperProvider {
|
export class CoreEmulatorCaptureHelperProvider {
|
||||||
|
|
||||||
protected possibleAudioMimeTypes = {
|
|
||||||
'audio/webm': 'weba',
|
|
||||||
'audio/ogg': 'ogg',
|
|
||||||
};
|
|
||||||
|
|
||||||
protected possibleVideoMimeTypes = {
|
protected possibleVideoMimeTypes = {
|
||||||
'video/webm;codecs=vp9': 'webm',
|
'video/webm;codecs=vp9': 'webm',
|
||||||
'video/webm;codecs=vp8': 'webm',
|
'video/webm;codecs=vp8': 'webm',
|
||||||
|
@ -38,22 +33,20 @@ export class CoreEmulatorCaptureHelperProvider {
|
||||||
};
|
};
|
||||||
|
|
||||||
videoMimeType?: string;
|
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.
|
* @param options Optional options.
|
||||||
* @returns Promise resolved when captured, rejected if error.
|
* @returns Promise resolved when captured, rejected if error.
|
||||||
*/
|
*/
|
||||||
captureMedia(type: 'image', options?: MockCameraOptions): Promise<string>;
|
captureMedia(type: 'image', options?: MockCameraOptions): Promise<string>;
|
||||||
captureMedia(type: 'captureimage', options?: MockCaptureImageOptions): Promise<MediaFile[]>;
|
captureMedia(type: 'captureimage', options?: MockCaptureImageOptions): Promise<MediaFile[]>;
|
||||||
captureMedia(type: 'audio', options?: MockCaptureAudioOptions): Promise<MediaFile[]>;
|
|
||||||
captureMedia(type: 'video', options?: MockCaptureVideoOptions): Promise<MediaFile[]>;
|
captureMedia(type: 'video', options?: MockCaptureVideoOptions): Promise<MediaFile[]>;
|
||||||
async captureMedia(
|
async captureMedia(
|
||||||
type: 'image' | 'captureimage' | 'audio' | 'video',
|
type: 'image' | 'captureimage' | 'video',
|
||||||
options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureAudioOptions | MockCaptureVideoOptions,
|
options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureVideoOptions,
|
||||||
): Promise<MediaFile[] | string> {
|
): Promise<MediaFile[] | string> {
|
||||||
options = options || {};
|
options = options || {};
|
||||||
|
|
||||||
|
@ -67,10 +60,6 @@ export class CoreEmulatorCaptureHelperProvider {
|
||||||
const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes);
|
const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes);
|
||||||
params.mimetype = mimeAndExt.mimetype;
|
params.mimetype = mimeAndExt.mimetype;
|
||||||
params.extension = mimeAndExt.extension;
|
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') {
|
} else if (type == 'image') {
|
||||||
if ('sourceType' in options && options.sourceType !== undefined && options.sourceType != 1) {
|
if ('sourceType' in options && options.sourceType !== undefined && options.sourceType != 1) {
|
||||||
return Promise.reject('This source type is not supported in browser.');
|
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.
|
* 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.
|
* @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported.
|
||||||
* @returns An object with mimetype and extension to use.
|
* @returns An object with mimetype and extension to use.
|
||||||
*/
|
*/
|
||||||
|
@ -148,10 +137,6 @@ export class CoreEmulatorCaptureHelperProvider {
|
||||||
// No mimetype found, use default extension.
|
// No mimetype found, use default extension.
|
||||||
result.mimetype = this.videoMimeType;
|
result.mimetype = this.videoMimeType;
|
||||||
result.extension = this.possibleVideoMimeTypes[result.mimetype!];
|
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;
|
return result;
|
||||||
|
@ -170,20 +155,12 @@ export class CoreEmulatorCaptureHelperProvider {
|
||||||
* Initialize the mimetypes to use when capturing.
|
* Initialize the mimetypes to use when capturing.
|
||||||
*/
|
*/
|
||||||
protected initMimeTypes(): void {
|
protected initMimeTypes(): void {
|
||||||
// Determine video and audio mimetype to use.
|
|
||||||
for (const mimeType in this.possibleVideoMimeTypes) {
|
for (const mimeType in this.possibleVideoMimeTypes) {
|
||||||
if (window.MediaRecorder.isTypeSupported(mimeType)) {
|
if (window.MediaRecorder.isTypeSupported(mimeType)) {
|
||||||
this.videoMimeType = mimeType;
|
this.videoMimeType = mimeType;
|
||||||
break;
|
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 {
|
export interface MockCaptureImageOptions extends CaptureImageOptions {
|
||||||
mimetypes?: string[]; // Allowed mimetypes.
|
mimetypes?: string[]; // Allowed mimetypes.
|
||||||
}
|
}
|
||||||
export interface MockCaptureAudioOptions extends CaptureAudioOptions {
|
|
||||||
mimetypes?: string[]; // Allowed mimetypes.
|
|
||||||
}
|
|
||||||
export interface MockCaptureVideoOptions extends CaptureVideoOptions {
|
export interface MockCaptureVideoOptions extends CaptureVideoOptions {
|
||||||
mimetypes?: string[]; // Allowed mimetypes.
|
mimetypes?: string[]; // Allowed mimetypes.
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,6 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
MediaCapture,
|
MediaCapture,
|
||||||
CaptureAudioOptions,
|
|
||||||
CaptureImageOptions,
|
CaptureImageOptions,
|
||||||
CaptureVideoOptions,
|
CaptureVideoOptions,
|
||||||
MediaFile,
|
MediaFile,
|
||||||
|
@ -29,16 +28,6 @@ import { CoreEmulatorCaptureHelper } from './capture-helper';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaCaptureMock extends MediaCapture {
|
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.
|
* 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",
|
"addfiletext": "Add file",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
|
"audiotitle": "Record audio",
|
||||||
"camera": "Camera",
|
"camera": "Camera",
|
||||||
"confirmuploadfile": "You are about to upload {{size}}. Are you sure you want to continue?",
|
"confirmuploadfile": "You are about to upload {{size}}. Are you sure you want to continue?",
|
||||||
"confirmuploadunknownsize": "It was not possible to calculate the size of the upload. Are you sure you want to continue?",
|
"confirmuploadunknownsize": "It was not possible to calculate the size of the upload. Are you sure you want to continue?",
|
||||||
|
"discardrecording": "Discard recording",
|
||||||
"errorcapturingaudio": "Error capturing audio.",
|
"errorcapturingaudio": "Error capturing audio.",
|
||||||
"errorcapturingimage": "Error capturing image.",
|
"errorcapturingimage": "Error capturing image.",
|
||||||
"errorcapturingvideo": "Error capturing video.",
|
"errorcapturingvideo": "Error capturing video.",
|
||||||
|
@ -17,13 +19,20 @@
|
||||||
"filesofthesetypes": "Accepted file types:",
|
"filesofthesetypes": "Accepted file types:",
|
||||||
"invalidfiletype": "{{$a}} filetype cannot be accepted.",
|
"invalidfiletype": "{{$a}} filetype cannot be accepted.",
|
||||||
"maxbytesfile": "The file {{$a.file}} is too large. The maximum size you can upload is {{$a.size}}.",
|
"maxbytesfile": "The file {{$a.file}} is too large. The maximum size you can upload is {{$a.size}}.",
|
||||||
|
"microphonepermissiondenied": "Permission to access the microphone has been denied.",
|
||||||
|
"microphonepermissionrestricted": "Microphone access is restricted.",
|
||||||
"more": "More",
|
"more": "More",
|
||||||
|
"pauserecording": "Pause recording",
|
||||||
"photoalbums": "Photo albums",
|
"photoalbums": "Photo albums",
|
||||||
"readingfile": "Reading file",
|
"readingfile": "Reading file",
|
||||||
"readingfileperc": "Reading file: {{$a}}%",
|
"readingfileperc": "Reading file: {{$a}}%",
|
||||||
|
"resumerecording": "Resume recording",
|
||||||
"selectafile": "Select a file",
|
"selectafile": "Select a file",
|
||||||
|
"startrecording": "Start recording",
|
||||||
|
"startrecordinginstructions": "Tap to start recording",
|
||||||
|
"stoprecording": "Stop recording",
|
||||||
"uploadafile": "Upload a file",
|
"uploadafile": "Upload a file",
|
||||||
"uploading": "Uploading",
|
"uploading": "Uploading",
|
||||||
"uploadingperc": "Uploading: {{$a}}%",
|
"uploadingperc": "Uploading: {{$a}}%",
|
||||||
"video": "Video"
|
"video": "Video"
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,9 +29,14 @@ import { makeSingleton, Translate, Camera, Chooser, ActionSheetController } from
|
||||||
import { CoreLogger } from '@singletons/logger';
|
import { CoreLogger } from '@singletons/logger';
|
||||||
import { CoreCanceledError } from '@classes/errors/cancelederror';
|
import { CoreCanceledError } from '@classes/errors/cancelederror';
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreError } from '@classes/errors/error';
|
||||||
import { CoreFileUploader, CoreFileUploaderProvider, CoreFileUploaderOptions } from './fileuploader';
|
import {
|
||||||
|
CoreFileUploader,
|
||||||
|
CoreFileUploaderProvider,
|
||||||
|
CoreFileUploaderOptions,
|
||||||
|
CoreFileUploaderAudioRecording,
|
||||||
|
} from './fileuploader';
|
||||||
import { CoreFileUploaderDelegate } from './fileuploader-delegate';
|
import { CoreFileUploaderDelegate } from './fileuploader-delegate';
|
||||||
import { CoreCaptureError } from '@classes/errors/captureerror';
|
import { CAPTURE_ERROR_NO_MEDIA_FILES, CoreCaptureError } from '@classes/errors/captureerror';
|
||||||
import { CoreIonLoadingElement } from '@classes/ion-loading';
|
import { CoreIonLoadingElement } from '@classes/ion-loading';
|
||||||
import { CoreWSUploadFileResult } from '@services/ws';
|
import { CoreWSUploadFileResult } from '@services/ws';
|
||||||
import { CoreSites } from '@services/sites';
|
import { CoreSites } from '@services/sites';
|
||||||
|
@ -466,9 +471,9 @@ export class CoreFileUploaderHelperProvider {
|
||||||
* @param defaultMessage Key of the default message to show.
|
* @param defaultMessage Key of the default message to show.
|
||||||
*/
|
*/
|
||||||
protected treatCaptureError(error: CoreCaptureError, defaultMessage: string): void {
|
protected treatCaptureError(error: CoreCaptureError, defaultMessage: string): void {
|
||||||
// Cancelled or error. If cancelled, error is an object with code = 3.
|
// Cancelled or error. If cancelled, error is an object with code = CAPTURE_EROR_NO_MEDIA_FILES.
|
||||||
if (error) {
|
if (error) {
|
||||||
if (error.code != 3) {
|
if (error.code !== CAPTURE_ERROR_NO_MEDIA_FILES) {
|
||||||
// Error, not cancelled.
|
// Error, not cancelled.
|
||||||
this.logger.error('Error while recording audio/video', error);
|
this.logger.error('Error while recording audio/video', error);
|
||||||
|
|
||||||
|
@ -514,7 +519,7 @@ export class CoreFileUploaderHelperProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
return new CoreError(error);
|
return new CoreError(error);
|
||||||
} else if ('code' in error && error.code == 3) {
|
} else if ('code' in error && error.code === CAPTURE_ERROR_NO_MEDIA_FILES) {
|
||||||
throw new CoreCanceledError();
|
throw new CoreCanceledError();
|
||||||
} else {
|
} else {
|
||||||
throw error;
|
throw error;
|
||||||
|
@ -539,34 +544,22 @@ export class CoreFileUploaderHelperProvider {
|
||||||
): Promise<CoreWSUploadFileResult | FileEntry> {
|
): Promise<CoreWSUploadFileResult | FileEntry> {
|
||||||
this.logger.debug('Trying to record a ' + (isAudio ? 'audio' : 'video') + ' file');
|
this.logger.debug('Trying to record a ' + (isAudio ? 'audio' : 'video') + ' file');
|
||||||
|
|
||||||
// The mimetypes param is only for browser, the Cordova plugin doesn't support it.
|
let media: MediaFile | CoreFileUploaderAudioRecording;
|
||||||
const captureOptions = { limit: 1, mimetypes: mimetypes };
|
|
||||||
let media: MediaFile;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const medias = isAudio ? await CoreFileUploader.captureAudio(captureOptions) :
|
const medias = isAudio
|
||||||
await CoreFileUploader.captureVideo(captureOptions);
|
? await CoreFileUploader.captureAudio()
|
||||||
|
: await CoreFileUploader.captureVideo({ limit: 1 });
|
||||||
|
|
||||||
media = medias[0]; // We used limit 1, we only want 1 media.
|
media = medias[0]; // We used limit 1, we only want 1 media.
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const defaultError = isAudio ? 'core.fileuploader.errorcapturingaudio' : 'core.fileuploader.errorcapturingvideo';
|
||||||
|
|
||||||
if (isAudio && this.isNoAppError(error) && CorePlatform.isMobile()) {
|
throw this.treatCaptureError(error, defaultError); // Throw the right error.
|
||||||
// No app to record audio, fallback to capture it ourselves.
|
|
||||||
try {
|
|
||||||
media = await CoreFileUploader.captureAudioInApp();
|
|
||||||
} catch (error) {
|
|
||||||
throw this.treatCaptureError(error, 'core.fileuploader.errorcapturingaudio'); // Throw the right error.
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
const defaultError = isAudio ? 'core.fileuploader.errorcapturingaudio' : 'core.fileuploader.errorcapturingvideo';
|
|
||||||
|
|
||||||
throw this.treatCaptureError(error, defaultError); // Throw the right error.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let path = media.fullPath;
|
let path = media.fullPath;
|
||||||
const error = CoreFileUploader.isInvalidMimetype(mimetypes, path); // Verify that the mimetype is supported.
|
const error = CoreFileUploader.isInvalidMimetype(mimetypes, media.fullPath);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
|
@ -773,7 +766,6 @@ export class CoreFileUploaderHelperProvider {
|
||||||
options: CoreFileUploaderOptions,
|
options: CoreFileUploaderOptions,
|
||||||
siteId?: string,
|
siteId?: string,
|
||||||
): Promise<CoreWSUploadFileResult> {
|
): Promise<CoreWSUploadFileResult> {
|
||||||
|
|
||||||
const errorStr = Translate.instant('core.error');
|
const errorStr = Translate.instant('core.error');
|
||||||
const retryStr = Translate.instant('core.retry');
|
const retryStr = Translate.instant('core.retry');
|
||||||
const uploadingStr = Translate.instant('core.fileuploader.uploading');
|
const uploadingStr = Translate.instant('core.fileuploader.uploading');
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { CameraOptions } from '@ionic-native/camera/ngx';
|
import { CameraOptions } from '@ionic-native/camera/ngx';
|
||||||
import { FileEntry } from '@ionic-native/file/ngx';
|
import { FileEntry } from '@ionic-native/file/ngx';
|
||||||
import { MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture/ngx';
|
import { MediaFile, CaptureError, CaptureVideoOptions } from '@ionic-native/media-capture/ngx';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
import { CoreFile, CoreFileProvider } from '@services/file';
|
import { CoreFile, CoreFileProvider } from '@services/file';
|
||||||
|
@ -25,14 +25,14 @@ import { CoreMimetypeUtils } from '@services/utils/mimetype';
|
||||||
import { CoreTimeUtils } from '@services/utils/time';
|
import { CoreTimeUtils } from '@services/utils/time';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreUtils } from '@services/utils/utils';
|
||||||
import { CoreWSFile, CoreWSFileUploadOptions, CoreWSUploadFileResult } from '@services/ws';
|
import { CoreWSFile, CoreWSFileUploadOptions, CoreWSUploadFileResult } from '@services/ws';
|
||||||
import { makeSingleton, Translate, MediaCapture, ModalController, Camera } from '@singletons';
|
import { makeSingleton, Translate, MediaCapture, Camera } from '@singletons';
|
||||||
import { CoreLogger } from '@singletons/logger';
|
import { CoreLogger } from '@singletons/logger';
|
||||||
import { CoreEmulatorCaptureMediaComponent } from '@features/emulator/components/capture-media/capture-media';
|
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreError } from '@classes/errors/error';
|
||||||
import { CoreSite } from '@classes/site';
|
import { CoreSite } from '@classes/site';
|
||||||
import { CoreFileEntry, CoreFileHelper } from '@services/file-helper';
|
import { CoreFileEntry, CoreFileHelper } from '@services/file-helper';
|
||||||
import { CorePath } from '@singletons/path';
|
import { CorePath } from '@singletons/path';
|
||||||
import { CorePlatform } from '@services/platform';
|
import { CorePlatform } from '@services/platform';
|
||||||
|
import { CoreModals } from '@services/modals';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File upload options.
|
* File upload options.
|
||||||
|
@ -132,14 +132,21 @@ export class CoreFileUploaderProvider {
|
||||||
/**
|
/**
|
||||||
* Start the audio recorder application and return information about captured audio clip files.
|
* Start the audio recorder application and return information about captured audio clip files.
|
||||||
*
|
*
|
||||||
* @param options Options.
|
|
||||||
* @returns Promise resolved with the result.
|
* @returns Promise resolved with the result.
|
||||||
*/
|
*/
|
||||||
async captureAudio(options: CaptureAudioOptions): Promise<MediaFile[] | CaptureError> {
|
async captureAudio(): Promise<CoreFileUploaderAudioRecording[] | MediaFile[] | CaptureError> {
|
||||||
this.onAudioCapture.next(true);
|
this.onAudioCapture.next(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await MediaCapture.captureAudio(options);
|
if (!CorePlatform.supportsMediaCapture() || !CorePlatform.supportsWebAssembly()) {
|
||||||
|
const media = await MediaCapture.captureAudio({ limit: 1 });
|
||||||
|
|
||||||
|
return media;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recording = await this.captureAudioInApp();
|
||||||
|
|
||||||
|
return [recording];
|
||||||
} finally {
|
} finally {
|
||||||
this.onAudioCapture.next(false);
|
this.onAudioCapture.next(false);
|
||||||
}
|
}
|
||||||
|
@ -150,27 +157,17 @@ export class CoreFileUploaderProvider {
|
||||||
*
|
*
|
||||||
* @returns Promise resolved with the file.
|
* @returns Promise resolved with the file.
|
||||||
*/
|
*/
|
||||||
async captureAudioInApp(): Promise<MediaFile> {
|
async captureAudioInApp(): Promise<CoreFileUploaderAudioRecording> {
|
||||||
const params = {
|
const { CoreFileUploaderAudioRecorderComponent } =
|
||||||
type: 'audio',
|
await import('@features/fileuploader/components/audio-recorder/audio-recorder.module');
|
||||||
};
|
|
||||||
|
|
||||||
const modal = await ModalController.create({
|
const recording = await CoreModals.openSheet(CoreFileUploaderAudioRecorderComponent);
|
||||||
component: CoreEmulatorCaptureMediaComponent,
|
|
||||||
cssClass: 'core-modal-fullscreen',
|
|
||||||
componentProps: params,
|
|
||||||
backdropDismiss: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await modal.present();
|
if (!recording) {
|
||||||
|
throw new Error('Recording missing from audio capture');
|
||||||
const result = await modal.onWillDismiss();
|
|
||||||
|
|
||||||
if (result.role == 'success') {
|
|
||||||
return result.data[0];
|
|
||||||
} else {
|
|
||||||
throw result.data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return recording;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -335,7 +332,7 @@ export class CoreFileUploaderProvider {
|
||||||
* @param mediaFile File object to upload.
|
* @param mediaFile File object to upload.
|
||||||
* @returns Options.
|
* @returns Options.
|
||||||
*/
|
*/
|
||||||
getMediaUploadOptions(mediaFile: MediaFile): CoreFileUploaderOptions {
|
getMediaUploadOptions(mediaFile: MediaFile | CoreFileUploaderAudioRecording): CoreFileUploaderOptions {
|
||||||
const options: CoreFileUploaderOptions = {};
|
const options: CoreFileUploaderOptions = {};
|
||||||
let filename = mediaFile.name;
|
let filename = mediaFile.name;
|
||||||
|
|
||||||
|
@ -781,3 +778,9 @@ export type CoreFileUploaderTypeListInfoEntry = {
|
||||||
name?: string;
|
name?: string;
|
||||||
extlist: string;
|
extlist: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CoreFileUploaderAudioRecording = {
|
||||||
|
name: string;
|
||||||
|
fullPath: string;
|
||||||
|
type: string;
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
// (C) Copyright 2015 Moodle Pty Ltd.
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
import type { Mp3WorkerConfig } from 'mp3-mediarecorder/types/config.type';
|
||||||
|
|
||||||
|
export interface InitAudioEncoderMessage {
|
||||||
|
name: 'init-audio-encoder';
|
||||||
|
config: Mp3WorkerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the given data is an init audio encoder message.
|
||||||
|
*
|
||||||
|
* @param message Message.
|
||||||
|
* @returns Whether the data is an init audio encoder message.
|
||||||
|
*/
|
||||||
|
export function isInitAudioEncoderMessage(message: unknown): message is InitAudioEncoderMessage {
|
||||||
|
return typeof message === 'object'
|
||||||
|
&& message !== null
|
||||||
|
&& 'name' in message
|
||||||
|
&& message['name'] === 'init-audio-encoder';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an init audio encoder message.
|
||||||
|
*
|
||||||
|
* @param config Audio encoder config.
|
||||||
|
* @returns Message.
|
||||||
|
*/
|
||||||
|
export function initAudioEncoderMessage(config: Mp3WorkerConfig): InitAudioEncoderMessage {
|
||||||
|
return {
|
||||||
|
name: 'init-audio-encoder',
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
}
|
|
@ -11,6 +11,7 @@
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { CoreLoginHelper, CoreLoginMethod } from '@features/login/services/login-helper';
|
import { CoreLoginHelper, CoreLoginMethod } from '@features/login/services/login-helper';
|
||||||
import { CoreSites } from '@services/sites';
|
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 { WebView } from '@ionic-native/ionic-webview/ngx';
|
||||||
import { Keyboard } from '@ionic-native/keyboard/ngx';
|
import { Keyboard } from '@ionic-native/keyboard/ngx';
|
||||||
import { LocalNotifications } from '@ionic-native/local-notifications/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 { MediaCapture } from '@ionic-native/media-capture/ngx';
|
||||||
import { Push } from '@ionic-native/push/ngx';
|
import { Push } from '@ionic-native/push/ngx';
|
||||||
import { QRScanner } from '@ionic-native/qr-scanner/ngx';
|
import { QRScanner } from '@ionic-native/qr-scanner/ngx';
|
||||||
|
@ -54,7 +53,6 @@ export const CORE_NATIVE_SERVICES = [
|
||||||
InAppBrowser,
|
InAppBrowser,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
LocalNotifications,
|
LocalNotifications,
|
||||||
Media,
|
|
||||||
MediaCapture,
|
MediaCapture,
|
||||||
Push,
|
Push,
|
||||||
QRScanner,
|
QRScanner,
|
||||||
|
@ -82,7 +80,6 @@ export const CORE_NATIVE_SERVICES = [
|
||||||
InAppBrowser,
|
InAppBrowser,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
LocalNotifications,
|
LocalNotifications,
|
||||||
Media,
|
|
||||||
MediaCapture,
|
MediaCapture,
|
||||||
Push,
|
Push,
|
||||||
QRScanner,
|
QRScanner,
|
||||||
|
|
|
@ -11,8 +11,6 @@
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
// (C) Copyright 2015 Moodle Pty Ltd.
|
|
||||||
//
|
|
||||||
|
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { CoreError } from '@classes/errors/error';
|
import { CoreError } from '@classes/errors/error';
|
||||||
|
|
|
@ -40,7 +40,7 @@ export class CoreSecondsToHMSPipe implements PipeTransform {
|
||||||
* @param seconds Number of seconds.
|
* @param seconds Number of seconds.
|
||||||
* @returns Formatted seconds.
|
* @returns Formatted seconds.
|
||||||
*/
|
*/
|
||||||
transform(seconds: string | number): string {
|
transform(seconds: string | number, showHours: boolean = true): string {
|
||||||
if (!seconds || seconds < 0) {
|
if (!seconds || seconds < 0) {
|
||||||
seconds = 0;
|
seconds = 0;
|
||||||
} else if (typeof seconds == 'string') {
|
} else if (typeof seconds == 'string') {
|
||||||
|
@ -62,8 +62,9 @@ export class CoreSecondsToHMSPipe implements PipeTransform {
|
||||||
const minutes = Math.floor(seconds / CoreConstants.SECONDS_MINUTE);
|
const minutes = Math.floor(seconds / CoreConstants.SECONDS_MINUTE);
|
||||||
seconds -= minutes * CoreConstants.SECONDS_MINUTE;
|
seconds -= minutes * CoreConstants.SECONDS_MINUTE;
|
||||||
|
|
||||||
return CoreTextUtils.twoDigits(hours) + ':' + CoreTextUtils.twoDigits(minutes) + ':' +
|
return showHours
|
||||||
CoreTextUtils.twoDigits(seconds);
|
? CoreTextUtils.twoDigits(hours) + ':' + CoreTextUtils.twoDigits(minutes) + ':' + CoreTextUtils.twoDigits(seconds)
|
||||||
|
: CoreTextUtils.twoDigits(minutes) + ':' + CoreTextUtils.twoDigits(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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');
|
return this.is('cordova');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the device is configured to reduce motion.
|
||||||
|
*
|
||||||
|
* @returns Whether the device is configured to reduce motion.
|
||||||
|
*/
|
||||||
|
prefersReducedMotion(): boolean {
|
||||||
|
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether media capture is supported.
|
||||||
|
*
|
||||||
|
* @returns Whether media capture is supported.
|
||||||
|
*/
|
||||||
|
supportsMediaCapture(): boolean {
|
||||||
|
return 'mediaDevices' in navigator;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether web assembly is supported.
|
||||||
|
*
|
||||||
|
* @returns Whether web assembly is supported.
|
||||||
|
*/
|
||||||
|
supportsWebAssembly(): boolean {
|
||||||
|
return 'WebAssembly' in window;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CorePlatform = makeSingleton(CorePlatformService);
|
export const CorePlatform = makeSingleton(CorePlatformService);
|
||||||
|
|
|
@ -14,8 +14,7 @@
|
||||||
|
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { AsyncDirective } from '@classes/async-directive';
|
import { AsyncDirective } from '@classes/async-directive';
|
||||||
import { CoreUtils } from '@services/utils/utils';
|
import { CoreDirectivesRegistry } from '@singletons/directives-registry';
|
||||||
import { CoreLogger } from './logger';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registry to keep track of component instances.
|
* Registry to keep track of component instances.
|
||||||
|
@ -24,9 +23,6 @@ import { CoreLogger } from './logger';
|
||||||
*/
|
*/
|
||||||
export class CoreComponentsRegistry {
|
export class CoreComponentsRegistry {
|
||||||
|
|
||||||
private static instances: WeakMap<Element, unknown> = new WeakMap();
|
|
||||||
protected static logger = CoreLogger.getInstance('CoreComponentsRegistry');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a component instance.
|
* Register a component instance.
|
||||||
*
|
*
|
||||||
|
@ -34,7 +30,7 @@ export class CoreComponentsRegistry {
|
||||||
* @param instance Component instance.
|
* @param instance Component instance.
|
||||||
*/
|
*/
|
||||||
static register(element: Element, instance: unknown): void {
|
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.
|
* @returns Component instance.
|
||||||
*/
|
*/
|
||||||
static resolve<T>(element?: Element | null, componentClass?: ComponentConstructor<T>): T | null {
|
static resolve<T>(element?: Element | null, componentClass?: ComponentConstructor<T>): T | null {
|
||||||
const instance = (element && this.instances.get(element) as T) ?? null;
|
return CoreDirectivesRegistry.resolve(element, componentClass);
|
||||||
|
|
||||||
return instance && (!componentClass || instance instanceof componentClass)
|
|
||||||
? instance
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -60,13 +52,7 @@ export class CoreComponentsRegistry {
|
||||||
* @returns Component instance.
|
* @returns Component instance.
|
||||||
*/
|
*/
|
||||||
static require<T>(element: Element, componentClass?: ComponentConstructor<T>): T {
|
static require<T>(element: Element, componentClass?: ComponentConstructor<T>): T {
|
||||||
const instance = this.resolve(element, componentClass);
|
return CoreDirectivesRegistry.require(element, componentClass);
|
||||||
|
|
||||||
if (!instance) {
|
|
||||||
throw new Error('Couldn\'t resolve component instance');
|
|
||||||
}
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -80,14 +66,7 @@ export class CoreComponentsRegistry {
|
||||||
element: Element | null,
|
element: Element | null,
|
||||||
componentClass?: ComponentConstructor<T>,
|
componentClass?: ComponentConstructor<T>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const instance = this.resolve(element, componentClass);
|
return CoreDirectivesRegistry.waitDirectiveReady(element, componentClass);
|
||||||
if (!instance) {
|
|
||||||
this.logger.error('No instance registered for element ' + componentClass, element);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await instance.ready();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -103,23 +82,7 @@ export class CoreComponentsRegistry {
|
||||||
selector: string,
|
selector: string,
|
||||||
componentClass?: ComponentConstructor<T>,
|
componentClass?: ComponentConstructor<T>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let elements: Element[] = [];
|
return CoreDirectivesRegistry.waitDirectivesReady(element, selector, componentClass);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { WebView as WebViewService } from '@ionic-native/ionic-webview/ngx';
|
||||||
import { Keyboard as KeyboardService } from '@ionic-native/keyboard/ngx';
|
import { Keyboard as KeyboardService } from '@ionic-native/keyboard/ngx';
|
||||||
import { LocalNotifications as LocalNotificationsService } from '@ionic-native/local-notifications/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 { MediaCapture as MediaCaptureService } from '@ionic-native/media-capture/ngx';
|
||||||
import { Push as PushService } from '@ionic-native/push/ngx';
|
import { Push as PushService } from '@ionic-native/push/ngx';
|
||||||
import { QRScanner as QRScannerService } from '@ionic-native/qr-scanner/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 InAppBrowser = makeSingleton(InAppBrowserService);
|
||||||
export const Keyboard = makeSingleton(KeyboardService);
|
export const Keyboard = makeSingleton(KeyboardService);
|
||||||
export const LocalNotifications = makeSingleton(LocalNotificationsService);
|
export const LocalNotifications = makeSingleton(LocalNotificationsService);
|
||||||
export const Media = makeSingleton(MediaService);
|
|
||||||
export const MediaCapture = makeSingleton(MediaCaptureService);
|
export const MediaCapture = makeSingleton(MediaCaptureService);
|
||||||
export const NativeHttp = makeSingleton(HTTP);
|
export const NativeHttp = makeSingleton(HTTP);
|
||||||
export const Push = makeSingleton(PushService);
|
export const Push = makeSingleton(PushService);
|
||||||
|
|
|
@ -12,6 +12,12 @@
|
||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper type to infer class instance types.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type Constructor<T> = { new(...args: any[]): T };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper type to flatten complex types.
|
* Helper type to flatten complex types.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/worker",
|
||||||
|
"lib": [
|
||||||
|
"es2018",
|
||||||
|
"webworker"
|
||||||
|
],
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.worker.ts"
|
||||||
|
]
|
||||||
|
}
|
Loading…
Reference in New Issue