diff --git a/Dockerfile b/Dockerfile index 82f350279..1ff23c922 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/angular.json b/angular.json index 376a770fd..ec4ff0695 100644 --- a/angular.json +++ b/angular.json @@ -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": { diff --git a/config.xml b/config.xml index 800051a32..fe4e37ebe 100644 --- a/config.xml +++ b/config.xml @@ -196,11 +196,6 @@ - - - - - diff --git a/package-lock.json b/package-lock.json index 2c56f638d..71817b7fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 81bf000bb..815c1605b 100644 --- a/package.json +++ b/package.json @@ -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": {}, diff --git a/patches/event-target-shim+6.0.2.patch b/patches/event-target-shim+6.0.2.patch new file mode 100644 index 000000000..8d8947270 --- /dev/null +++ b/patches/event-target-shim+6.0.2.patch @@ -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, TMode extends "standard" | "strict"> = EventTarget & defineEventAttribute.EventAttributes; ++ type CustomEventTarget, TMode extends "standard" | "strict"> = EventTarget & defineEventAttribute.EventAttributes; + } + /** + * 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(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes>>; ++export declare function defineEventAttribute(target: TEventTarget, type: TEventType, _eventClass?: TEventConstrucor): asserts target is TEventTarget & defineEventAttribute.EventAttributes; + export declare namespace defineEventAttribute { + /** + * Definition of event attributes. + */ +- type EventAttributes, TEventMap extends Record> = { +- [P in string & keyof TEventMap as `on${P}`]: EventTarget.CallbackFunction | null; +- }; ++ type EventAttributes> = Record | null>; + } + /** + * Set the warning handler. diff --git a/patches/mp3-mediarecorder+4.0.5.patch b/patches/mp3-mediarecorder+4.0.5.patch new file mode 100644 index 000000000..230e1e04f --- /dev/null +++ b/patches/mp3-mediarecorder+4.0.5.patch @@ -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 +- }); ++ })); + } + + } //------------------------------------------------------------------------------ diff --git a/scripts/copy-assets.js b/scripts/copy-assets.js index 03fec6038..72274160b 100644 --- a/scripts/copy-assets.js +++ b/scripts/copy-assets.js @@ -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', diff --git a/scripts/langindex.json b/scripts/langindex.json index ca500a630..95e7f3887 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -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", diff --git a/src/addons/mod/data/fields/checkbox/services/handler.ts b/src/addons/mod/data/fields/checkbox/services/handler.ts index 2bc249e5e..569fb55f5 100644 --- a/src/addons/mod/data/fields/checkbox/services/handler.ts +++ b/src/addons/mod/data/fields/checkbox/services/handler.ts @@ -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, diff --git a/src/addons/mod/data/fields/file/component/file.ts b/src/addons/mod/data/fields/file/component/file.ts index 11abea5bb..210596929 100644 --- a/src/addons/mod/data/fields/file/component/file.ts +++ b/src/addons/mod/data/fields/file/component/file.ts @@ -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'; diff --git a/src/addons/mod/data/fields/picture/component/picture.ts b/src/addons/mod/data/fields/picture/component/picture.ts index 6b99f0c10..adf032eb1 100644 --- a/src/addons/mod/data/fields/picture/component/picture.ts +++ b/src/addons/mod/data/fields/picture/component/picture.ts @@ -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'; diff --git a/src/addons/mod/data/fields/radiobutton/component/radiobutton.ts b/src/addons/mod/data/fields/radiobutton/component/radiobutton.ts index 5653e8830..cfc0064fb 100644 --- a/src/addons/mod/data/fields/radiobutton/component/radiobutton.ts +++ b/src/addons/mod/data/fields/radiobutton/component/radiobutton.ts @@ -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'; diff --git a/src/addons/mod/data/fields/url/component/url.ts b/src/addons/mod/data/fields/url/component/url.ts index ca90c661f..8784b0748 100644 --- a/src/addons/mod/data/fields/url/component/url.ts +++ b/src/addons/mod/data/fields/url/component/url.ts @@ -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'; diff --git a/src/addons/qbehaviour/qbehaviour.module.ts b/src/addons/qbehaviour/qbehaviour.module.ts index 93f38bd98..ab148e0e0 100644 --- a/src/addons/qbehaviour/qbehaviour.module.ts +++ b/src/addons/qbehaviour/qbehaviour.module.ts @@ -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'; diff --git a/src/addons/userprofilefield/userprofilefield.module.ts b/src/addons/userprofilefield/userprofilefield.module.ts index 2b6096c2a..3ddecabfc 100644 --- a/src/addons/userprofilefield/userprofilefield.module.ts +++ b/src/addons/userprofilefield/userprofilefield.module.ts @@ -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'; diff --git a/src/core/classes/errors/captureerror.ts b/src/core/classes/errors/captureerror.ts index 302ffb5a8..084240290 100644 --- a/src/core/classes/errors/captureerror.ts +++ b/src/core/classes/errors/captureerror.ts @@ -14,6 +14,8 @@ import { CoreError } from './error'; +export const CAPTURE_ERROR_NO_MEDIA_FILES = 3; + /** * Capture error. */ diff --git a/src/core/classes/modal-component.ts b/src/core/classes/modal-component.ts new file mode 100644 index 000000000..207c8a9bb --- /dev/null +++ b/src/core/classes/modal-component.ts @@ -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 { + + result: CorePromisedValue = new CorePromisedValue(); + + constructor({ nativeElement: element }: ElementRef) { + 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 { + if (result instanceof Error) { + this.result.reject(result); + + return; + } + + this.result.resolve(result); + } + +} diff --git a/src/core/components/chrono/chrono.ts b/src/core/components/chrono/chrono.ts index 8f30406a1..0604cc65e 100644 --- a/src/core/components/chrono/chrono.ts +++ b/src/core/components/chrono/chrono.ts @@ -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; // Will emit an event when the endTime is reached. time = 0; diff --git a/src/core/components/chrono/core-chrono.html b/src/core/components/chrono/core-chrono.html index 5f0f28ade..fccdbca93 100644 --- a/src/core/components/chrono/core-chrono.html +++ b/src/core/components/chrono/core-chrono.html @@ -1 +1 @@ -{{ time / 1000 | coreSecondsToHMS }} \ No newline at end of file +{{ time / 1000 | coreSecondsToHMS:hours }} diff --git a/src/core/components/components.module.ts b/src/core/components/components.module.ts index 846aee4e6..f0cfb2cef 100644 --- a/src/core/components/components.module.ts +++ b/src/core/components/components.module.ts @@ -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 {} diff --git a/src/core/components/sheet-modal/sheet-modal.html b/src/core/components/sheet-modal/sheet-modal.html new file mode 100644 index 000000000..e5a79c2c8 --- /dev/null +++ b/src/core/components/sheet-modal/sheet-modal.html @@ -0,0 +1,2 @@ + +
diff --git a/src/core/components/sheet-modal/sheet-modal.scss b/src/core/components/sheet-modal/sheet-modal.scss new file mode 100644 index 000000000..b5f43ef96 --- /dev/null +++ b/src/core/components/sheet-modal/sheet-modal.scss @@ -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%); + } + + } + +} diff --git a/src/core/components/sheet-modal/sheet-modal.ts b/src/core/components/sheet-modal/sheet-modal.ts new file mode 100644 index 000000000..4216224b4 --- /dev/null +++ b/src/core/components/sheet-modal/sheet-modal.ts @@ -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 implements AfterViewInit { + + @Input() component!: Constructor; + @Input() componentProps?: Record; + @ViewChild('wrapper') wrapper?: ElementRef; + + private element: HTMLElement; + private wrapperElement = new CorePromisedValue(); + + constructor({ nativeElement: element }: ElementRef) { + 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 { + 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 { + this.element.classList.remove('active'); + + await CoreUtils.nextTick(); + await CoreUtils.wait(300); + } + +} diff --git a/src/core/features/emulator/components/capture-media/capture-media.html b/src/core/features/emulator/components/capture-media/capture-media.html index f85e62a51..a33ca161c 100644 --- a/src/core/features/emulator/components/capture-media/capture-media.html +++ b/src/core/features/emulator/components/capture-media/capture-media.html @@ -14,7 +14,7 @@
- +
@@ -48,8 +33,7 @@ - - + diff --git a/src/core/features/emulator/components/capture-media/capture-media.scss b/src/core/features/emulator/components/capture-media/capture-media.scss index 57d6b0351..f128e9629 100644 --- a/src/core/features/emulator/components/capture-media/capture-media.scss +++ b/src/core/features/emulator/components/capture-media/capture-media.scss @@ -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 { diff --git a/src/core/features/emulator/components/capture-media/capture-media.ts b/src/core/features/emulator/components/capture-media/capture-media.ts index 520d28099..34f9390e9 100644 --- a/src/core/features/emulator/components/capture-media/capture-media.ts +++ b/src/core/features/emulator/components/capture-media/capture-media.ts @@ -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 { - - 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 { - 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 { - 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 { 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 || ( 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 { 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; diff --git a/src/core/features/emulator/emulator.module.prod.ts b/src/core/features/emulator/emulator.module.prod.ts new file mode 100644 index 000000000..82336ab34 --- /dev/null +++ b/src/core/features/emulator/emulator.module.prod.ts @@ -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 {} diff --git a/src/core/features/emulator/services/capture-helper.ts b/src/core/features/emulator/services/capture-helper.ts index 17432f912..54885b622 100644 --- a/src/core/features/emulator/services/capture-helper.ts +++ b/src/core/features/emulator/services/capture-helper.ts @@ -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; captureMedia(type: 'captureimage', options?: MockCaptureImageOptions): Promise; - captureMedia(type: 'audio', options?: MockCaptureAudioOptions): Promise; captureMedia(type: 'video', options?: MockCaptureVideoOptions): Promise; async captureMedia( - type: 'image' | 'captureimage' | 'audio' | 'video', - options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureAudioOptions | MockCaptureVideoOptions, + type: 'image' | 'captureimage' | 'video', + options?: MockCameraOptions | MockCaptureImageOptions | MockCaptureVideoOptions, ): Promise { 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. } diff --git a/src/core/features/emulator/services/media-capture.ts b/src/core/features/emulator/services/media-capture.ts index c9f3e02a0..8c8985fbd 100644 --- a/src/core/features/emulator/services/media-capture.ts +++ b/src/core/features/emulator/services/media-capture.ts @@ -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 { - return CoreEmulatorCaptureHelper.captureMedia('audio', options); - } - /** * Start the camera application and return information about captured image files. * diff --git a/src/core/features/fileuploader/components/audio-histogram/audio-histogram.html b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.html new file mode 100644 index 000000000..c2e2ad0b6 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.html @@ -0,0 +1 @@ + diff --git a/src/core/features/fileuploader/components/audio-histogram/audio-histogram.scss b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.scss new file mode 100644 index 000000000..bbef26a98 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.scss @@ -0,0 +1,10 @@ +:host { + --background-color: var(--ion-background-color, #fff); + --bars-color: var(--ion-text-color, #000); + + canvas { + width: 100%; + height: 100%; + } + +} diff --git a/src/core/features/fileuploader/components/audio-histogram/audio-histogram.ts b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.ts new file mode 100644 index 000000000..0af7d4e76 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-histogram/audio-histogram.ts @@ -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; + + private element: HTMLElement; + private canvas?: HTMLCanvasElement; + private context?: CanvasRenderingContext2D | null; + private buffer?: Uint8Array; + private destroyed = false; + + constructor({ nativeElement }: ElementRef) { + 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; + } + } + +} diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.component.ts b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.component.ts new file mode 100644 index 000000000..184558e6b --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.component.ts @@ -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 + implements OnDestroy { + + recordingUrl$: Observable; + histogramAnalyzer$: Observable; + status$: Observable<'recording-ongoing' | 'recording-paused' | 'done' | 'empty'>; + + protected recording: AudioRecording | null; + protected media$: BehaviorSubject; + protected recording$: Observable; + + constructor(elementRef: ElementRef) { + 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 { + 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 { + this.close(new CoreCaptureError(CAPTURE_ERROR_NO_MEDIA_FILES)); + } + + /** + * Dismiss the modal with the current recording as a result. + */ + async submit(): Promise { + 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 { + 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 { + 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 { + 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 { + 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); + }; + }); +} diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.html b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.html new file mode 100644 index 000000000..60858f79f --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.html @@ -0,0 +1,74 @@ +
+

{{ 'core.fileuploader.audiotitle' | translate }}

+ + + + +
+ +
+ + +
+

{{ 'core.fileuploader.startrecordinginstructions' | translate }}

+ + + + +
+ +
+ + + +
+
+
+ + +
+ +
+ + + + + + + +
+ +
+ + + +
+
+
+ +
+ + +
+
+ + + +
+ +
+ + {{ 'core.save' | translate }} + +
+
+
+
diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.module.ts b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.module.ts new file mode 100644 index 000000000..3b50dd3b7 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.module.ts @@ -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 {} diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.scss b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.scss new file mode 100644 index 000000000..6bd83cef0 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.scss @@ -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); + } + + } + + } + + } + +} diff --git a/src/core/features/fileuploader/components/audio-recorder/audio-recorder.worker.ts b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.worker.ts new file mode 100644 index 000000000..9b46372b2 --- /dev/null +++ b/src/core/features/fileuploader/components/audio-recorder/audio-recorder.worker.ts @@ -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); diff --git a/src/core/features/fileuploader/lang.json b/src/core/features/fileuploader/lang.json index 22d14df4a..588b497ee 100644 --- a/src/core/features/fileuploader/lang.json +++ b/src/core/features/fileuploader/lang.json @@ -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" -} \ No newline at end of file +} diff --git a/src/core/features/fileuploader/services/fileuploader-helper.ts b/src/core/features/fileuploader/services/fileuploader-helper.ts index 47b649a28..cba30e2a7 100644 --- a/src/core/features/fileuploader/services/fileuploader-helper.ts +++ b/src/core/features/fileuploader/services/fileuploader-helper.ts @@ -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 { 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 { - const errorStr = Translate.instant('core.error'); const retryStr = Translate.instant('core.retry'); const uploadingStr = Translate.instant('core.fileuploader.uploading'); diff --git a/src/core/features/fileuploader/services/fileuploader.ts b/src/core/features/fileuploader/services/fileuploader.ts index f40ee7490..9926844da 100644 --- a/src/core/features/fileuploader/services/fileuploader.ts +++ b/src/core/features/fileuploader/services/fileuploader.ts @@ -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 { + async captureAudio(): Promise { 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 { - const params = { - type: 'audio', - }; + async captureAudioInApp(): Promise { + 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; +}; diff --git a/src/core/features/fileuploader/utils/worker-messages.ts b/src/core/features/fileuploader/utils/worker-messages.ts new file mode 100644 index 000000000..f31572a8c --- /dev/null +++ b/src/core/features/fileuploader/utils/worker-messages.ts @@ -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, + }; +} diff --git a/src/core/features/login/components/login-methods/login-methods.ts b/src/core/features/login/components/login-methods/login-methods.ts index c4180647c..b0c78e299 100644 --- a/src/core/features/login/components/login-methods/login-methods.ts +++ b/src/core/features/login/components/login-methods/login-methods.ts @@ -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'; diff --git a/src/core/features/native/native.module.ts b/src/core/features/native/native.module.ts index 3a8887ca0..c0a82490f 100644 --- a/src/core/features/native/native.module.ts +++ b/src/core/features/native/native.module.ts @@ -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, diff --git a/src/core/features/reportbuilder/services/reportbuilder.ts b/src/core/features/reportbuilder/services/reportbuilder.ts index 601a89427..e5cfd47b5 100644 --- a/src/core/features/reportbuilder/services/reportbuilder.ts +++ b/src/core/features/reportbuilder/services/reportbuilder.ts @@ -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'; diff --git a/src/core/pipes/seconds-to-hms.ts b/src/core/pipes/seconds-to-hms.ts index 91115ea62..4a14d02b1 100644 --- a/src/core/pipes/seconds-to-hms.ts +++ b/src/core/pipes/seconds-to-hms.ts @@ -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); } } diff --git a/src/core/services/modals.ts b/src/core/services/modals.ts new file mode 100644 index 000000000..7201505e5 --- /dev/null +++ b/src/core/services/modals.ts @@ -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( + '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(component: Constructor>): Promise { + 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>>( + 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); diff --git a/src/core/services/platform.ts b/src/core/services/platform.ts index c00787d1b..87e40fdfb 100644 --- a/src/core/services/platform.ts +++ b/src/core/services/platform.ts @@ -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); diff --git a/src/core/singletons/components-registry.ts b/src/core/singletons/components-registry.ts index cd123802f..092c66ee6 100644 --- a/src/core/singletons/components-registry.ts +++ b/src/core/singletons/components-registry.ts @@ -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 = 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(element?: Element | null, componentClass?: ComponentConstructor): 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(element: Element, componentClass?: ComponentConstructor): 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, ): Promise { - 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, ): Promise { - 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(element, componentClass))); - - // Wait for next tick to ensure components are completely rendered. - await CoreUtils.nextTick(); + return CoreDirectivesRegistry.waitDirectivesReady(element, selector, componentClass); } } diff --git a/src/core/singletons/index.ts b/src/core/singletons/index.ts index acc10439c..60b71d369 100644 --- a/src/core/singletons/index.ts +++ b/src/core/singletons/index.ts @@ -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); diff --git a/src/core/utils/types.d.ts b/src/core/utils/types.d.ts index 469d87f0a..0cfbe30e7 100644 --- a/src/core/utils/types.d.ts +++ b/src/core/utils/types.d.ts @@ -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 = { new(...args: any[]): T }; + /** * Helper type to flatten complex types. */ diff --git a/tsconfig.worker.json b/tsconfig.worker.json new file mode 100644 index 000000000..1c8cc55ed --- /dev/null +++ b/tsconfig.worker.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/worker", + "lib": [ + "es2018", + "webworker" + ], + "types": [] + }, + "include": [ + "src/**/*.worker.ts" + ] +}