Merge pull request #3556 from NoelDeMartin/MOBILE-2314
MOBILE-2314: Audio Recorder improvements
This commit is contained in:
		
						commit
						4cb9a6640c
					
				| @ -6,6 +6,8 @@ WORKDIR /app | ||||
| # Prepare node dependencies | ||||
| RUN apt-get update && apt-get install libsecret-1-0 -y | ||||
| COPY package*.json ./ | ||||
| COPY patches ./patches | ||||
| RUN echo "unsafe-perm=true" > ./.npmrc | ||||
| RUN npm ci --no-audit | ||||
| 
 | ||||
| # Build source | ||||
|  | ||||
| @ -42,7 +42,8 @@ | ||||
|                 "input": "src/theme/theme.scss" | ||||
|               } | ||||
|             ], | ||||
|             "scripts": [] | ||||
|             "scripts": [], | ||||
|             "webWorkerTsConfig": "tsconfig.worker.json" | ||||
|           }, | ||||
|           "configurations": { | ||||
|             "production": { | ||||
| @ -50,6 +51,10 @@ | ||||
|                 { | ||||
|                   "replace": "src/testing/testing.module.ts", | ||||
|                   "with": "src/testing/testing.module.prod.ts" | ||||
|                 }, | ||||
|                 { | ||||
|                   "replace": "src/core/features/emulator/emulator.module.ts", | ||||
|                   "with": "src/core/features/emulator/emulator.module.prod.ts" | ||||
|                 } | ||||
|               ], | ||||
|               "optimization": { | ||||
|  | ||||
| @ -196,11 +196,6 @@ | ||||
|                 <param name="android-package" value="com.adobe.phonegap.push.PushPlugin" /> | ||||
|             </feature> | ||||
|         </config-file> | ||||
|         <config-file parent="/*" target="res/xml/config.xml"> | ||||
|             <feature name="Media"> | ||||
|                 <param name="android-package" value="org.apache.cordova.media.AudioHandler" /> | ||||
|             </feature> | ||||
|         </config-file> | ||||
|         <config-file parent="/*" target="AndroidManifest.xml"> | ||||
|             <uses-feature android:name="android.hardware.bluetooth" android:required="false" /> | ||||
|         </config-file> | ||||
|  | ||||
							
								
								
									
										39
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										39
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -4167,21 +4167,6 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "@ionic-native/media": { | ||||
|       "version": "5.36.0", | ||||
|       "resolved": "https://registry.npmjs.org/@ionic-native/media/-/media-5.36.0.tgz", | ||||
|       "integrity": "sha512-WIDCeUlX7bCbse/x2Rr7mAIQJnLo18ZWcmsVgSTTBVS7ObU2DBl4ieqRx6y9PAAV+3tNZqMV4JAWDfMiFokpJg==", | ||||
|       "requires": { | ||||
|         "@types/cordova": "^0.0.34" | ||||
|       }, | ||||
|       "dependencies": { | ||||
|         "@types/cordova": { | ||||
|           "version": "0.0.34", | ||||
|           "resolved": "https://registry.npmjs.org/@types/cordova/-/cordova-0.0.34.tgz", | ||||
|           "integrity": "sha512-rkiiTuf/z2wTd4RxFOb+clE7PF4AEJU0hsczbUdkHHBtkUmpWQpEddynNfJYKYtZFJKbq4F+brfekt1kx85IZA==" | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "@ionic-native/media-capture": { | ||||
|       "version": "5.36.0", | ||||
|       "resolved": "https://registry.npmjs.org/@ionic-native/media-capture/-/media-capture-5.36.0.tgz", | ||||
| @ -14340,11 +14325,6 @@ | ||||
|       "resolved": "https://registry.npmjs.org/cordova-plugin-ionic-keyboard/-/cordova-plugin-ionic-keyboard-2.2.0.tgz", | ||||
|       "integrity": "sha512-yDUG+9ieKVRitq5mGlNxjaZh/MgEhFFIgTIPhqSbUaQ8UuZbawy5mhJAVClqY97q8/rcQtL6dCDa7x2sEtCLcA==" | ||||
|     }, | ||||
|     "cordova-plugin-media": { | ||||
|       "version": "5.0.4", | ||||
|       "resolved": "https://registry.npmjs.org/cordova-plugin-media/-/cordova-plugin-media-5.0.4.tgz", | ||||
|       "integrity": "sha512-mAqincYqOT5gu5LWyfgJu3qmOq+lhLAKhnOZULpG622FvYiHjjfsoJ/fkI55WwI3FIcHeeyhToGvHXBCNJePZg==" | ||||
|     }, | ||||
|     "cordova-plugin-media-capture": { | ||||
|       "version": "3.0.3", | ||||
|       "resolved": "https://registry.npmjs.org/cordova-plugin-media-capture/-/cordova-plugin-media-capture-3.0.3.tgz", | ||||
| @ -16854,6 +16834,11 @@ | ||||
|       "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", | ||||
|       "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" | ||||
|     }, | ||||
|     "event-target-shim": { | ||||
|       "version": "6.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz", | ||||
|       "integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==" | ||||
|     }, | ||||
|     "eventemitter3": { | ||||
|       "version": "4.0.7", | ||||
|       "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", | ||||
| @ -23834,6 +23819,15 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "mp3-mediarecorder": { | ||||
|       "version": "4.0.5", | ||||
|       "resolved": "https://registry.npmjs.org/mp3-mediarecorder/-/mp3-mediarecorder-4.0.5.tgz", | ||||
|       "integrity": "sha512-tu8XvKGMrdwNmEQTzBbaJRLBAuVNEzbzmCOnYzUyYuEb48Kwl97qA6f5nBEaZXveNmHgvvi0i85TjROPC49qFA==", | ||||
|       "requires": { | ||||
|         "event-target-shim": "6.0.2", | ||||
|         "vmsg": "0.4.0" | ||||
|       } | ||||
|     }, | ||||
|     "mpd-parser": { | ||||
|       "version": "0.22.1", | ||||
|       "resolved": "https://registry.npmjs.org/mpd-parser/-/mpd-parser-0.22.1.tgz", | ||||
| @ -33487,6 +33481,11 @@ | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     "vmsg": { | ||||
|       "version": "0.4.0", | ||||
|       "resolved": "https://registry.npmjs.org/vmsg/-/vmsg-0.4.0.tgz", | ||||
|       "integrity": "sha512-46BBqRSfqdFGUpO2j+Hpz8T9YE5uWG0/PWal1PT+R1o8NEthtjG/XWl4HzbB8hIHpg/UtmKvsxL2OKQBrIYcHQ==" | ||||
|     }, | ||||
|     "w3c-hr-time": { | ||||
|       "version": "1.0.2", | ||||
|       "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", | ||||
|  | ||||
| @ -24,7 +24,7 @@ | ||||
|     "build": "ionic build", | ||||
|     "build:prod": "NODE_ENV=production ionic build --prod", | ||||
|     "build:test": "NODE_ENV=testing ionic build --configuration=testing", | ||||
|     "dev:android": "ionic cordova run android --livereload", | ||||
|     "dev:android": "ionic cordova run android --livereload --external --ssl", | ||||
|     "dev:ios": "ionic cordova run ios", | ||||
|     "prod:android": "NODE_ENV=production ionic cordova run android --prod", | ||||
|     "prod:ios": "NODE_ENV=production ionic cordova run ios --prod", | ||||
| @ -63,7 +63,6 @@ | ||||
|     "@ionic-native/ionic-webview": "5.36.0", | ||||
|     "@ionic-native/keyboard": "5.36.0", | ||||
|     "@ionic-native/local-notifications": "5.36.0", | ||||
|     "@ionic-native/media": "5.36.0", | ||||
|     "@ionic-native/media-capture": "5.36.0", | ||||
|     "@ionic-native/network": "5.36.0", | ||||
|     "@ionic-native/push": "5.36.0", | ||||
| @ -104,7 +103,6 @@ | ||||
|     "cordova-plugin-file": "6.0.2", | ||||
|     "cordova-plugin-geolocation": "4.1.0", | ||||
|     "cordova-plugin-ionic-keyboard": "2.2.0", | ||||
|     "cordova-plugin-media": "5.0.4", | ||||
|     "cordova-plugin-media-capture": "3.0.3", | ||||
|     "cordova-plugin-network-information": "3.0.0", | ||||
|     "cordova-plugin-prevent-override": "1.0.1", | ||||
| @ -122,6 +120,7 @@ | ||||
|     "mathjax": "2.7.9", | ||||
|     "moment": "2.29.4", | ||||
|     "moment-timezone": "0.5.38", | ||||
|     "mp3-mediarecorder": "^4.0.5", | ||||
|     "nl.kingsquare.cordova.background-audio": "1.0.1", | ||||
|     "ogv": "1.8.9", | ||||
|     "rxjs": "6.5.5", | ||||
| @ -224,9 +223,6 @@ | ||||
|         "ANDROID_SUPPORT_V4_VERSION": "26.+" | ||||
|       }, | ||||
|       "cordova-plugin-media-capture": {}, | ||||
|       "cordova-plugin-media": { | ||||
|         "KEEP_AVAUDIOSESSION_ALWAYS_ACTIVE": "NO" | ||||
|       }, | ||||
|       "cordova-plugin-network-information": {}, | ||||
|       "@moodlehq/cordova-plugin-qrscanner": {}, | ||||
|       "cordova-plugin-splashscreen": {}, | ||||
|  | ||||
							
								
								
									
										30
									
								
								patches/event-target-shim+6.0.2.patch
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								patches/event-target-shim+6.0.2.patch
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | ||||
| diff --git a/node_modules/event-target-shim/index.d.ts b/node_modules/event-target-shim/index.d.ts
 | ||||
| index 7a5bfc7..ba5e7d8 100644
 | ||||
| --- a/node_modules/event-target-shim/index.d.ts
 | ||||
| +++ b/node_modules/event-target-shim/index.d.ts
 | ||||
| @@ -359,7 +359,7 @@ export declare namespace defineCustomEventTarget {
 | ||||
|  	/** | ||||
|  	 * The interface of CustomEventTarget. | ||||
|  	 */ | ||||
| -	type CustomEventTarget<TEventMap extends Record<string, Event>, TMode extends "standard" | "strict"> = EventTarget<TEventMap, TMode> & defineEventAttribute.EventAttributes<any, TEventMap>;
 | ||||
| +	type CustomEventTarget<TEventMap extends Record<string, Event>, TMode extends "standard" | "strict"> = EventTarget<TEventMap, TMode> & defineEventAttribute.EventAttributes<any>;
 | ||||
|  } | ||||
|  /** | ||||
|   * Define an event attribute. | ||||
| @@ -368,14 +368,12 @@ export declare namespace defineCustomEventTarget {
 | ||||
|   * @param _eventClass Unused, but to infer `Event` class type. | ||||
|   * @deprecated Use `getEventAttributeValue`/`setEventAttributeValue` pair on your derived class instead because of static analysis friendly. | ||||
|   */ | ||||
| -export declare function defineEventAttribute<TEventTarget extends EventTarget, TEventType extends string, TEventConstrucor extends typeof Event>(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes<TEventTarget, Record<TEventType, InstanceType<TEventConstrucor>>>;
 | ||||
| +export declare function defineEventAttribute<TEventTarget extends EventTarget, TEventType extends string, TEventConstrucor extends typeof Event>(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes<TEventTarget>;
 | ||||
|  export declare namespace defineEventAttribute { | ||||
|  	/** | ||||
|  	 * Definition of event attributes. | ||||
|  	 */ | ||||
| -	type EventAttributes<TEventTarget extends EventTarget<any, any>, TEventMap extends Record<string, Event>> = {
 | ||||
| -		[P in string & keyof TEventMap as `on${P}`]: EventTarget.CallbackFunction<TEventTarget, TEventMap[P]> | null;
 | ||||
| -	};
 | ||||
| +	type EventAttributes<TEventTarget extends EventTarget<any, any>> = Record<string, EventTarget.CallbackFunction<TEventTarget, any> | null>;
 | ||||
|  } | ||||
|  /** | ||||
|   * Set the warning handler. | ||||
							
								
								
									
										91
									
								
								patches/mp3-mediarecorder+4.0.5.patch
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								patches/mp3-mediarecorder+4.0.5.patch
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,91 @@ | ||||
| diff --git a/node_modules/mp3-mediarecorder/dist/index.es.js b/node_modules/mp3-mediarecorder/dist/index.es.js
 | ||||
| index 7a96961..82ec4e8 100644
 | ||||
| --- a/node_modules/mp3-mediarecorder/dist/index.es.js
 | ||||
| +++ b/node_modules/mp3-mediarecorder/dist/index.es.js
 | ||||
| @@ -357,8 +357,7 @@ class Event$1 {
 | ||||
|              InitEventWasCalledWhileDispatching.warn(); | ||||
|              return; | ||||
|          } | ||||
| -        internalDataMap.set(this, {
 | ||||
| -            ...data,
 | ||||
| +        internalDataMap.set(this, Object.assign({}, data, {
 | ||||
|              type: String(type), | ||||
|              bubbles: Boolean(bubbles), | ||||
|              cancelable: Boolean(cancelable), | ||||
| @@ -366,8 +365,8 @@ class Event$1 {
 | ||||
|              currentTarget: null, | ||||
|              stopPropagationFlag: false, | ||||
|              stopImmediatePropagationFlag: false, | ||||
| -            canceledFlag: false,
 | ||||
| -        });
 | ||||
| +            canceledFlag: false
 | ||||
| +        }));
 | ||||
|      } | ||||
|  } | ||||
|  //------------------------------------------------------------------------------ | ||||
| diff --git a/node_modules/mp3-mediarecorder/dist/index.es5.js b/node_modules/mp3-mediarecorder/dist/index.es5.js
 | ||||
| index 0caa82d..aa46cc2 100644
 | ||||
| --- a/node_modules/mp3-mediarecorder/dist/index.es5.js
 | ||||
| +++ b/node_modules/mp3-mediarecorder/dist/index.es5.js
 | ||||
| @@ -418,7 +418,7 @@ class Event$1 {
 | ||||
|        return; | ||||
|      } | ||||
|   | ||||
| -    internalDataMap.set(this, { ...data,
 | ||||
| +    internalDataMap.set(this, Object.assign({}, data, {
 | ||||
|        type: String(type), | ||||
|        bubbles: Boolean(bubbles), | ||||
|        cancelable: Boolean(cancelable), | ||||
| @@ -427,7 +427,7 @@ class Event$1 {
 | ||||
|        stopPropagationFlag: false, | ||||
|        stopImmediatePropagationFlag: false, | ||||
|        canceledFlag: false | ||||
| -    });
 | ||||
| +    }));
 | ||||
|    } | ||||
|   | ||||
|  } //------------------------------------------------------------------------------ | ||||
| diff --git a/node_modules/mp3-mediarecorder/dist/index.js b/node_modules/mp3-mediarecorder/dist/index.js
 | ||||
| index f7a517e..5f7f415 100644
 | ||||
| --- a/node_modules/mp3-mediarecorder/dist/index.js
 | ||||
| +++ b/node_modules/mp3-mediarecorder/dist/index.js
 | ||||
| @@ -418,7 +418,7 @@ class Event$1 {
 | ||||
|        return; | ||||
|      } | ||||
|   | ||||
| -    internalDataMap.set(this, { ...data,
 | ||||
| +    internalDataMap.set(this, Object.assign({}, data, {
 | ||||
|        type: String(type), | ||||
|        bubbles: Boolean(bubbles), | ||||
|        cancelable: Boolean(cancelable), | ||||
| @@ -427,7 +427,7 @@ class Event$1 {
 | ||||
|        stopPropagationFlag: false, | ||||
|        stopImmediatePropagationFlag: false, | ||||
|        canceledFlag: false | ||||
| -    });
 | ||||
| +    }));
 | ||||
|    } | ||||
|   | ||||
|  } //------------------------------------------------------------------------------ | ||||
| diff --git a/node_modules/mp3-mediarecorder/dist/index.umd.js b/node_modules/mp3-mediarecorder/dist/index.umd.js
 | ||||
| index 3f5f2a2..dd7783d 100644
 | ||||
| --- a/node_modules/mp3-mediarecorder/dist/index.umd.js
 | ||||
| +++ b/node_modules/mp3-mediarecorder/dist/index.umd.js
 | ||||
| @@ -418,7 +418,7 @@ class Event$1 {
 | ||||
|        return; | ||||
|      } | ||||
|   | ||||
| -    internalDataMap.set(this, { ...data,
 | ||||
| +    internalDataMap.set(this, Object.assign({}, data, {
 | ||||
|        type: String(type), | ||||
|        bubbles: Boolean(bubbles), | ||||
|        cancelable: Boolean(cancelable), | ||||
| @@ -427,7 +427,7 @@ class Event$1 {
 | ||||
|        stopPropagationFlag: false, | ||||
|        stopImmediatePropagationFlag: false, | ||||
|        canceledFlag: false | ||||
| -    });
 | ||||
| +    }));
 | ||||
|    } | ||||
|   | ||||
|  } //------------------------------------------------------------------------------ | ||||
| @ -27,6 +27,7 @@ const ASSETS = { | ||||
|     '/node_modules/mathjax/jax/output/SVG': '/lib/mathjax/jax/output/SVG', | ||||
|     '/node_modules/mathjax/jax/output/PreviewHTML': '/lib/mathjax/jax/output/PreviewHTML', | ||||
|     '/node_modules/mathjax/localization': '/lib/mathjax/localization', | ||||
|     '/node_modules/mp3-mediarecorder/dist/vmsg.wasm': '/lib/vmsg/vmsg.wasm', | ||||
|     '/src/core/features/h5p/assets': '/lib/h5p', | ||||
|     '/node_modules/ogv/dist': '/lib/ogv', | ||||
|     '/node_modules/video.js/dist/video-js.min.css': '/lib/video.js/video-js.min.css', | ||||
|  | ||||
| @ -1742,9 +1742,11 @@ | ||||
|   "core.filenotfound": "resource", | ||||
|   "core.fileuploader.addfiletext": "repository", | ||||
|   "core.fileuploader.audio": "local_moodlemobileapp", | ||||
|   "core.fileuploader.audiotitle": "tiny_recordrtc", | ||||
|   "core.fileuploader.camera": "local_moodlemobileapp", | ||||
|   "core.fileuploader.confirmuploadfile": "local_moodlemobileapp", | ||||
|   "core.fileuploader.confirmuploadunknownsize": "local_moodlemobileapp", | ||||
|   "core.fileuploader.discardrecording": "local_moodlemobileapp", | ||||
|   "core.fileuploader.errorcapturingaudio": "local_moodlemobileapp", | ||||
|   "core.fileuploader.errorcapturingimage": "local_moodlemobileapp", | ||||
|   "core.fileuploader.errorcapturingvideo": "local_moodlemobileapp", | ||||
| @ -1758,11 +1760,18 @@ | ||||
|   "core.fileuploader.fileuploaded": "local_moodlemobileapp", | ||||
|   "core.fileuploader.invalidfiletype": "repository", | ||||
|   "core.fileuploader.maxbytesfile": "local_moodlemobileapp", | ||||
|   "core.fileuploader.microphonepermissiondenied": "local_moodlemobileapp", | ||||
|   "core.fileuploader.microphonepermissionrestricted": "local_moodlemobileapp", | ||||
|   "core.fileuploader.more": "data", | ||||
|   "core.fileuploader.pauserecording": "local_moodlemobileapp", | ||||
|   "core.fileuploader.photoalbums": "local_moodlemobileapp", | ||||
|   "core.fileuploader.readingfile": "local_moodlemobileapp", | ||||
|   "core.fileuploader.readingfileperc": "local_moodlemobileapp", | ||||
|   "core.fileuploader.resumerecording": "local_moodlemobileapp", | ||||
|   "core.fileuploader.selectafile": "local_moodlemobileapp", | ||||
|   "core.fileuploader.startrecording": "tiny_recordrtc", | ||||
|   "core.fileuploader.startrecordinginstructions": "local_moodlemobileapp", | ||||
|   "core.fileuploader.stoprecording": "tiny_recordrtc", | ||||
|   "core.fileuploader.uploadafile": "local_moodlemobileapp", | ||||
|   "core.fileuploader.uploading": "local_moodlemobileapp", | ||||
|   "core.fileuploader.uploadingperc": "local_moodlemobileapp", | ||||
|  | ||||
| @ -11,6 +11,7 @@ | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { | ||||
|     AddonModDataEntryField, | ||||
|     AddonModDataField, | ||||
|  | ||||
| @ -11,6 +11,7 @@ | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component } from '@angular/core'; | ||||
| import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data'; | ||||
| import { AddonModDataFieldPluginBaseComponent } from '@addons/mod/data/classes/base-field-plugin-component'; | ||||
|  | ||||
| @ -11,6 +11,7 @@ | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { AddonModDataEntryField, AddonModDataProvider } from '@addons/mod/data/services/data'; | ||||
| import { Component } from '@angular/core'; | ||||
| import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; | ||||
|  | ||||
| @ -11,6 +11,7 @@ | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component } from '@angular/core'; | ||||
| import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component'; | ||||
| 
 | ||||
|  | ||||
| @ -11,6 +11,7 @@ | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { AddonModDataEntryField } from '@addons/mod/data/services/data'; | ||||
| import { Component } from '@angular/core'; | ||||
| import { AddonModDataFieldPluginBaseComponent } from '../../../classes/base-field-plugin-component'; | ||||
|  | ||||
| @ -11,6 +11,7 @@ | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { NgModule } from '@angular/core'; | ||||
| 
 | ||||
| import { AddonQbehaviourAdaptiveModule } from './adaptive/adaptive.module'; | ||||
|  | ||||
| @ -11,6 +11,7 @@ | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { NgModule } from '@angular/core'; | ||||
| 
 | ||||
| import { AddonUserProfileFieldCheckboxModule } from './checkbox/checkbox.module'; | ||||
|  | ||||
| @ -14,6 +14,8 @@ | ||||
| 
 | ||||
| import { CoreError } from './error'; | ||||
| 
 | ||||
| export const CAPTURE_ERROR_NO_MEDIA_FILES = 3; | ||||
| 
 | ||||
| /** | ||||
|  * Capture error. | ||||
|  */ | ||||
|  | ||||
							
								
								
									
										45
									
								
								src/core/classes/modal-component.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/core/classes/modal-component.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,45 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { ElementRef } from '@angular/core'; | ||||
| import { CorePromisedValue } from '@classes/promised-value'; | ||||
| import { CoreDirectivesRegistry } from '@singletons/directives-registry'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper class to build modals. | ||||
|  */ | ||||
| export class CoreModalComponent<T=unknown> { | ||||
| 
 | ||||
|     result: CorePromisedValue<T> = new CorePromisedValue(); | ||||
| 
 | ||||
|     constructor({ nativeElement: element }: ElementRef<HTMLElement>) { | ||||
|         CoreDirectivesRegistry.register(element, this); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Close the modal. | ||||
|      * | ||||
|      * @param result Result data, or error instance if the modal was closed with a failure. | ||||
|      */ | ||||
|     async close(result: T | Error): Promise<void> { | ||||
|         if (result instanceof Error) { | ||||
|             this.result.reject(result); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.result.resolve(result); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -46,6 +46,7 @@ export class CoreChronoComponent implements OnInit, OnChanges, OnDestroy { | ||||
|     @Input() startTime = 0; // Number of milliseconds to put in the chrono before starting.
 | ||||
|     @Input() endTime?: number; // Number of milliseconds to stop the chrono.
 | ||||
|     @Input() reset?: boolean; // Set it to true to reset the chrono.
 | ||||
|     @Input() hours = true; | ||||
|     @Output() onEnd: EventEmitter<void>; // Will emit an event when the endTime is reached.
 | ||||
| 
 | ||||
|     time = 0; | ||||
|  | ||||
| @ -1 +1 @@ | ||||
| <span role="timer">{{ time / 1000 | coreSecondsToHMS }}</span> | ||||
| <span role="timer">{{ time / 1000 | coreSecondsToHMS:hours }}</span> | ||||
|  | ||||
| @ -64,6 +64,7 @@ import { CoreSwipeNavigationTourComponent } from './swipe-navigation-tour/swipe- | ||||
| import { CoreMessageComponent } from './message/message'; | ||||
| import { CoreGroupSelectorComponent } from './group-selector/group-selector'; | ||||
| import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh-button-modal'; | ||||
| import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal'; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     declarations: [ | ||||
| @ -110,6 +111,7 @@ import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh- | ||||
|         CoreHorizontalScrollControlsComponent, | ||||
|         CoreSwipeNavigationTourComponent, | ||||
|         CoreRefreshButtonModalComponent, | ||||
|         CoreSheetModalComponent, | ||||
|     ], | ||||
|     imports: [ | ||||
|         CommonModule, | ||||
| @ -163,6 +165,7 @@ import { CoreRefreshButtonModalComponent } from './refresh-button-modal/refresh- | ||||
|         CoreHorizontalScrollControlsComponent, | ||||
|         CoreSwipeNavigationTourComponent, | ||||
|         CoreRefreshButtonModalComponent, | ||||
|         CoreSheetModalComponent, | ||||
|     ], | ||||
| }) | ||||
| export class CoreComponentsModule {} | ||||
|  | ||||
							
								
								
									
										2
									
								
								src/core/components/sheet-modal/sheet-modal.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								src/core/components/sheet-modal/sheet-modal.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,2 @@ | ||||
| <ion-backdrop></ion-backdrop> | ||||
| <div class="sheet-modal--wrapper" #wrapper></div> | ||||
							
								
								
									
										40
									
								
								src/core/components/sheet-modal/sheet-modal.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/core/components/sheet-modal/sheet-modal.scss
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,40 @@ | ||||
| @import "~theme/globals"; | ||||
| 
 | ||||
| :host { | ||||
|     --backdrop-opacity: var(--ion-backdrop-opacity, 0.4); | ||||
| 
 | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     justify-content: flex-end; | ||||
|     isolation: isolate; | ||||
| 
 | ||||
|     ion-backdrop { | ||||
|         opacity: 0; | ||||
|         transition: opacity 300ms ease-in; | ||||
|     } | ||||
| 
 | ||||
|     .sheet-modal--wrapper { | ||||
|         border-radius: var(--big-radius) var(--big-radius) 0 0; | ||||
|         @include padding(24px, 16px, 24px, 16px); | ||||
| 
 | ||||
|         background-color: var(--ion-overlay-background-color, var(--ion-background-color, #fff)); | ||||
|         z-index: 3; // ion-backdrop has z-index 2 | ||||
|         transform: translateY(100%); | ||||
|         transition: transform 300ms ease-in; | ||||
|     } | ||||
| 
 | ||||
|     &.active { | ||||
| 
 | ||||
|         ion-backdrop { | ||||
|             opacity: var(--backdrop-opacity); | ||||
|         } | ||||
| 
 | ||||
|         .sheet-modal--wrapper { | ||||
|             transform: translateY(0%); | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										93
									
								
								src/core/components/sheet-modal/sheet-modal.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/core/components/sheet-modal/sheet-modal.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,93 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Constructor } from '@/core/utils/types'; | ||||
| import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; | ||||
| import { CoreModalComponent } from '@classes/modal-component'; | ||||
| import { CorePromisedValue } from '@classes/promised-value'; | ||||
| import { CoreModals } from '@services/modals'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { AngularFrameworkDelegate } from '@singletons'; | ||||
| import { CoreDirectivesRegistry } from '@singletons/directives-registry'; | ||||
| 
 | ||||
| @Component({ | ||||
|     selector: 'core-sheet-modal', | ||||
|     templateUrl: 'sheet-modal.html', | ||||
|     styleUrls: ['sheet-modal.scss'], | ||||
| }) | ||||
| export class CoreSheetModalComponent<T extends CoreModalComponent> implements AfterViewInit { | ||||
| 
 | ||||
|     @Input() component!: Constructor<T>; | ||||
|     @Input() componentProps?: Record<string, unknown>; | ||||
|     @ViewChild('wrapper') wrapper?: ElementRef<HTMLElement>; | ||||
| 
 | ||||
|     private element: HTMLElement; | ||||
|     private wrapperElement = new CorePromisedValue<HTMLElement>(); | ||||
| 
 | ||||
|     constructor({ nativeElement: element }: ElementRef<HTMLElement>) { | ||||
|         this.element = element; | ||||
| 
 | ||||
|         CoreDirectivesRegistry.register(element, this); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngAfterViewInit(): void { | ||||
|         if (!this.wrapper) { | ||||
|             this.wrapperElement.reject(new Error('CoreSheetModalComponent wasn\'t mounted properly')); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         this.wrapperElement.resolve(this.wrapper.nativeElement); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Show modal. | ||||
|      * | ||||
|      * @returns Component instance. | ||||
|      */ | ||||
|     async show(): Promise<T> { | ||||
|         const wrapper = await this.wrapperElement; | ||||
|         const element = await AngularFrameworkDelegate.attachViewToDom(wrapper, this.component, this.componentProps ?? {}); | ||||
| 
 | ||||
|         await CoreUtils.nextTick(); | ||||
| 
 | ||||
|         this.element.classList.add('active'); | ||||
|         this.element.style.zIndex = `${20000 + CoreModals.getTopOverlayIndex()}`; | ||||
| 
 | ||||
|         await CoreUtils.nextTick(); | ||||
|         await CoreUtils.wait(300); | ||||
| 
 | ||||
|         const instance = CoreDirectivesRegistry.resolve(element, this.component); | ||||
| 
 | ||||
|         if (!instance) { | ||||
|             throw new Error('Modal not mounted properly'); | ||||
|         } | ||||
| 
 | ||||
|         return instance; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Hide modal. | ||||
|      */ | ||||
|     async hide(): Promise<void> { | ||||
|         this.element.classList.remove('active'); | ||||
| 
 | ||||
|         await CoreUtils.nextTick(); | ||||
|         await CoreUtils.wait(300); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -14,7 +14,7 @@ | ||||
|     <core-loading [hideUntil]="readyToCapture"> | ||||
|         <div class="core-av-wrapper"> | ||||
|             <!-- Video stream for image and video. --> | ||||
|             <video *ngIf="!isAudio" [hidden]="hasCaptured" class="core-webcam-stream" autoplay #streamVideo></video> | ||||
|             <video [hidden]="hasCaptured" class="core-webcam-stream" autoplay #streamVideo></video> | ||||
| 
 | ||||
|             <!-- For video recording, use 2 videos and show/hide them because a CSS rule caused problems with the controls. --> | ||||
|             <video *ngIf="isVideo" [hidden]="!hasCaptured" class="core-webcam-video-captured" controls #previewVideo | ||||
| @ -25,21 +25,6 @@ | ||||
|             <canvas *ngIf="isImage" class="core-webcam-image-canvas" #imgCanvas></canvas> | ||||
|             <img *ngIf="isImage" [hidden]="!hasCaptured" class="core-webcam-image" alt="{{ 'core.capturedimage' | translate }}" | ||||
|                 #previewImage> | ||||
| 
 | ||||
|             <!-- Recording audio. --> | ||||
|             <div *ngIf="isAudio" class="core-audio-record-container"> | ||||
|                 <!-- Canvas to show audio waves when recording audio in browser. --> | ||||
|                 <canvas [hidden]="hasCaptured || isCordovaAudioCapture" class="core-audio-canvas" #streamAudio></canvas> | ||||
| 
 | ||||
|                 <!-- Button to start/stop in mobile devices. --> | ||||
|                 <ion-button fill="clear" *ngIf="!hasCaptured && isCordovaAudioCapture" (click)="actionClicked()" [attr.aria-label]="title"> | ||||
|                     <ion-icon *ngIf="!isCapturing" name="fas-microphone" slot="icon-only" aria-hidden="true"></ion-icon> | ||||
|                     <ion-icon *ngIf="isCapturing" name="fas-square" slot="icon-only" aria-hidden="true"></ion-icon> | ||||
|                 </ion-button> | ||||
| 
 | ||||
|                 <!-- Audio player to listen to the result. --> | ||||
|                 <audio [hidden]="!hasCaptured" class="core-audio-captured" controls #previewAudio controlsList="nodownload"></audio> | ||||
|             </div> | ||||
|         </div> | ||||
|     </core-loading> | ||||
| </ion-content> | ||||
| @ -48,8 +33,7 @@ | ||||
|     <ion-row> | ||||
|         <ion-col></ion-col> | ||||
|         <ion-col class="ion-text-center"> | ||||
|             <ion-button fill="clear" *ngIf="!hasCaptured && !isCordovaAudioCapture" (click)="actionClicked()" [attr.aria-label]="title"> | ||||
|                 <ion-icon *ngIf="!isCapturing && isAudio" name="fas-microphone" slot="icon-only" aria-hidden="true"></ion-icon> | ||||
|             <ion-button fill="clear" *ngIf="!hasCaptured" (click)="actionClicked()" [attr.aria-label]="title"> | ||||
|                 <ion-icon *ngIf="!isCapturing && isVideo" name="fas-video" slot="icon-only" aria-hidden="true"></ion-icon> | ||||
|                 <ion-icon *ngIf="isImage" name="fas-camera" slot="icon-only" aria-hidden="true"></ion-icon> | ||||
|                 <ion-icon *ngIf="isCapturing" name="fas-square" slot="icon-only" aria-hidden="true"></ion-icon> | ||||
|  | ||||
| @ -21,30 +21,6 @@ | ||||
|                 width: 100%; | ||||
|             } | ||||
| 
 | ||||
|             .button { | ||||
|                 position: absolute; | ||||
|                 top: 0; | ||||
|                 left: 0; | ||||
|                 bottom: 0; | ||||
|                 right: 0; | ||||
|                 margin: auto; | ||||
|                 height: 120px; | ||||
|                 width: 120px; | ||||
| 
 | ||||
|                 .icon { | ||||
|                     font-size: 120px; | ||||
|                     width: auto; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             audio { | ||||
|                 position: absolute; | ||||
|                 top: 0; | ||||
|                 left: 0; | ||||
|                 bottom: 0; | ||||
|                 right: 0; | ||||
|                 margin: auto; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         video, img { | ||||
|  | ||||
| @ -13,23 +13,20 @@ | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnInit, OnDestroy, ViewChild, ElementRef, ChangeDetectorRef, Input } from '@angular/core'; | ||||
| import { MediaObject } from '@ionic-native/media/ngx'; | ||||
| import { FileEntry } from '@ionic-native/file/ngx'; | ||||
| import { MediaFile } from '@ionic-native/media-capture/ngx'; | ||||
| 
 | ||||
| import { CoreFile, CoreFileProvider } from '@services/file'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CoreMimetypeUtils } from '@services/utils/mimetype'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { ModalController, Media, Translate } from '@singletons'; | ||||
| import { ModalController, Translate } from '@singletons'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| import { CoreCaptureError } from '@classes/errors/captureerror'; | ||||
| import { CoreCanceledError } from '@classes/errors/cancelederror'; | ||||
| import { CorePath } from '@singletons/path'; | ||||
| import { CorePlatform } from '@services/platform'; | ||||
| 
 | ||||
| /** | ||||
|  * Page to capture media in browser, or to capture audio in mobile devices. | ||||
|  * Page to capture media in browser. | ||||
|  */ | ||||
| @Component({ | ||||
|     selector: 'core-emulator-capture-media', | ||||
| @ -38,7 +35,7 @@ import { CorePlatform } from '@services/platform'; | ||||
| }) | ||||
| export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
| 
 | ||||
|     @Input() type?: 'audio' | 'video' | 'image' | 'captureimage'; | ||||
|     @Input() type?: 'video' | 'image' | 'captureimage'; | ||||
|     @Input() maxTime?: number; // Max time to capture.
 | ||||
|     @Input() facingMode?: string; // Camera facing mode.
 | ||||
|     @Input() mimetype?: string; | ||||
| @ -50,30 +47,20 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
|     @ViewChild('previewVideo') previewVideo?: ElementRef; | ||||
|     @ViewChild('imgCanvas') imgCanvas?: ElementRef; | ||||
|     @ViewChild('previewImage') previewImage?: ElementRef; | ||||
|     @ViewChild('streamAudio') streamAudio?: ElementRef; | ||||
|     @ViewChild('previewAudio') previewAudio?: ElementRef; | ||||
| 
 | ||||
|     title?: string; // The title of the page.
 | ||||
|     isAudio?: boolean; // Whether it should capture audio.
 | ||||
|     isVideo?: boolean; // Whether it should capture video.
 | ||||
|     isImage?: boolean; // Whether it should capture image.
 | ||||
|     readyToCapture?: boolean; // Whether it's ready to capture.
 | ||||
|     hasCaptured?: boolean; // Whether it has captured something.
 | ||||
|     isCapturing?: boolean; // Whether it's capturing.
 | ||||
|     resetChrono?: boolean; // Boolean to reset the chrono.
 | ||||
|     isCordovaAudioCapture?: boolean; // Whether it's capturing audio using Cordova plugin.
 | ||||
| 
 | ||||
|     protected isCaptureImage?: boolean; // To identify if it's capturing an image using media capture plugin (instead of camera).
 | ||||
|     protected mediaRecorder?: MediaRecorder; // To record video/audio.
 | ||||
|     protected previewMedia?: HTMLAudioElement | HTMLVideoElement; // The element to preview the audio/video captured.
 | ||||
|     protected mediaRecorder?: MediaRecorder; // To record video.
 | ||||
|     protected previewMedia?: HTMLVideoElement; // The element to preview the video captured.
 | ||||
|     protected mediaBlob?: Blob; // A Blob where the captured data is stored.
 | ||||
|     protected localMediaStream?: MediaStream; | ||||
|     protected audioDrawer?: {start: () => void; stop: () => void }; // To start/stop the display of audio sound.
 | ||||
| 
 | ||||
|     // Variables for Cordova Media capture.
 | ||||
|     protected mediaFile?: MediaObject; | ||||
|     protected filePath?: string; | ||||
|     protected fileEntry?: FileEntry; | ||||
| 
 | ||||
|     constructor( | ||||
|         protected changeDetectorRef: ChangeDetectorRef, | ||||
| @ -84,12 +71,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
|      */ | ||||
|     ngOnInit(): void { | ||||
|         this.initVariables(); | ||||
| 
 | ||||
|         if (this.isCordovaAudioCapture) { | ||||
|             this.initCordovaMediaPlugin(); | ||||
|         } else { | ||||
|             this.initHtmlCapture(); | ||||
|         } | ||||
|         this.initHtmlCapture(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -108,71 +90,10 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
|         if (this.type == 'video') { | ||||
|             this.isVideo = true; | ||||
|             this.title = 'core.capturevideo'; | ||||
|         } else if (this.type == 'audio') { | ||||
|             this.isAudio = true; | ||||
|             this.title = 'core.captureaudio'; | ||||
|         } else if (this.type == 'image') { | ||||
|             this.isImage = true; | ||||
|             this.title = 'core.captureimage'; | ||||
|         } | ||||
| 
 | ||||
|         this.isCordovaAudioCapture = CorePlatform.isMobile() && this.isAudio; | ||||
| 
 | ||||
|         if (this.isCordovaAudioCapture) { | ||||
|             this.extension = CorePlatform.is('ios') ? 'wav' : 'aac'; | ||||
|             this.returnDataUrl = false; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Init recording with Cordova media plugin. | ||||
|      * | ||||
|      * @returns Promise resolved when ready. | ||||
|      */ | ||||
|     protected async initCordovaMediaPlugin(): Promise<void> { | ||||
| 
 | ||||
|         try { | ||||
|             await this.createFileAndMediaInstance(); | ||||
| 
 | ||||
|             this.readyToCapture = true; | ||||
|             this.previewMedia = this.previewAudio?.nativeElement; | ||||
|         } catch (error) { | ||||
|             this.dismissWithError(-1, error.message || error); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Create a file entry and the cordova media instance. | ||||
|      */ | ||||
|     protected async createFileAndMediaInstance(): Promise<void> { | ||||
|         this.filePath = this.getFilePath(); | ||||
| 
 | ||||
|         // First create the file.
 | ||||
|         this.fileEntry = await CoreFile.createFile(this.filePath); | ||||
| 
 | ||||
|         // Now create the media instance.
 | ||||
|         let absolutePath = CorePath.concatenatePaths(CoreFile.getBasePathInstant(), this.filePath); | ||||
| 
 | ||||
|         if (CorePlatform.is('ios')) { | ||||
|             // In iOS we need to remove the file:// part.
 | ||||
|             absolutePath = absolutePath.replace(/^file:\/\//, ''); | ||||
|         } | ||||
| 
 | ||||
|         this.mediaFile = Media.create(absolutePath); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Reset the file and the cordova media instance. | ||||
|      */ | ||||
|     protected async resetCordovaMediaCapture(): Promise<void> { | ||||
|         if (this.filePath) { | ||||
|             // Remove old file, don't block the user for this.
 | ||||
|             CoreFile.removeFile(this.filePath); | ||||
|         } | ||||
| 
 | ||||
|         this.mediaFile?.release(); | ||||
| 
 | ||||
|         await this.createFileAndMediaInstance(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -183,8 +104,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
|      */ | ||||
|     protected async initHtmlCapture(): Promise<void> { | ||||
|         const constraints = { | ||||
|             video: this.isAudio ? false : { facingMode: this.facingMode }, | ||||
|             audio: !this.isImage, | ||||
|             video: { facingMode: this.facingMode }, | ||||
|         }; | ||||
| 
 | ||||
|         try { | ||||
| @ -196,22 +116,18 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
|             if (!this.isImage) { | ||||
|                 if (this.isVideo) { | ||||
|                     this.previewMedia = this.previewVideo?.nativeElement; | ||||
|                 } else { | ||||
|                     this.previewMedia = this.previewAudio?.nativeElement; | ||||
|                     this.initAudioDrawer(this.localMediaStream); | ||||
|                     this.audioDrawer?.start(); | ||||
|                 } | ||||
| 
 | ||||
|                 this.mediaRecorder = new MediaRecorder(this.localMediaStream, { mimeType: this.mimetype }); | ||||
| 
 | ||||
|                 // When video or audio is recorded, add it to the list of chunks.
 | ||||
|                 // When video is recorded, add it to the list of chunks.
 | ||||
|                 this.mediaRecorder.ondataavailable = (e): void => { | ||||
|                     if (e.data.size > 0) { | ||||
|                         chunks.push(e.data); | ||||
|                     } | ||||
|                 }; | ||||
| 
 | ||||
|                 // When recording stops, create a Blob element with the recording and set it to the video or audio.
 | ||||
|                 // When recording stops, create a Blob element with the recording and set it to the video.
 | ||||
|                 this.mediaRecorder.onstop = (): void => { | ||||
|                     this.mediaBlob = new Blob(chunks); | ||||
|                     chunks = []; | ||||
| @ -267,91 +183,6 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Initialize the audio drawer. This code has been extracted from MDN's example on MediaStream Recording: | ||||
|      * https://github.com/mdn/web-dictaphone
 | ||||
|      * | ||||
|      * @param stream Stream returned by getUserMedia. | ||||
|      */ | ||||
|     protected initAudioDrawer(stream: MediaStream): void { | ||||
|         if (!this.streamAudio) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         let skip = true; | ||||
|         let running = false; | ||||
| 
 | ||||
|         // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
|         const audioCtx = new (window.AudioContext || (<any> window).webkitAudioContext)(); | ||||
|         const canvasCtx = this.streamAudio.nativeElement.getContext('2d'); | ||||
|         const source = audioCtx.createMediaStreamSource(stream); | ||||
|         const analyser = audioCtx.createAnalyser(); | ||||
|         const bufferLength = analyser.frequencyBinCount; | ||||
|         const dataArray = new Uint8Array(bufferLength); | ||||
|         const width = this.streamAudio.nativeElement.width; | ||||
|         const height = this.streamAudio.nativeElement.height; | ||||
|         const drawAudio = (): void => { | ||||
|             if (!running) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             // Update the draw every animation frame.
 | ||||
|             requestAnimationFrame(drawAudio); | ||||
| 
 | ||||
|             // Skip half of the frames to improve performance, shouldn't affect the smoothness.
 | ||||
|             skip = !skip; | ||||
|             if (skip) { | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             const sliceWidth = width / bufferLength; | ||||
|             let x = 0; | ||||
| 
 | ||||
|             analyser.getByteTimeDomainData(dataArray); | ||||
| 
 | ||||
|             canvasCtx.fillStyle = 'rgb(200, 200, 200)'; | ||||
|             canvasCtx.fillRect(0, 0, width, height); | ||||
| 
 | ||||
|             canvasCtx.lineWidth = 1; | ||||
|             canvasCtx.strokeStyle = 'rgb(0, 0, 0)'; | ||||
| 
 | ||||
|             canvasCtx.beginPath(); | ||||
| 
 | ||||
|             for (let i = 0; i < bufferLength; i++) { | ||||
|                 const v = dataArray[i] / 128.0; | ||||
|                 const y = v * height / 2; | ||||
| 
 | ||||
|                 if (i === 0) { | ||||
|                     canvasCtx.moveTo(x, y); | ||||
|                 } else { | ||||
|                     canvasCtx.lineTo(x, y); | ||||
|                 } | ||||
| 
 | ||||
|                 x += sliceWidth; | ||||
|             } | ||||
| 
 | ||||
|             canvasCtx.lineTo(width, height / 2); | ||||
|             canvasCtx.stroke(); | ||||
|         }; | ||||
| 
 | ||||
|         analyser.fftSize = 2048; | ||||
|         source.connect(analyser); | ||||
| 
 | ||||
|         this.audioDrawer = { | ||||
|             start: (): void => { | ||||
|                 if (running) { | ||||
|                     return; | ||||
|                 } | ||||
| 
 | ||||
|                 running = true; | ||||
|                 drawAudio(); | ||||
|             }, | ||||
|             stop: (): void => { | ||||
|                 running = false; | ||||
|             }, | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Main action clicked: record or stop recording. | ||||
|      */ | ||||
| @ -369,15 +200,7 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
|             this.isCapturing = true; | ||||
|             this.resetChrono = false; | ||||
| 
 | ||||
|             if (this.isCordovaAudioCapture) { | ||||
|                 this.mediaFile?.startRecord(); | ||||
|                 if (this.previewMedia) { | ||||
|                     this.previewMedia.src = ''; | ||||
|                 } | ||||
|             } else { | ||||
|                 this.mediaRecorder?.start(); | ||||
|             } | ||||
| 
 | ||||
|             this.mediaRecorder?.start(); | ||||
|             this.changeDetectorRef.detectChanges(); | ||||
|         } else { | ||||
|             if (!this.imgCanvas) { | ||||
| @ -419,11 +242,6 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
| 
 | ||||
|         // Send a "cancelled" error like the Cordova plugin does.
 | ||||
|         this.dismissWithCanceledError('Canceled.', 'Camera cancelled'); | ||||
| 
 | ||||
|         if (this.isCordovaAudioCapture && this.filePath) { | ||||
|             // Delete the tmp file.
 | ||||
|             CoreFile.removeFile(this.filePath); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -432,11 +250,6 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
|     async discard(): Promise<void> { | ||||
|         this.previewMedia?.pause(); | ||||
|         this.streamVideo?.nativeElement.play(); | ||||
|         this.audioDrawer?.start(); | ||||
| 
 | ||||
|         if (this.isCordovaAudioCapture) { | ||||
|             await this.resetCordovaMediaCapture(); | ||||
|         } | ||||
| 
 | ||||
|         this.hasCaptured = false; | ||||
|         this.isCapturing = false; | ||||
| @ -492,30 +305,23 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         if (!this.mediaBlob && !this.isCordovaAudioCapture) { | ||||
|         if (!this.mediaBlob) { | ||||
|             // Shouldn't happen.
 | ||||
|             CoreDomUtils.showErrorModal('Please capture the media first.'); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         let fileEntry = this.fileEntry; | ||||
|         const loadingModal = await CoreDomUtils.showModalLoading(); | ||||
| 
 | ||||
|         try { | ||||
|             if (!this.isCordovaAudioCapture) { | ||||
|                 // Capturing in browser. Write the blob in a file.
 | ||||
|                 if (!this.mediaBlob) { | ||||
|                     // Shouldn't happen.
 | ||||
|                     throw new Error('Please capture the media first.'); | ||||
|                 } | ||||
| 
 | ||||
|                 fileEntry = await CoreFile.writeFile(this.getFilePath(), this.mediaBlob); | ||||
|             // Capturing in browser. Write the blob in a file.
 | ||||
|             if (!this.mediaBlob) { | ||||
|                 // Shouldn't happen.
 | ||||
|                 throw new Error('Please capture the media first.'); | ||||
|             } | ||||
| 
 | ||||
|             if (!fileEntry) { | ||||
|                 throw new CoreError('File not found.'); | ||||
|             } | ||||
|             const fileEntry = await CoreFile.writeFile(this.getFilePath(), this.mediaBlob); | ||||
| 
 | ||||
|             if (this.isImage && !this.isCaptureImage) { | ||||
|                 this.dismissWithData(fileEntry.toURL()); | ||||
| @ -560,30 +366,20 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Stop capturing. Only for video and audio. | ||||
|      * Stop capturing. Only for video. | ||||
|      */ | ||||
|     stopCapturing(): void { | ||||
|         this.isCapturing = false; | ||||
|         this.hasCaptured = true; | ||||
| 
 | ||||
|         if (this.isCordovaAudioCapture) { | ||||
|             this.mediaFile?.stopRecord(); | ||||
|             if (this.previewMedia && this.fileEntry) { | ||||
|                 this.previewMedia.src = CoreFile.convertFileSrc(this.fileEntry.toURL()); | ||||
|             } | ||||
|         } else { | ||||
|             this.streamVideo && this.streamVideo.nativeElement.pause(); | ||||
|             this.audioDrawer && this.audioDrawer.stop(); | ||||
|             this.mediaRecorder && this.mediaRecorder.stop(); | ||||
|         } | ||||
|         this.streamVideo && this.streamVideo.nativeElement.pause(); | ||||
|         this.mediaRecorder && this.mediaRecorder.stop(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Page destroyed. | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.mediaFile?.release(); | ||||
| 
 | ||||
|         if (this.localMediaStream) { | ||||
|             const tracks = this.localMediaStream.getTracks(); | ||||
|             tracks.forEach((track) => { | ||||
| @ -592,14 +388,13 @@ export class CoreEmulatorCaptureMediaComponent implements OnInit, OnDestroy { | ||||
|         } | ||||
|         this.streamVideo?.nativeElement.pause(); | ||||
|         this.previewMedia?.pause(); | ||||
|         this.audioDrawer?.stop(); | ||||
|         delete this.mediaBlob; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export type CaptureMediaComponentInputs = { | ||||
|     type: 'audio' | 'video' | 'image' | 'captureimage'; | ||||
|     type: 'video' | 'image' | 'captureimage'; | ||||
|     maxTime?: number; // Max time to capture.
 | ||||
|     facingMode?: string; // Camera facing mode.
 | ||||
|     mimetype?: string; | ||||
|  | ||||
							
								
								
									
										21
									
								
								src/core/features/emulator/emulator.module.prod.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/core/features/emulator/emulator.module.prod.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { NgModule } from '@angular/core'; | ||||
| 
 | ||||
| /** | ||||
|  * Stub used in production to avoid including emulator code in production bundles. | ||||
|  */ | ||||
| @NgModule({}) | ||||
| export class CoreEmulatorModule {} | ||||
| @ -14,23 +14,18 @@ | ||||
| 
 | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { CameraOptions } from '@ionic-native/camera/ngx'; | ||||
| import { CaptureAudioOptions, CaptureImageOptions, CaptureVideoOptions, MediaFile } from '@ionic-native/media-capture/ngx'; | ||||
| import { CaptureImageOptions, CaptureVideoOptions, MediaFile } from '@ionic-native/media-capture/ngx'; | ||||
| 
 | ||||
| import { CoreMimetypeUtils } from '@services/utils/mimetype'; | ||||
| import { makeSingleton, ModalController } from '@singletons'; | ||||
| import { CaptureMediaComponentInputs, CoreEmulatorCaptureMediaComponent } from '../components/capture-media/capture-media'; | ||||
| 
 | ||||
| /** | ||||
|  * Helper service with some features to capture media (image, audio, video). | ||||
|  * Helper service with some features to capture media (image, video). | ||||
|  */ | ||||
| @Injectable({ providedIn: 'root' }) | ||||
| export class CoreEmulatorCaptureHelperProvider { | ||||
| 
 | ||||
|     protected possibleAudioMimeTypes = { | ||||
|         'audio/webm': 'weba', | ||||
|         'audio/ogg': 'ogg', | ||||
|     }; | ||||
| 
 | ||||
|     protected possibleVideoMimeTypes = { | ||||
|         'video/webm;codecs=vp9': 'webm', | ||||
|         'video/webm;codecs=vp8': 'webm', | ||||
| @ -38,22 +33,20 @@ export class CoreEmulatorCaptureHelperProvider { | ||||
|     }; | ||||
| 
 | ||||
|     videoMimeType?: string; | ||||
|     audioMimeType?: string; | ||||
| 
 | ||||
|     /** | ||||
|      * Capture media (image, audio, video). | ||||
|      * Capture media (image, video). | ||||
|      * | ||||
|      * @param type Type of media: image, audio, video. | ||||
|      * @param type Type of media: image, video. | ||||
|      * @param options Optional options. | ||||
|      * @returns Promise resolved when captured, rejected if error. | ||||
|      */ | ||||
|     captureMedia(type: 'image', options?: MockCameraOptions): Promise<string>; | ||||
|     captureMedia(type: 'captureimage', options?: MockCaptureImageOptions): Promise<MediaFile[]>; | ||||
|     captureMedia(type: 'audio', options?: MockCaptureAudioOptions): Promise<MediaFile[]>; | ||||
|     captureMedia(type: 'video', options?: MockCaptureVideoOptions): Promise<MediaFile[]>; | ||||
|     async captureMedia( | ||||
|         type: 'image' | 'captureimage' | 'audio' | 'video', | ||||
|         options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureAudioOptions | MockCaptureVideoOptions, | ||||
|         type: 'image' | 'captureimage' | 'video', | ||||
|         options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureVideoOptions, | ||||
|     ): Promise<MediaFile[] | string> { | ||||
|         options = options || {}; | ||||
| 
 | ||||
| @ -67,10 +60,6 @@ export class CoreEmulatorCaptureHelperProvider { | ||||
|             const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); | ||||
|             params.mimetype = mimeAndExt.mimetype; | ||||
|             params.extension = mimeAndExt.extension; | ||||
|         } else if (type == 'audio') { | ||||
|             const mimeAndExt = this.getMimeTypeAndExtension(type, options.mimetypes); | ||||
|             params.mimetype = mimeAndExt.mimetype; | ||||
|             params.extension = mimeAndExt.extension; | ||||
|         } else if (type == 'image') { | ||||
|             if ('sourceType' in options && options.sourceType !== undefined && options.sourceType != 1) { | ||||
|                 return Promise.reject('This source type is not supported in browser.'); | ||||
| @ -121,7 +110,7 @@ export class CoreEmulatorCaptureHelperProvider { | ||||
|     /** | ||||
|      * Get the mimetype and extension to capture media. | ||||
|      * | ||||
|      * @param type Type of media: image, audio, video. | ||||
|      * @param type Type of media: image, video. | ||||
|      * @param mimetypes List of supported mimetypes. If undefined, all mimetypes supported. | ||||
|      * @returns An object with mimetype and extension to use. | ||||
|      */ | ||||
| @ -148,10 +137,6 @@ export class CoreEmulatorCaptureHelperProvider { | ||||
|             // No mimetype found, use default extension.
 | ||||
|             result.mimetype = this.videoMimeType; | ||||
|             result.extension = this.possibleVideoMimeTypes[result.mimetype!]; | ||||
|         } else if (type == 'audio') { | ||||
|             // No mimetype found, use default extension.
 | ||||
|             result.mimetype = this.audioMimeType; | ||||
|             result.extension = this.possibleAudioMimeTypes[result.mimetype!]; | ||||
|         } | ||||
| 
 | ||||
|         return result; | ||||
| @ -170,20 +155,12 @@ export class CoreEmulatorCaptureHelperProvider { | ||||
|      * Initialize the mimetypes to use when capturing. | ||||
|      */ | ||||
|     protected initMimeTypes(): void { | ||||
|         // Determine video and audio mimetype to use.
 | ||||
|         for (const mimeType in this.possibleVideoMimeTypes) { | ||||
|             if (window.MediaRecorder.isTypeSupported(mimeType)) { | ||||
|                 this.videoMimeType = mimeType; | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|         for (const mimeType in this.possibleAudioMimeTypes) { | ||||
|             if (window.MediaRecorder.isTypeSupported(mimeType)) { | ||||
|                 this.audioMimeType = mimeType; | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -209,9 +186,6 @@ export interface MockCameraOptions extends CameraOptions { | ||||
| export interface MockCaptureImageOptions extends CaptureImageOptions { | ||||
|     mimetypes?: string[]; // Allowed mimetypes.
 | ||||
| } | ||||
| export interface MockCaptureAudioOptions extends CaptureAudioOptions { | ||||
|     mimetypes?: string[]; // Allowed mimetypes.
 | ||||
| } | ||||
| export interface MockCaptureVideoOptions extends CaptureVideoOptions { | ||||
|     mimetypes?: string[]; // Allowed mimetypes.
 | ||||
| } | ||||
|  | ||||
| @ -15,7 +15,6 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { | ||||
|     MediaCapture, | ||||
|     CaptureAudioOptions, | ||||
|     CaptureImageOptions, | ||||
|     CaptureVideoOptions, | ||||
|     MediaFile, | ||||
| @ -29,16 +28,6 @@ import { CoreEmulatorCaptureHelper } from './capture-helper'; | ||||
| @Injectable() | ||||
| export class MediaCaptureMock extends MediaCapture { | ||||
| 
 | ||||
|     /** | ||||
|      * Start the audio recorder application and return information about captured audio clip files. | ||||
|      * | ||||
|      * @param options Options. | ||||
|      * @returns Promise resolved when captured. | ||||
|      */ | ||||
|     captureAudio(options: CaptureAudioOptions): Promise<MediaFile[]> { | ||||
|         return CoreEmulatorCaptureHelper.captureMedia('audio', options); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Start the camera application and return information about captured image files. | ||||
|      * | ||||
|  | ||||
| @ -0,0 +1 @@ | ||||
| <canvas #canvas></canvas> | ||||
| @ -0,0 +1,10 @@ | ||||
| :host { | ||||
|     --background-color: var(--ion-background-color, #fff); | ||||
|     --bars-color: var(--ion-text-color, #000); | ||||
| 
 | ||||
|     canvas { | ||||
|         width: 100%; | ||||
|         height: 100%; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,160 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { AfterViewInit, ChangeDetectionStrategy, Component, ElementRef, Input, OnDestroy, ViewChild } from '@angular/core'; | ||||
| 
 | ||||
| @Component({ | ||||
|     selector: 'core-audio-histogram', | ||||
|     templateUrl: 'audio-histogram.html', | ||||
|     styleUrls: ['audio-histogram.scss'], | ||||
|     changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class CoreFileUploaderAudioHistogramComponent implements AfterViewInit, OnDestroy { | ||||
| 
 | ||||
|     private static readonly BARS_WIDTH = 2; | ||||
|     private static readonly BARS_MIN_HEIGHT = 4; | ||||
|     private static readonly BARS_GUTTER = 4; | ||||
| 
 | ||||
|     @Input() analyser!: AnalyserNode; | ||||
|     @Input() paused?: boolean; | ||||
|     @ViewChild('canvas') canvasRef?: ElementRef<HTMLCanvasElement>; | ||||
| 
 | ||||
|     private element: HTMLElement; | ||||
|     private canvas?: HTMLCanvasElement; | ||||
|     private context?: CanvasRenderingContext2D | null; | ||||
|     private buffer?: Uint8Array; | ||||
|     private destroyed = false; | ||||
| 
 | ||||
|     constructor({ nativeElement }: ElementRef<HTMLElement>) { | ||||
|         this.element = nativeElement; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngAfterViewInit(): void { | ||||
|         this.canvas = this.canvasRef?.nativeElement; | ||||
|         this.context = this.canvas?.getContext('2d'); | ||||
|         this.buffer = new Uint8Array(this.analyser.fftSize); | ||||
| 
 | ||||
|         if (this.context && this.canvas) { | ||||
|             const styles = getComputedStyle(this.element); | ||||
| 
 | ||||
|             this.canvas.width = this.canvas.clientWidth; | ||||
|             this.canvas.height = this.canvas.clientHeight; | ||||
|             this.context.fillStyle = styles.getPropertyValue('--background-color'); | ||||
|             this.context.lineCap = 'round'; | ||||
|             this.context.lineWidth = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH; | ||||
|             this.context.strokeStyle = styles.getPropertyValue('--bars-color'); | ||||
|         } | ||||
| 
 | ||||
|         this.draw(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.destroyed = true; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Draw histogram. | ||||
|      */ | ||||
|     private draw(): void { | ||||
|         if (this.destroyed || !this.canvas || !this.context || !this.buffer) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const width = this.canvas.width; | ||||
|         const height = this.canvas.height; | ||||
|         const barsWidth = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH; | ||||
|         const barsGutter = CoreFileUploaderAudioHistogramComponent.BARS_GUTTER; | ||||
|         const chunkLength = Math.floor(this.buffer.length / ((width - barsWidth - 1) / (barsWidth + barsGutter))); | ||||
|         const barsCount = Math.floor(this.buffer.length / chunkLength); | ||||
| 
 | ||||
|         // Reset canvas.
 | ||||
|         this.context.fillRect(0, 0, width, height); | ||||
| 
 | ||||
|         // Draw bars.
 | ||||
|         const startX = Math.floor((width - (barsWidth + barsGutter)*barsCount - barsWidth - 1)/2); | ||||
| 
 | ||||
|         this.context.beginPath(); | ||||
|         this.paused ? this.drawPausedBars(startX) : this.drawActiveBars(startX); | ||||
|         this.context.stroke(); | ||||
| 
 | ||||
|         // Schedule next frame.
 | ||||
|         requestAnimationFrame(() => this.draw()); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Draws bars on the histogram when it is active. | ||||
|      * | ||||
|      * @param x Starting x position. | ||||
|      */ | ||||
|     private drawActiveBars(x: number): void { | ||||
|         if (!this.canvas || !this.context || !this.buffer) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         let bufferX = 0; | ||||
|         const width = this.canvas.width; | ||||
|         const halfHeight = this.canvas.height / 2; | ||||
|         const halfMinHeight = CoreFileUploaderAudioHistogramComponent.BARS_MIN_HEIGHT / 2; | ||||
|         const barsWidth = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH; | ||||
|         const barsGutter = CoreFileUploaderAudioHistogramComponent.BARS_GUTTER; | ||||
|         const bufferLength = this.buffer.length; | ||||
|         const barsBufferWidth = Math.floor(bufferLength / ((width - barsWidth - 1) / (barsWidth + barsGutter))); | ||||
| 
 | ||||
|         this.analyser.getByteTimeDomainData(this.buffer); | ||||
| 
 | ||||
|         while (bufferX < bufferLength) { | ||||
|             let maxLevel = halfMinHeight; | ||||
| 
 | ||||
|             do { | ||||
|                 maxLevel = Math.max(maxLevel, halfHeight * (1 - (this.buffer[bufferX] / 128))); | ||||
|                 bufferX++; | ||||
|             } while (bufferX % barsBufferWidth !== 0 && bufferX < bufferLength); | ||||
| 
 | ||||
|             this.context.moveTo(x, halfHeight - maxLevel); | ||||
|             this.context.lineTo(x, halfHeight + maxLevel); | ||||
| 
 | ||||
|             x += barsWidth + barsGutter; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Draws bars on the histogram when it is paused. | ||||
|      * | ||||
|      * @param x Starting x position. | ||||
|      */ | ||||
|     private drawPausedBars(x: number): void { | ||||
|         if (!this.canvas || !this.context) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const width = this.canvas.width; | ||||
|         const halfHeight = this.canvas.height / 2; | ||||
|         const halfMinHeight = CoreFileUploaderAudioHistogramComponent.BARS_MIN_HEIGHT / 2; | ||||
|         const xStep = CoreFileUploaderAudioHistogramComponent.BARS_WIDTH + CoreFileUploaderAudioHistogramComponent.BARS_GUTTER; | ||||
| 
 | ||||
|         while (x < width) { | ||||
|             this.context.moveTo(x, halfHeight - halfMinHeight); | ||||
|             this.context.lineTo(x, halfHeight + halfMinHeight); | ||||
| 
 | ||||
|             x += xStep; | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,316 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { ChangeDetectionStrategy, Component, ElementRef, OnDestroy } from '@angular/core'; | ||||
| import { CoreModalComponent } from '@classes/modal-component'; | ||||
| import { CorePlatform } from '@services/platform'; | ||||
| import { Diagnostic, DomSanitizer, Translate } from '@singletons'; | ||||
| import { BehaviorSubject, combineLatest, Observable, OperatorFunction } from 'rxjs'; | ||||
| import { Mp3MediaRecorder } from 'mp3-mediarecorder'; | ||||
| import { map, shareReplay, tap } from 'rxjs/operators'; | ||||
| import { initAudioEncoderMessage } from '@features/fileuploader/utils/worker-messages'; | ||||
| import { SafeUrl } from '@angular/platform-browser'; | ||||
| import { CoreDomUtils } from '@services/utils/dom'; | ||||
| import { CAPTURE_ERROR_NO_MEDIA_FILES, CoreCaptureError } from '@classes/errors/captureerror'; | ||||
| import { CoreFileUploaderAudioRecording } from '@features/fileuploader/services/fileuploader'; | ||||
| import { CoreFile, CoreFileProvider } from '@services/file'; | ||||
| import { CorePath } from '@singletons/path'; | ||||
| 
 | ||||
| @Component({ | ||||
|     selector: 'core-fileuploader-audio-recorder', | ||||
|     styleUrls: ['./audio-recorder.scss'], | ||||
|     templateUrl: 'audio-recorder.html', | ||||
|     changeDetection: ChangeDetectionStrategy.OnPush, | ||||
| }) | ||||
| export class CoreFileUploaderAudioRecorderComponent extends CoreModalComponent<CoreFileUploaderAudioRecording> | ||||
|     implements OnDestroy { | ||||
| 
 | ||||
|     recordingUrl$: Observable<SafeUrl | null>; | ||||
|     histogramAnalyzer$: Observable<AnalyserNode | null>; | ||||
|     status$: Observable<'recording-ongoing' | 'recording-paused' | 'done' | 'empty'>; | ||||
| 
 | ||||
|     protected recording: AudioRecording | null; | ||||
|     protected media$: BehaviorSubject<AudioRecorderMedia | null>; | ||||
|     protected recording$: Observable<AudioRecording | null>; | ||||
| 
 | ||||
|     constructor(elementRef: ElementRef<HTMLElement>) { | ||||
|         super(elementRef); | ||||
| 
 | ||||
|         this.recording = null; | ||||
|         this.media$ = new BehaviorSubject(null); | ||||
|         this.recording$ = this.media$.pipe( | ||||
|             recorderAudioRecording(), | ||||
|             shareReplay(), | ||||
|             tap(recording => this.recording = recording), | ||||
|         ); | ||||
|         this.recordingUrl$ = this.recording$.pipe( | ||||
|             map(recording => recording && DomSanitizer.bypassSecurityTrustUrl(recording.url)), | ||||
|         ); | ||||
|         this.histogramAnalyzer$ = this.media$.pipe(map(media => { | ||||
|             if (!media?.analyser || CorePlatform.prefersReducedMotion()) { | ||||
|                 return null; | ||||
|             } | ||||
| 
 | ||||
|             return media.analyser; | ||||
|         })); | ||||
|         this.status$ = combineLatest([this.media$.pipe(recorderStatus(), shareReplay()), this.recording$]) | ||||
|             .pipe(map(([recordingStatus, recording]) => { | ||||
|                 if (recordingStatus === 'recording') { | ||||
|                     return 'recording-ongoing'; | ||||
|                 } | ||||
| 
 | ||||
|                 if (recordingStatus === 'paused') { | ||||
|                     return 'recording-paused'; | ||||
|                 } | ||||
| 
 | ||||
|                 if (recording) { | ||||
|                     return 'done'; | ||||
|                 } | ||||
| 
 | ||||
|                 return 'empty'; | ||||
|             })); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * @inheritdoc | ||||
|      */ | ||||
|     ngOnDestroy(): void { | ||||
|         this.media$.value?.recorder.stop(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Start recording. | ||||
|      */ | ||||
|     async startRecording(): Promise<void> { | ||||
|         const media = await this.createMedia(); | ||||
| 
 | ||||
|         this.media$.next(media); | ||||
| 
 | ||||
|         media.recorder.start(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Stop recording. | ||||
|      */ | ||||
|     stopRecording(): void { | ||||
|         this.media$.value?.recorder.stop(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Stop recording. | ||||
|      */ | ||||
|     pauseRecording(): void { | ||||
|         this.media$.value?.recorder.pause(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Stop recording. | ||||
|      */ | ||||
|     resumeRecording(): void { | ||||
|         this.media$.value?.recorder.resume(); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Discard recording. | ||||
|      */ | ||||
|     discardRecording(): void { | ||||
|         this.media$.next(null); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Dismiss modal without a result. | ||||
|      */ | ||||
|     async cancel(): Promise<void> { | ||||
|         this.close(new CoreCaptureError(CAPTURE_ERROR_NO_MEDIA_FILES)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Dismiss the modal with the current recording as a result. | ||||
|      */ | ||||
|     async submit(): Promise<void> { | ||||
|         if (!this.recording) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const fileName = await CoreFile.getUniqueNameInFolder(CoreFileProvider.TMPFOLDER, 'recording.mp3'); | ||||
|         const filePath = CorePath.concatenatePaths(CoreFileProvider.TMPFOLDER, fileName); | ||||
|         const fileEntry = await CoreFile.writeFile(filePath, this.recording.blob); | ||||
| 
 | ||||
|         this.close({ | ||||
|             name: fileEntry.name, | ||||
|             fullPath: fileEntry.toURL(), | ||||
|             type: 'audio/mpeg', | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Create media instances. | ||||
|      * | ||||
|      * @returns Media instances. | ||||
|      */ | ||||
|     protected async createMedia(): Promise<AudioRecorderMedia> { | ||||
|         await this.prepareMicrophoneAuthorization(); | ||||
| 
 | ||||
|         const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true }); | ||||
|         const audioContext = new window.AudioContext(); | ||||
|         const source = audioContext.createMediaStreamSource(mediaStream); | ||||
|         const analyser = audioContext.createAnalyser(); | ||||
| 
 | ||||
|         analyser.fftSize = 2048; | ||||
|         source.connect(analyser); | ||||
| 
 | ||||
|         return { | ||||
|             analyser, | ||||
|             recorder: new Mp3MediaRecorder(mediaStream, { worker: this.startWorker(), audioContext }), | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Make sure that microphone usage has been authorized. | ||||
|      */ | ||||
|     protected async prepareMicrophoneAuthorization(): Promise<void> { | ||||
|         if (!CorePlatform.isMobile()) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         const status = await Diagnostic.requestMicrophoneAuthorization(); | ||||
| 
 | ||||
|         switch (status) { | ||||
|             case Diagnostic.permissionStatus.DENIED_ONCE: | ||||
|             case Diagnostic.permissionStatus.DENIED_ALWAYS: | ||||
|                 throw new Error(Translate.instant('core.fileuploader.microphonepermissiondenied')); | ||||
|             case Diagnostic.permissionStatus.RESTRICTED: | ||||
|                 throw new Error(Translate.instant('core.fileuploader.microphonepermissionrestricted')); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Start worker script. | ||||
|      * | ||||
|      * @returns Worker. | ||||
|      */ | ||||
|     protected startWorker(): Worker { | ||||
|         const worker = new Worker('./audio-recorder.worker', { type: 'module' }); | ||||
| 
 | ||||
|         worker.postMessage( | ||||
|             initAudioEncoderMessage({ vmsgWasmUrl: `${document.head.baseURI}assets/lib/vmsg/vmsg.wasm` }), | ||||
|         ); | ||||
| 
 | ||||
|         return worker; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Audio recording data. | ||||
|  */ | ||||
| interface AudioRecording { | ||||
|     url: string; | ||||
|     blob: Blob; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Media instances. | ||||
|  */ | ||||
| interface AudioRecorderMedia { | ||||
|     recorder: Mp3MediaRecorder; | ||||
|     analyser: AnalyserNode; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Observable operator that listens to a recorder and emits a recording file. | ||||
|  * | ||||
|  * @returns Operator. | ||||
|  */ | ||||
| function recorderAudioRecording(): OperatorFunction<AudioRecorderMedia | null, AudioRecording | null> { | ||||
|     return source => new Observable(subscriber => { | ||||
|         let audioChunks: Blob[] = []; | ||||
|         let previousRecorder: Mp3MediaRecorder | undefined; | ||||
|         const onDataAvailable = event => audioChunks.push(event.data); | ||||
|         const onError = event => CoreDomUtils.showErrorModal(event.error); | ||||
|         const onStop = () => { | ||||
|             const blob = new Blob(audioChunks, { type: 'audio/mpeg' }); | ||||
| 
 | ||||
|             subscriber.next({ | ||||
|                 url: URL.createObjectURL(blob), | ||||
|                 blob, | ||||
|             }); | ||||
|         }; | ||||
|         const subscription = source.subscribe(media => { | ||||
|             previousRecorder?.removeEventListener('dataavailable', onDataAvailable); | ||||
|             previousRecorder?.removeEventListener('error', onError); | ||||
|             previousRecorder?.removeEventListener('stop', onStop); | ||||
| 
 | ||||
|             media?.recorder.addEventListener('dataavailable', onDataAvailable); | ||||
|             media?.recorder.addEventListener('error', onError); | ||||
|             media?.recorder.addEventListener('stop', onStop); | ||||
| 
 | ||||
|             audioChunks = []; | ||||
|             previousRecorder = media?.recorder; | ||||
| 
 | ||||
|             subscriber.next(null); | ||||
|         }); | ||||
| 
 | ||||
|         subscriber.next(null); | ||||
| 
 | ||||
|         return () => { | ||||
|             subscription.unsubscribe(); | ||||
| 
 | ||||
|             previousRecorder?.removeEventListener('dataavailable', onDataAvailable); | ||||
|             previousRecorder?.removeEventListener('error', onError); | ||||
|             previousRecorder?.removeEventListener('stop', onStop); | ||||
|         }; | ||||
|     }); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Observable operator that listens to a recorder and emits its recording status. | ||||
|  * | ||||
|  * @returns Operator. | ||||
|  */ | ||||
| function recorderStatus(): OperatorFunction<AudioRecorderMedia | null, RecordingState> { | ||||
|     return source => new Observable(subscriber => { | ||||
|         let previousRecorder: Mp3MediaRecorder | undefined; | ||||
|         const onStart = () => subscriber.next('recording'); | ||||
|         const onPause = () => subscriber.next('paused'); | ||||
|         const onResume = () => subscriber.next('recording'); | ||||
|         const onStop = () => subscriber.next('inactive'); | ||||
|         const subscription = source.subscribe(media => { | ||||
|             previousRecorder?.removeEventListener('start', onStart); | ||||
|             previousRecorder?.removeEventListener('pause', onPause); | ||||
|             previousRecorder?.removeEventListener('resume', onResume); | ||||
|             previousRecorder?.removeEventListener('stop', onStop); | ||||
| 
 | ||||
|             media?.recorder.addEventListener('start', onStart); | ||||
|             media?.recorder.addEventListener('pause', onPause); | ||||
|             media?.recorder.addEventListener('resume', onResume); | ||||
|             media?.recorder.addEventListener('stop', onStop); | ||||
| 
 | ||||
|             previousRecorder = media?.recorder; | ||||
| 
 | ||||
|             subscriber.next(media?.recorder.state ?? 'inactive'); | ||||
|         }); | ||||
| 
 | ||||
|         subscriber.next('inactive'); | ||||
| 
 | ||||
|         return () => { | ||||
|             subscription.unsubscribe(); | ||||
| 
 | ||||
|             previousRecorder?.removeEventListener('start', onStart); | ||||
|             previousRecorder?.removeEventListener('pause', onPause); | ||||
|             previousRecorder?.removeEventListener('resume', onResume); | ||||
|             previousRecorder?.removeEventListener('stop', onStop); | ||||
|         }; | ||||
|     }); | ||||
| } | ||||
| @ -0,0 +1,74 @@ | ||||
| <header> | ||||
|     <h1>{{ 'core.fileuploader.audiotitle' | translate }}</h1> | ||||
| 
 | ||||
|     <ion-button shape="round" fill="clear" [attr.aria-label]="'core.close' | translate" (click)="cancel()"> | ||||
|         <ion-icon slot="icon-only" name="close"></ion-icon> | ||||
|     </ion-button> | ||||
| </header> | ||||
| 
 | ||||
| <hr> | ||||
| 
 | ||||
| <ng-container *ngIf="(status$ | async) as status"> | ||||
|     <div *ngIf="status === 'empty'" class="core-audio-recorder--wrapper"> | ||||
|         <p>{{ 'core.fileuploader.startrecordinginstructions' | translate }}</p> | ||||
| 
 | ||||
|         <ion-button shape="round" color="danger" [attr.aria-label]="'core.fileuploader.startrecording' | translate" | ||||
|             (click)="startRecording()"> | ||||
|             <ion-icon slot="icon-only" name="fas-microphone"></ion-icon> | ||||
|         </ion-button> | ||||
|     </div> | ||||
| 
 | ||||
|     <div *ngIf="status.startsWith('recording')" class="core-audio-recorder--wrapper"> | ||||
|         <core-audio-histogram *ngIf="(histogramAnalyzer$ | async) as analyser" [analyser]="analyser" | ||||
|             [paused]="status !== 'recording-ongoing'"> | ||||
|         </core-audio-histogram> | ||||
| 
 | ||||
|         <div class="core-audio-recorder--controls"> | ||||
|             <div class="core-audio-recorder--control chrono"> | ||||
|                 <div *ngIf="status === 'recording-ongoing'" class="core-audio-recorder--recording-marker"></div> | ||||
|                 <core-chrono [class.recording]="status === 'recording-ongoing'" [running]="status === 'recording-ongoing'" [hours]="false"> | ||||
|                 </core-chrono> | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="core-audio-recorder--control"> | ||||
|                 <ion-button *ngIf="status === 'recording-ongoing'" shape="round" fill="clear" | ||||
|                     [attr.aria-label]="'core.fileuploader.pauserecording' | translate" (click)="pauseRecording()"> | ||||
|                     <ion-icon slot="icon-only" name="fas-pause-circle"></ion-icon> | ||||
|                 </ion-button> | ||||
| 
 | ||||
|                 <ion-button *ngIf="status === 'recording-paused'" [attr.aria-label]="'core.fileuploader.resumerecording' | translate" | ||||
|                     shape="round" fill="clear" color="danger" (click)="resumeRecording()"> | ||||
|                     <ion-icon slot="icon-only" name="fas-microphone"></ion-icon> | ||||
|                 </ion-button> | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="core-audio-recorder--control"> | ||||
|                 <ion-button shape="round" fill="clear" [attr.aria-label]="'core.fileuploader.stoprecording' | translate" | ||||
|                     (click)="stopRecording()"> | ||||
|                     <ion-icon slot="icon-only" name="fa-check"></ion-icon> | ||||
|                 </ion-button> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| 
 | ||||
|     <div *ngIf="status === 'done'" class="core-audio-recorder--wrapper"> | ||||
|         <audio *ngIf="(recordingUrl$ | async) as recordingUrl" controls controlsList="nodownload"> | ||||
|             <source [src]="recordingUrl" /> | ||||
|         </audio> | ||||
| 
 | ||||
|         <div class="core-audio-recorder--controls"> | ||||
|             <div class="core-audio-recorder--control"> | ||||
|                 <ion-button shape="round" fill="clear" color="danger" [attr.aria-label]="'core.fileuploader.discardrecording' | translate" | ||||
|                     (click)="discardRecording()"> | ||||
|                     <ion-icon slot="icon-only" name="fas-trash"></ion-icon> | ||||
|                 </ion-button> | ||||
|             </div> | ||||
| 
 | ||||
|             <div class="core-audio-recorder--control"> | ||||
|                 <ion-button (click)="submit()"> | ||||
|                     {{ 'core.save' | translate }} | ||||
|                 </ion-button> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </ng-container> | ||||
| @ -0,0 +1,32 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { CoreSharedModule } from '@/core/shared.module'; | ||||
| import { NgModule } from '@angular/core'; | ||||
| import { CoreFileUploaderAudioHistogramComponent } from '@features/fileuploader/components/audio-histogram/audio-histogram'; | ||||
| 
 | ||||
| import { CoreFileUploaderAudioRecorderComponent } from './audio-recorder.component'; | ||||
| 
 | ||||
| export { CoreFileUploaderAudioRecorderComponent }; | ||||
| 
 | ||||
| @NgModule({ | ||||
|     imports: [ | ||||
|         CoreSharedModule, | ||||
|     ], | ||||
|     declarations: [ | ||||
|         CoreFileUploaderAudioRecorderComponent, | ||||
|         CoreFileUploaderAudioHistogramComponent, | ||||
|     ], | ||||
| }) | ||||
| export class CoreFileUploaderAudioRecorderComponentModule {} | ||||
| @ -0,0 +1,119 @@ | ||||
| :host { | ||||
|     color: var(--ion-text-color, #000); | ||||
| 
 | ||||
|     header { | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|         align-items: center; | ||||
| 
 | ||||
|         h1 { | ||||
|             margin: 0; | ||||
|             font-size: 16px; | ||||
|             font-weight: 400; | ||||
|             line-height: 24px; | ||||
|             letter-spacing: 0.15px; | ||||
|         } | ||||
| 
 | ||||
|         ion-button { | ||||
|             --padding-start: 0; | ||||
|             --padding-end: 0; | ||||
|             --icon-size: 1.8em; | ||||
| 
 | ||||
|             // Offset padding for visual alignment. | ||||
|             margin: calc((var(--icon-size) - var(--a11y-min-target-size)) / 2); | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
|     hr { | ||||
|         background: var(--gray-300); | ||||
|         margin: 16px 0; | ||||
|     } | ||||
| 
 | ||||
|     .core-audio-recorder--wrapper { | ||||
|         display: flex; | ||||
|         flex-direction: column; | ||||
|         align-items: center; | ||||
| 
 | ||||
|         p { | ||||
|             font-size: 14px; | ||||
|             font-weight: 400; | ||||
|             line-height: 20px; | ||||
|             letter-spacing: 0.25px; | ||||
|             text-align: center; | ||||
|             opacity: 0.6; | ||||
|             margin-top: 0; | ||||
|             margin-bottom: 16px; | ||||
|         } | ||||
| 
 | ||||
|         ion-button[shape="round"] { | ||||
|             --border-radius: 99px; | ||||
|             --padding-start: 16px; | ||||
|             --padding-end: 16px; | ||||
|             --padding-top: 16px; | ||||
|             --padding-bottom: 16px; | ||||
| 
 | ||||
|             height: max-content; | ||||
|         } | ||||
| 
 | ||||
|         core-audio-histogram { | ||||
|             width: 100%; | ||||
|             height: 35px; | ||||
|             display: block; | ||||
|         } | ||||
| 
 | ||||
|         audio { | ||||
|             width: 100%; | ||||
|             margin-bottom: 16px; | ||||
|         } | ||||
| 
 | ||||
|         .core-audio-recorder--controls { | ||||
|             width: 100%; | ||||
|             display: flex; | ||||
|             flex-direction: row; | ||||
|             align-items: center; | ||||
|             justify-content: space-between; | ||||
| 
 | ||||
|             .core-audio-recorder--control { | ||||
|                 width: 33%; | ||||
|                 text-align: center; | ||||
| 
 | ||||
|                 &:first-child { | ||||
|                     text-align: start; | ||||
|                 } | ||||
| 
 | ||||
|                 &:last-child { | ||||
|                     text-align: end; | ||||
|                 } | ||||
| 
 | ||||
|                 &.chrono { | ||||
|                     padding: 0 16px; | ||||
|                     display: flex; | ||||
|                     flex-direction: row; | ||||
|                     align-items: center; | ||||
|                     justify-content: flex-start; | ||||
|                 } | ||||
| 
 | ||||
|                 ion-button { | ||||
|                     margin: 0; | ||||
|                 } | ||||
| 
 | ||||
|                 .core-audio-recorder--recording-marker { | ||||
|                     width: 8px; | ||||
|                     height: 8px; | ||||
|                     margin-inline-end: 4px; | ||||
|                     border-radius: 4px; | ||||
|                     background: var(--danger); | ||||
|                 } | ||||
| 
 | ||||
|                 core-chrono.recording { | ||||
|                     color: var(--danger); | ||||
|                 } | ||||
| 
 | ||||
|             } | ||||
| 
 | ||||
|         } | ||||
| 
 | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| @ -0,0 +1,32 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { isInitAudioEncoderMessage } from '@features/fileuploader/utils/worker-messages'; | ||||
| import { initMp3MediaEncoder } from 'mp3-mediarecorder/worker'; | ||||
| 
 | ||||
| /** | ||||
|  * Handle worker message. | ||||
|  * | ||||
|  * @param event Worker message event. | ||||
|  */ | ||||
| function onMessage(event: MessageEvent): void { | ||||
|     if (!isInitAudioEncoderMessage(event.data)) { | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     removeEventListener('message', onMessage); | ||||
|     initMp3MediaEncoder(event.data.config); | ||||
| } | ||||
| 
 | ||||
| addEventListener('message', onMessage); | ||||
| @ -1,9 +1,11 @@ | ||||
| { | ||||
|     "addfiletext": "Add file", | ||||
|     "audio": "Audio", | ||||
|     "audiotitle": "Record audio", | ||||
|     "camera": "Camera", | ||||
|     "confirmuploadfile": "You are about to upload {{size}}. Are you sure you want to continue?", | ||||
|     "confirmuploadunknownsize": "It was not possible to calculate the size of the upload. Are you sure you want to continue?", | ||||
|     "discardrecording": "Discard recording", | ||||
|     "errorcapturingaudio": "Error capturing audio.", | ||||
|     "errorcapturingimage": "Error capturing image.", | ||||
|     "errorcapturingvideo": "Error capturing video.", | ||||
| @ -17,13 +19,20 @@ | ||||
|     "filesofthesetypes": "Accepted file types:", | ||||
|     "invalidfiletype": "{{$a}} filetype cannot be accepted.", | ||||
|     "maxbytesfile": "The file {{$a.file}} is too large. The maximum size you can upload is {{$a.size}}.", | ||||
|     "microphonepermissiondenied": "Permission to access the microphone has been denied.", | ||||
|     "microphonepermissionrestricted": "Microphone access is restricted.", | ||||
|     "more": "More", | ||||
|     "pauserecording": "Pause recording", | ||||
|     "photoalbums": "Photo albums", | ||||
|     "readingfile": "Reading file", | ||||
|     "readingfileperc": "Reading file: {{$a}}%", | ||||
|     "resumerecording": "Resume recording", | ||||
|     "selectafile": "Select a file", | ||||
|     "startrecording": "Start recording", | ||||
|     "startrecordinginstructions": "Tap to start recording", | ||||
|     "stoprecording": "Stop recording", | ||||
|     "uploadafile": "Upload a file", | ||||
|     "uploading": "Uploading", | ||||
|     "uploadingperc": "Uploading: {{$a}}%", | ||||
|     "video": "Video" | ||||
| } | ||||
| } | ||||
|  | ||||
| @ -29,9 +29,14 @@ import { makeSingleton, Translate, Camera, Chooser, ActionSheetController } from | ||||
| import { CoreLogger } from '@singletons/logger'; | ||||
| import { CoreCanceledError } from '@classes/errors/cancelederror'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| import { CoreFileUploader, CoreFileUploaderProvider, CoreFileUploaderOptions } from './fileuploader'; | ||||
| import { | ||||
|     CoreFileUploader, | ||||
|     CoreFileUploaderProvider, | ||||
|     CoreFileUploaderOptions, | ||||
|     CoreFileUploaderAudioRecording, | ||||
| } from './fileuploader'; | ||||
| import { CoreFileUploaderDelegate } from './fileuploader-delegate'; | ||||
| import { CoreCaptureError } from '@classes/errors/captureerror'; | ||||
| import { CAPTURE_ERROR_NO_MEDIA_FILES, CoreCaptureError } from '@classes/errors/captureerror'; | ||||
| import { CoreIonLoadingElement } from '@classes/ion-loading'; | ||||
| import { CoreWSUploadFileResult } from '@services/ws'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
| @ -466,9 +471,9 @@ export class CoreFileUploaderHelperProvider { | ||||
|      * @param defaultMessage Key of the default message to show. | ||||
|      */ | ||||
|     protected treatCaptureError(error: CoreCaptureError, defaultMessage: string): void { | ||||
|         // Cancelled or error. If cancelled, error is an object with code = 3.
 | ||||
|         // Cancelled or error. If cancelled, error is an object with code = CAPTURE_EROR_NO_MEDIA_FILES.
 | ||||
|         if (error) { | ||||
|             if (error.code != 3) { | ||||
|             if (error.code !== CAPTURE_ERROR_NO_MEDIA_FILES) { | ||||
|                 // Error, not cancelled.
 | ||||
|                 this.logger.error('Error while recording audio/video', error); | ||||
| 
 | ||||
| @ -514,7 +519,7 @@ export class CoreFileUploaderHelperProvider { | ||||
|             } | ||||
| 
 | ||||
|             return new CoreError(error); | ||||
|         } else if ('code' in error && error.code == 3) { | ||||
|         } else if ('code' in error && error.code === CAPTURE_ERROR_NO_MEDIA_FILES) { | ||||
|             throw new CoreCanceledError(); | ||||
|         } else { | ||||
|             throw error; | ||||
| @ -539,34 +544,22 @@ export class CoreFileUploaderHelperProvider { | ||||
|     ): Promise<CoreWSUploadFileResult | FileEntry> { | ||||
|         this.logger.debug('Trying to record a ' + (isAudio ? 'audio' : 'video') + ' file'); | ||||
| 
 | ||||
|         // The mimetypes param is only for browser, the Cordova plugin doesn't support it.
 | ||||
|         const captureOptions = { limit: 1, mimetypes: mimetypes }; | ||||
|         let media: MediaFile; | ||||
|         let media: MediaFile | CoreFileUploaderAudioRecording; | ||||
| 
 | ||||
|         try { | ||||
|             const medias = isAudio ? await CoreFileUploader.captureAudio(captureOptions) : | ||||
|                 await CoreFileUploader.captureVideo(captureOptions); | ||||
|             const medias = isAudio | ||||
|                 ? await CoreFileUploader.captureAudio() | ||||
|                 : await CoreFileUploader.captureVideo({ limit: 1 }); | ||||
| 
 | ||||
|             media = medias[0]; // We used limit 1, we only want 1 media.
 | ||||
|         } catch (error) { | ||||
|             const defaultError = isAudio ? 'core.fileuploader.errorcapturingaudio' : 'core.fileuploader.errorcapturingvideo'; | ||||
| 
 | ||||
|             if (isAudio && this.isNoAppError(error) && CorePlatform.isMobile()) { | ||||
|                 // No app to record audio, fallback to capture it ourselves.
 | ||||
|                 try { | ||||
|                     media = await CoreFileUploader.captureAudioInApp(); | ||||
|                 } catch (error) { | ||||
|                     throw this.treatCaptureError(error, 'core.fileuploader.errorcapturingaudio'); // Throw the right error.
 | ||||
|                 } | ||||
| 
 | ||||
|             } else { | ||||
|                 const defaultError = isAudio ? 'core.fileuploader.errorcapturingaudio' : 'core.fileuploader.errorcapturingvideo'; | ||||
| 
 | ||||
|                 throw this.treatCaptureError(error, defaultError); // Throw the right error.
 | ||||
|             } | ||||
|             throw this.treatCaptureError(error, defaultError); // Throw the right error.
 | ||||
|         } | ||||
| 
 | ||||
|         let path = media.fullPath; | ||||
|         const error = CoreFileUploader.isInvalidMimetype(mimetypes, path); // Verify that the mimetype is supported.
 | ||||
|         const error = CoreFileUploader.isInvalidMimetype(mimetypes, media.fullPath); | ||||
| 
 | ||||
|         if (error) { | ||||
|             throw new Error(error); | ||||
| @ -773,7 +766,6 @@ export class CoreFileUploaderHelperProvider { | ||||
|         options: CoreFileUploaderOptions, | ||||
|         siteId?: string, | ||||
|     ): Promise<CoreWSUploadFileResult> { | ||||
| 
 | ||||
|         const errorStr = Translate.instant('core.error'); | ||||
|         const retryStr = Translate.instant('core.retry'); | ||||
|         const uploadingStr = Translate.instant('core.fileuploader.uploading'); | ||||
|  | ||||
| @ -15,7 +15,7 @@ | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { CameraOptions } from '@ionic-native/camera/ngx'; | ||||
| import { FileEntry } from '@ionic-native/file/ngx'; | ||||
| import { MediaFile, CaptureError, CaptureAudioOptions, CaptureVideoOptions } from '@ionic-native/media-capture/ngx'; | ||||
| import { MediaFile, CaptureError, CaptureVideoOptions } from '@ionic-native/media-capture/ngx'; | ||||
| import { Subject } from 'rxjs'; | ||||
| 
 | ||||
| import { CoreFile, CoreFileProvider } from '@services/file'; | ||||
| @ -25,14 +25,14 @@ import { CoreMimetypeUtils } from '@services/utils/mimetype'; | ||||
| import { CoreTimeUtils } from '@services/utils/time'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreWSFile, CoreWSFileUploadOptions, CoreWSUploadFileResult } from '@services/ws'; | ||||
| import { makeSingleton, Translate, MediaCapture, ModalController, Camera } from '@singletons'; | ||||
| import { makeSingleton, Translate, MediaCapture, Camera } from '@singletons'; | ||||
| import { CoreLogger } from '@singletons/logger'; | ||||
| import { CoreEmulatorCaptureMediaComponent } from '@features/emulator/components/capture-media/capture-media'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
| import { CoreSite } from '@classes/site'; | ||||
| import { CoreFileEntry, CoreFileHelper } from '@services/file-helper'; | ||||
| import { CorePath } from '@singletons/path'; | ||||
| import { CorePlatform } from '@services/platform'; | ||||
| import { CoreModals } from '@services/modals'; | ||||
| 
 | ||||
| /** | ||||
|  * File upload options. | ||||
| @ -132,14 +132,21 @@ export class CoreFileUploaderProvider { | ||||
|     /** | ||||
|      * Start the audio recorder application and return information about captured audio clip files. | ||||
|      * | ||||
|      * @param options Options. | ||||
|      * @returns Promise resolved with the result. | ||||
|      */ | ||||
|     async captureAudio(options: CaptureAudioOptions): Promise<MediaFile[] | CaptureError> { | ||||
|     async captureAudio(): Promise<CoreFileUploaderAudioRecording[] | MediaFile[] | CaptureError> { | ||||
|         this.onAudioCapture.next(true); | ||||
| 
 | ||||
|         try { | ||||
|             return await MediaCapture.captureAudio(options); | ||||
|             if (!CorePlatform.supportsMediaCapture() || !CorePlatform.supportsWebAssembly()) { | ||||
|                 const media = await MediaCapture.captureAudio({ limit: 1 }); | ||||
| 
 | ||||
|                 return media; | ||||
|             } | ||||
| 
 | ||||
|             const recording = await this.captureAudioInApp(); | ||||
| 
 | ||||
|             return [recording]; | ||||
|         } finally { | ||||
|             this.onAudioCapture.next(false); | ||||
|         } | ||||
| @ -150,27 +157,17 @@ export class CoreFileUploaderProvider { | ||||
|      * | ||||
|      * @returns Promise resolved with the file. | ||||
|      */ | ||||
|     async captureAudioInApp(): Promise<MediaFile> { | ||||
|         const params = { | ||||
|             type: 'audio', | ||||
|         }; | ||||
|     async captureAudioInApp(): Promise<CoreFileUploaderAudioRecording> { | ||||
|         const { CoreFileUploaderAudioRecorderComponent } = | ||||
|             await import('@features/fileuploader/components/audio-recorder/audio-recorder.module'); | ||||
| 
 | ||||
|         const modal = await ModalController.create({ | ||||
|             component: CoreEmulatorCaptureMediaComponent, | ||||
|             cssClass: 'core-modal-fullscreen', | ||||
|             componentProps: params, | ||||
|             backdropDismiss: false, | ||||
|         }); | ||||
|         const recording = await CoreModals.openSheet(CoreFileUploaderAudioRecorderComponent); | ||||
| 
 | ||||
|         await modal.present(); | ||||
| 
 | ||||
|         const result = await modal.onWillDismiss(); | ||||
| 
 | ||||
|         if (result.role == 'success') { | ||||
|             return result.data[0]; | ||||
|         } else { | ||||
|             throw result.data; | ||||
|         if (!recording) { | ||||
|             throw new Error('Recording missing from audio capture'); | ||||
|         } | ||||
| 
 | ||||
|         return recording; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -335,7 +332,7 @@ export class CoreFileUploaderProvider { | ||||
|      * @param mediaFile File object to upload. | ||||
|      * @returns Options. | ||||
|      */ | ||||
|     getMediaUploadOptions(mediaFile: MediaFile): CoreFileUploaderOptions { | ||||
|     getMediaUploadOptions(mediaFile: MediaFile | CoreFileUploaderAudioRecording): CoreFileUploaderOptions { | ||||
|         const options: CoreFileUploaderOptions = {}; | ||||
|         let filename = mediaFile.name; | ||||
| 
 | ||||
| @ -781,3 +778,9 @@ export type CoreFileUploaderTypeListInfoEntry = { | ||||
|     name?: string; | ||||
|     extlist: string; | ||||
| }; | ||||
| 
 | ||||
| export type CoreFileUploaderAudioRecording = { | ||||
|     name: string; | ||||
|     fullPath: string; | ||||
|     type: string; | ||||
| }; | ||||
|  | ||||
							
								
								
									
										46
									
								
								src/core/features/fileuploader/utils/worker-messages.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/core/features/fileuploader/utils/worker-messages.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,46 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import type { Mp3WorkerConfig } from 'mp3-mediarecorder/types/config.type'; | ||||
| 
 | ||||
| export interface InitAudioEncoderMessage { | ||||
|     name: 'init-audio-encoder'; | ||||
|     config: Mp3WorkerConfig; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Check whether the given data is an init audio encoder message. | ||||
|  * | ||||
|  * @param message Message. | ||||
|  * @returns Whether the data is an init audio encoder message. | ||||
|  */ | ||||
| export function isInitAudioEncoderMessage(message: unknown): message is InitAudioEncoderMessage { | ||||
|     return typeof message === 'object' | ||||
|         && message !== null | ||||
|         && 'name' in message | ||||
|         && message['name'] === 'init-audio-encoder'; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Create an init audio encoder message. | ||||
|  * | ||||
|  * @param config Audio encoder config. | ||||
|  * @returns Message. | ||||
|  */ | ||||
| export function initAudioEncoderMessage(config: Mp3WorkerConfig): InitAudioEncoderMessage { | ||||
|     return { | ||||
|         name: 'init-audio-encoder', | ||||
|         config, | ||||
|     }; | ||||
| } | ||||
| @ -11,6 +11,7 @@ | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Component, OnInit } from '@angular/core'; | ||||
| import { CoreLoginHelper, CoreLoginMethod } from '@features/login/services/login-helper'; | ||||
| import { CoreSites } from '@services/sites'; | ||||
|  | ||||
| @ -29,7 +29,6 @@ import { InAppBrowser } from '@ionic-native/in-app-browser/ngx'; | ||||
| import { WebView } from '@ionic-native/ionic-webview/ngx'; | ||||
| import { Keyboard } from '@ionic-native/keyboard/ngx'; | ||||
| import { LocalNotifications } from '@ionic-native/local-notifications/ngx'; | ||||
| import { Media } from '@ionic-native/media/ngx'; | ||||
| import { MediaCapture } from '@ionic-native/media-capture/ngx'; | ||||
| import { Push } from '@ionic-native/push/ngx'; | ||||
| import { QRScanner } from '@ionic-native/qr-scanner/ngx'; | ||||
| @ -54,7 +53,6 @@ export const CORE_NATIVE_SERVICES = [ | ||||
|     InAppBrowser, | ||||
|     Keyboard, | ||||
|     LocalNotifications, | ||||
|     Media, | ||||
|     MediaCapture, | ||||
|     Push, | ||||
|     QRScanner, | ||||
| @ -82,7 +80,6 @@ export const CORE_NATIVE_SERVICES = [ | ||||
|         InAppBrowser, | ||||
|         Keyboard, | ||||
|         LocalNotifications, | ||||
|         Media, | ||||
|         MediaCapture, | ||||
|         Push, | ||||
|         QRScanner, | ||||
|  | ||||
| @ -11,8 +11,6 @@ | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| 
 | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { CoreError } from '@classes/errors/error'; | ||||
|  | ||||
| @ -40,7 +40,7 @@ export class CoreSecondsToHMSPipe implements PipeTransform { | ||||
|      * @param seconds Number of seconds. | ||||
|      * @returns Formatted seconds. | ||||
|      */ | ||||
|     transform(seconds: string | number): string { | ||||
|     transform(seconds: string | number, showHours: boolean = true): string { | ||||
|         if (!seconds || seconds < 0) { | ||||
|             seconds = 0; | ||||
|         } else if (typeof seconds == 'string') { | ||||
| @ -62,8 +62,9 @@ export class CoreSecondsToHMSPipe implements PipeTransform { | ||||
|         const minutes = Math.floor(seconds / CoreConstants.SECONDS_MINUTE); | ||||
|         seconds -= minutes * CoreConstants.SECONDS_MINUTE; | ||||
| 
 | ||||
|         return CoreTextUtils.twoDigits(hours) + ':' + CoreTextUtils.twoDigits(minutes) + ':' + | ||||
|             CoreTextUtils.twoDigits(seconds); | ||||
|         return showHours | ||||
|             ? CoreTextUtils.twoDigits(hours) + ':' + CoreTextUtils.twoDigits(minutes) + ':' + CoreTextUtils.twoDigits(seconds) | ||||
|             : CoreTextUtils.twoDigits(minutes) + ':' + CoreTextUtils.twoDigits(seconds); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
							
								
								
									
										89
									
								
								src/core/services/modals.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										89
									
								
								src/core/services/modals.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,89 @@ | ||||
| // (C) Copyright 2015 Moodle Pty Ltd.
 | ||||
| //
 | ||||
| // Licensed under the Apache License, Version 2.0 (the "License");
 | ||||
| // you may not use this file except in compliance with the License.
 | ||||
| // You may obtain a copy of the License at
 | ||||
| //
 | ||||
| //     http://www.apache.org/licenses/LICENSE-2.0
 | ||||
| //
 | ||||
| // Unless required by applicable law or agreed to in writing, software
 | ||||
| // distributed under the License is distributed on an "AS IS" BASIS,
 | ||||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| import { Constructor } from '@/core/utils/types'; | ||||
| import { Injectable } from '@angular/core'; | ||||
| import { CoreModalComponent } from '@classes/modal-component'; | ||||
| import { CoreSheetModalComponent } from '@components/sheet-modal/sheet-modal'; | ||||
| import { AngularFrameworkDelegate, makeSingleton } from '@singletons'; | ||||
| import { CoreDirectivesRegistry } from '@singletons/directives-registry'; | ||||
| 
 | ||||
| /** | ||||
|  * Handles application modals. | ||||
|  */ | ||||
| @Injectable({ providedIn: 'root' }) | ||||
| export class CoreModalsService { | ||||
| 
 | ||||
|     /** | ||||
|      * Get index of the overlay on top of the stack. | ||||
|      * | ||||
|      * @returns Z-index of the overlay on top. | ||||
|      */ | ||||
|     getTopOverlayIndex(): number { | ||||
|         // This has to be done manually because Ionic's overlay mechanisms are not exposed externally, thus making it more difficult
 | ||||
|         // to implement custom overlays.
 | ||||
|         //
 | ||||
|         // eslint-disable-next-line max-len
 | ||||
|         // See https://github.com/ionic-team/ionic-framework/blob/a9b12a5aa4c150a1f8a80a826dda0df350bc0092/core/src/utils/overlays.ts#L39
 | ||||
| 
 | ||||
|         const overlays = document.querySelectorAll<HTMLElement>( | ||||
|             'ion-action-sheet, ion-alert, ion-loading, ion-modal, ion-picker, ion-popover, ion-toast', | ||||
|         ); | ||||
| 
 | ||||
|         return Array.from(overlays).reduce((maxIndex, element) => { | ||||
|             const index = parseInt(element.style.zIndex); | ||||
| 
 | ||||
|             if (isNaN(index)) { | ||||
|                 return maxIndex; | ||||
|             } | ||||
| 
 | ||||
|             return Math.max(maxIndex, index % 10000); | ||||
|         }, 0); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Open a sheet modal component. | ||||
|      * | ||||
|      * @param component Component to render inside the modal. | ||||
|      * @returns Modal result once it's been closed. | ||||
|      */ | ||||
|     async openSheet<T>(component: Constructor<CoreModalComponent<T>>): Promise<T> { | ||||
|         const container = document.querySelector('ion-app') ?? document.body; | ||||
|         const viewContainer = container.querySelector('ion-router-outlet, ion-nav, #ion-view-container-root'); | ||||
|         const element = await AngularFrameworkDelegate.attachViewToDom( | ||||
|             container, | ||||
|             CoreSheetModalComponent, | ||||
|             { component }, | ||||
|         ); | ||||
|         const sheetModal = CoreDirectivesRegistry.require<CoreSheetModalComponent<CoreModalComponent<T>>>( | ||||
|             element, | ||||
|             CoreSheetModalComponent, | ||||
|         ); | ||||
|         const modal = await sheetModal.show(); | ||||
| 
 | ||||
|         viewContainer?.setAttribute('aria-hidden', 'true'); | ||||
| 
 | ||||
|         modal.result.finally(async () => { | ||||
|             await sheetModal.hide(); | ||||
| 
 | ||||
|             element.remove(); | ||||
|             viewContainer?.removeAttribute('aria-hidden'); | ||||
|         }); | ||||
| 
 | ||||
|         return modal.result; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export const CoreModals = makeSingleton(CoreModalsService); | ||||
| @ -80,6 +80,33 @@ export class CorePlatformService extends Platform { | ||||
|         return this.is('cordova'); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Check whether the device is configured to reduce motion. | ||||
|      * | ||||
|      * @returns Whether the device is configured to reduce motion. | ||||
|      */ | ||||
|     prefersReducedMotion(): boolean { | ||||
|         return window.matchMedia('(prefers-reduced-motion: reduce)').matches; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Checks whether media capture is supported. | ||||
|      * | ||||
|      * @returns Whether media capture is supported. | ||||
|      */ | ||||
|     supportsMediaCapture(): boolean { | ||||
|         return 'mediaDevices' in navigator; | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Checks whether web assembly is supported. | ||||
|      * | ||||
|      * @returns Whether web assembly is supported. | ||||
|      */ | ||||
|     supportsWebAssembly(): boolean { | ||||
|         return 'WebAssembly' in window; | ||||
|     } | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| export const CorePlatform = makeSingleton(CorePlatformService); | ||||
|  | ||||
| @ -14,8 +14,7 @@ | ||||
| 
 | ||||
| import { Component } from '@angular/core'; | ||||
| import { AsyncDirective } from '@classes/async-directive'; | ||||
| import { CoreUtils } from '@services/utils/utils'; | ||||
| import { CoreLogger } from './logger'; | ||||
| import { CoreDirectivesRegistry } from '@singletons/directives-registry'; | ||||
| 
 | ||||
| /** | ||||
|  * Registry to keep track of component instances. | ||||
| @ -24,9 +23,6 @@ import { CoreLogger } from './logger'; | ||||
|  */ | ||||
| export class CoreComponentsRegistry { | ||||
| 
 | ||||
|     private static instances: WeakMap<Element, unknown> = new WeakMap(); | ||||
|     protected static logger = CoreLogger.getInstance('CoreComponentsRegistry'); | ||||
| 
 | ||||
|     /** | ||||
|      * Register a component instance. | ||||
|      * | ||||
| @ -34,7 +30,7 @@ export class CoreComponentsRegistry { | ||||
|      * @param instance Component instance. | ||||
|      */ | ||||
|     static register(element: Element, instance: unknown): void { | ||||
|         this.instances.set(element, instance); | ||||
|         CoreDirectivesRegistry.register(element, instance); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -45,11 +41,7 @@ export class CoreComponentsRegistry { | ||||
|      * @returns Component instance. | ||||
|      */ | ||||
|     static resolve<T>(element?: Element | null, componentClass?: ComponentConstructor<T>): T | null { | ||||
|         const instance = (element && this.instances.get(element) as T) ?? null; | ||||
| 
 | ||||
|         return instance && (!componentClass || instance instanceof componentClass) | ||||
|             ? instance | ||||
|             : null; | ||||
|         return CoreDirectivesRegistry.resolve(element, componentClass); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -60,13 +52,7 @@ export class CoreComponentsRegistry { | ||||
|      * @returns Component instance. | ||||
|      */ | ||||
|     static require<T>(element: Element, componentClass?: ComponentConstructor<T>): T { | ||||
|         const instance = this.resolve(element, componentClass); | ||||
| 
 | ||||
|         if (!instance) { | ||||
|             throw new Error('Couldn\'t resolve component instance'); | ||||
|         } | ||||
| 
 | ||||
|         return instance; | ||||
|         return CoreDirectivesRegistry.require(element, componentClass); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -80,14 +66,7 @@ export class CoreComponentsRegistry { | ||||
|         element: Element | null, | ||||
|         componentClass?: ComponentConstructor<T>, | ||||
|     ): Promise<void> { | ||||
|         const instance = this.resolve(element, componentClass); | ||||
|         if (!instance) { | ||||
|             this.logger.error('No instance registered for element ' + componentClass, element); | ||||
| 
 | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         await instance.ready(); | ||||
|         return CoreDirectivesRegistry.waitDirectiveReady(element, componentClass); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
| @ -103,23 +82,7 @@ export class CoreComponentsRegistry { | ||||
|         selector: string, | ||||
|         componentClass?: ComponentConstructor<T>, | ||||
|     ): Promise<void> { | ||||
|         let elements: Element[] = []; | ||||
| 
 | ||||
|         if (element.matches(selector)) { | ||||
|             // Element to wait is myself.
 | ||||
|             elements = [element]; | ||||
|         } else { | ||||
|             elements = Array.from(element.querySelectorAll(selector)); | ||||
|         } | ||||
| 
 | ||||
|         if (!elements.length) { | ||||
|             return; | ||||
|         } | ||||
| 
 | ||||
|         await Promise.all(elements.map(element => CoreComponentsRegistry.waitComponentReady<T>(element, componentClass))); | ||||
| 
 | ||||
|         // Wait for next tick to ensure components are completely rendered.
 | ||||
|         await CoreUtils.nextTick(); | ||||
|         return CoreDirectivesRegistry.waitDirectivesReady(element, selector, componentClass); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -52,7 +52,6 @@ import { InAppBrowser as InAppBrowserService } from '@ionic-native/in-app-browse | ||||
| import { WebView as WebViewService } from '@ionic-native/ionic-webview/ngx'; | ||||
| import { Keyboard as KeyboardService } from '@ionic-native/keyboard/ngx'; | ||||
| import { LocalNotifications as LocalNotificationsService } from '@ionic-native/local-notifications/ngx'; | ||||
| import { Media as MediaService } from '@ionic-native/media/ngx'; | ||||
| import { MediaCapture as MediaCaptureService } from '@ionic-native/media-capture/ngx'; | ||||
| import { Push as PushService } from '@ionic-native/push/ngx'; | ||||
| import { QRScanner as QRScannerService } from '@ionic-native/qr-scanner/ngx'; | ||||
| @ -184,7 +183,6 @@ export const Geolocation = makeSingleton(GeolocationService); | ||||
| export const InAppBrowser = makeSingleton(InAppBrowserService); | ||||
| export const Keyboard = makeSingleton(KeyboardService); | ||||
| export const LocalNotifications = makeSingleton(LocalNotificationsService); | ||||
| export const Media = makeSingleton(MediaService); | ||||
| export const MediaCapture = makeSingleton(MediaCaptureService); | ||||
| export const NativeHttp = makeSingleton(HTTP); | ||||
| export const Push = makeSingleton(PushService); | ||||
|  | ||||
							
								
								
									
										6
									
								
								src/core/utils/types.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										6
									
								
								src/core/utils/types.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -12,6 +12,12 @@ | ||||
| // See the License for the specific language governing permissions and
 | ||||
| // limitations under the License.
 | ||||
| 
 | ||||
| /** | ||||
|  * Helper type to infer class instance types. | ||||
|  */ | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any
 | ||||
| export type Constructor<T> = { new(...args: any[]): T }; | ||||
| 
 | ||||
| /** | ||||
|  * Helper type to flatten complex types. | ||||
|  */ | ||||
|  | ||||
							
								
								
									
										14
									
								
								tsconfig.worker.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								tsconfig.worker.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| { | ||||
|   "extends": "./tsconfig.json", | ||||
|   "compilerOptions": { | ||||
|     "outDir": "./out-tsc/worker", | ||||
|     "lib": [ | ||||
|       "es2018", | ||||
|       "webworker" | ||||
|     ], | ||||
|     "types": [] | ||||
|   }, | ||||
|   "include": [ | ||||
|     "src/**/*.worker.ts" | ||||
|   ] | ||||
| } | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user