From b26d7dd06f6038780332fd1e138b515cf30f5d9e Mon Sep 17 00:00:00 2001 From: Zita Szupera Date: Fri, 20 Sep 2024 17:06:55 +0200 Subject: [PATCH] feat: voice recording --- package-lock.json | 11 + package.json | 1 + .../sample-app/src/app/app.component.html | 16 +- projects/sample-app/src/app/app.component.ts | 6 +- projects/sample-app/src/app/app.module.ts | 2 + projects/stream-chat-angular/ng-package.json | 1 + projects/stream-chat-angular/package.json | 1 + .../attachment-preview-list.component.html | 13 +- .../src/lib/attachment.service.ts | 65 ++++- .../channel-preview.component.spec.ts | 6 +- .../stream-chat-angular/src/lib/file-utils.ts | 49 ++++ .../src/lib/format-duration.ts | 18 ++ .../icon-placeholder.component.html | 0 .../icon-placeholder.component.spec.ts | 2 +- .../icon-placeholder.component.ts | 6 +- .../src/lib/icon/icon.component.ts | 3 +- .../src/lib/icon/icon.module.ts | 23 ++ ...ading-indicator-placeholder.component.html | 0 ...loading-indicator-placeholder.component.ts | 2 +- .../loading-indicator.component.html | 0 .../loading-indicator.component.ts | 0 .../src/lib/is-image-file.ts | 4 - .../stream-chat-angular/src/lib/is-safari.ts | 3 + .../message-input-config.service.ts | 6 +- .../message-input.component.html | 16 +- .../message-input.component.spec.ts | 6 +- .../message-input/message-input.component.ts | 36 ++- .../voice-recorder.service.spec.ts | 16 ++ .../message-input/voice-recorder.service.ts | 16 ++ .../message-list/message-list.component.ts | 6 +- .../src/lib/message/message.component.spec.ts | 2 +- .../src/lib/stream-chat.module.ts | 26 +- projects/stream-chat-angular/src/lib/types.ts | 12 +- .../amplitude-recorder.service.ts | 167 ++++++++++++ .../voice-recorder/audio-recorder.service.ts | 96 +++++++ .../src/lib/voice-recorder/media-recorder.ts | 257 ++++++++++++++++++ .../lib/voice-recorder/transcoder.service.ts | 198 ++++++++++++++ .../voice-recorder-wavebar.component.html | 17 ++ .../voice-recorder-wavebar.component.spec.ts | 22 ++ .../voice-recorder-wavebar.component.ts | 44 +++ .../voice-recorder.component.html | 74 +++++ .../voice-recorder.component.spec.ts | 23 ++ .../voice-recorder.component.ts | 89 ++++++ .../voice-recorder/voice-recorder.module.ts | 22 ++ .../voice-recording-wavebar.component.ts | 145 +--------- .../voice-recording.component.ts | 18 +- .../voice-recording/voice-recording.module.ts | 13 + .../src/lib/wave-form-sampler.ts | 124 +++++++++ .../stream-chat-angular/src/public-api.ts | 20 +- 49 files changed, 1486 insertions(+), 217 deletions(-) create mode 100644 projects/stream-chat-angular/src/lib/file-utils.ts create mode 100644 projects/stream-chat-angular/src/lib/format-duration.ts rename projects/stream-chat-angular/src/lib/{ => icon}/icon-placeholder/icon-placeholder.component.html (100%) rename projects/stream-chat-angular/src/lib/{ => icon}/icon-placeholder/icon-placeholder.component.spec.ts (96%) rename projects/stream-chat-angular/src/lib/{ => icon}/icon-placeholder/icon-placeholder.component.ts (85%) create mode 100644 projects/stream-chat-angular/src/lib/icon/icon.module.ts rename projects/stream-chat-angular/src/lib/{ => icon}/loading-indicator-placeholder/loading-indicator-placeholder.component.html (100%) rename projects/stream-chat-angular/src/lib/{ => icon}/loading-indicator-placeholder/loading-indicator-placeholder.component.ts (89%) rename projects/stream-chat-angular/src/lib/{ => icon}/loading-indicator/loading-indicator.component.html (100%) rename projects/stream-chat-angular/src/lib/{ => icon}/loading-indicator/loading-indicator.component.ts (100%) delete mode 100644 projects/stream-chat-angular/src/lib/is-image-file.ts create mode 100644 projects/stream-chat-angular/src/lib/is-safari.ts create mode 100644 projects/stream-chat-angular/src/lib/message-input/voice-recorder.service.spec.ts create mode 100644 projects/stream-chat-angular/src/lib/message-input/voice-recorder.service.ts create mode 100644 projects/stream-chat-angular/src/lib/voice-recorder/amplitude-recorder.service.ts create mode 100644 projects/stream-chat-angular/src/lib/voice-recorder/audio-recorder.service.ts create mode 100644 projects/stream-chat-angular/src/lib/voice-recorder/media-recorder.ts create mode 100644 projects/stream-chat-angular/src/lib/voice-recorder/transcoder.service.ts create mode 100644 projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.html create mode 100644 projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.spec.ts create mode 100644 projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.ts create mode 100644 projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.html create mode 100644 projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.spec.ts create mode 100644 projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.ts create mode 100644 projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.module.ts create mode 100644 projects/stream-chat-angular/src/lib/voice-recording/voice-recording.module.ts create mode 100644 projects/stream-chat-angular/src/lib/wave-form-sampler.ts diff --git a/package-lock.json b/package-lock.json index 7045f57f..443f2454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "dayjs": "^1.11.10", "dotenv": "^16.4.5", "emoji-regex": "^10.3.0", + "fix-webm-duration": "^1.0.6", "ngx-float-ui": "^15.0.0", "pretty-bytes": "^6.1.1", "rxjs": "~7.4.0", @@ -11237,6 +11238,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/fix-webm-duration": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fix-webm-duration/-/fix-webm-duration-1.0.6.tgz", + "integrity": "sha512-zVAqi4gE+8ywxJuAyV/rlJVX6CMtvyapEbQx6jyoeX9TMjdqAlt/FdG5d7rXSSkDVzTvS0H7CtwzHcH/vh4FPA==" + }, "node_modules/flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", @@ -31691,6 +31697,11 @@ "semver-regex": "^3.1.2" } }, + "fix-webm-duration": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/fix-webm-duration/-/fix-webm-duration-1.0.6.tgz", + "integrity": "sha512-zVAqi4gE+8ywxJuAyV/rlJVX6CMtvyapEbQx6jyoeX9TMjdqAlt/FdG5d7rXSSkDVzTvS0H7CtwzHcH/vh4FPA==" + }, "flat-cache": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", diff --git a/package.json b/package.json index 7710684f..3a3d7861 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "dayjs": "^1.11.10", "dotenv": "^16.4.5", "emoji-regex": "^10.3.0", + "fix-webm-duration": "^1.0.6", "ngx-float-ui": "^15.0.0", "pretty-bytes": "^6.1.1", "rxjs": "~7.4.0", diff --git a/projects/sample-app/src/app/app.component.html b/projects/sample-app/src/app/app.component.html index 3e753b7e..cea3346e 100644 --- a/projects/sample-app/src/app/app.component.html +++ b/projects/sample-app/src/app/app.component.html @@ -29,10 +29,22 @@ - + + + + + - + + + + + diff --git a/projects/sample-app/src/app/app.component.ts b/projects/sample-app/src/app/app.component.ts index b122ae27..86b18dea 100644 --- a/projects/sample-app/src/app/app.component.ts +++ b/projects/sample-app/src/app/app.component.ts @@ -4,7 +4,7 @@ import { TemplateRef, ViewChild, } from '@angular/core'; -import { Observable } from 'rxjs'; +import { Observable, Subject } from 'rxjs'; import { map } from 'rxjs/operators'; import { ChatClientService, @@ -14,6 +14,7 @@ import { CustomTemplatesService, ThemeService, AvatarContext, + MessageService, } from 'stream-chat-angular'; import { environment } from '../environments/environment'; import names from 'starwars-names'; @@ -32,16 +33,19 @@ export class AppComponent implements AfterViewInit { @ViewChild('avatar') avatarTemplate!: TemplateRef; theme$: Observable; counter = 0; + sendMessageOutsideTrigger$ = new Subject(); constructor( private chatService: ChatClientService, private channelService: ChannelService, private streamI18nService: StreamI18nService, private customTemplateService: CustomTemplatesService, + private messageService: MessageService, themeService: ThemeService ) { const isDynamicUser = environment.userId === ''; const userId = isDynamicUser ? uuidv4() : environment.userId; + // this.messageService.displayAs = 'html'; void this.chatService.init( environment.apiKey, isDynamicUser ? { id: userId, name: names.random() } : userId, diff --git a/projects/sample-app/src/app/app.module.ts b/projects/sample-app/src/app/app.module.ts index bee108b1..20d80588 100644 --- a/projects/sample-app/src/app/app.module.ts +++ b/projects/sample-app/src/app/app.module.ts @@ -7,6 +7,7 @@ import { CustomMessageComponent } from './custom-message/custom-message.componen import { StreamAutocompleteTextareaModule, StreamChatModule, + VoiceRecorderModule, } from 'stream-chat-angular'; import { EmojiPickerComponent } from './emoji-picker/emoji-picker.component'; import { PickerModule } from '@ctrl/ngx-emoji-mart'; @@ -18,6 +19,7 @@ import { PickerModule } from '@ctrl/ngx-emoji-mart'; TranslateModule.forRoot(), StreamChatModule, PickerModule, + VoiceRecorderModule, StreamAutocompleteTextareaModule, ], bootstrap: [AppComponent], diff --git a/projects/stream-chat-angular/ng-package.json b/projects/stream-chat-angular/ng-package.json index 9236618a..f0393f9a 100644 --- a/projects/stream-chat-angular/ng-package.json +++ b/projects/stream-chat-angular/ng-package.json @@ -7,6 +7,7 @@ }, "allowedNonPeerDependencies": [ "dayjs", + "fix-webm-duration", "@stream-io/transliterate", "uuid", "pretty-bytes", diff --git a/projects/stream-chat-angular/package.json b/projects/stream-chat-angular/package.json index 79516b5a..f80aca0a 100644 --- a/projects/stream-chat-angular/package.json +++ b/projects/stream-chat-angular/package.json @@ -29,6 +29,7 @@ "angular-mentions": "^1.4.0", "dayjs": "^1.11.10", "emoji-regex": "^10.3.0", + "fix-webm-duration": "^1.0.6", "ngx-float-ui": "^15.0.0|| ^16.0.0 || ^17.0.0 || ^18.0.0 || ^18.0.1-rc.0", "pretty-bytes": "^6.1.1", "tslib": "^2.3.0", diff --git a/projects/stream-chat-angular/src/lib/attachment-preview-list/attachment-preview-list.component.html b/projects/stream-chat-angular/src/lib/attachment-preview-list/attachment-preview-list.component.html index 58819ae5..5a0525d1 100644 --- a/projects/stream-chat-angular/src/lib/attachment-preview-list/attachment-preview-list.component.html +++ b/projects/stream-chat-angular/src/lib/attachment-preview-list/attachment-preview-list.component.html @@ -48,9 +48,13 @@
-
+
{{ attachmentUpload.file.name }}
this.createPreview(f)); + imageFiles.forEach((f) => void this.createPreview(f)); const newUploads = [ ...imageFiles.map((file) => ({ file, @@ -177,7 +214,7 @@ export class AttachmentService< return attachmentUploads .filter((r) => r.state === 'success') .map((r) => { - const attachment: Attachment = { + let attachment: Attachment = { type: r.type, }; if (r.fromAttachment) { @@ -193,6 +230,9 @@ export class AttachmentService< attachment.file_size = r.file?.size; attachment.thumb_url = r.thumb_url; } + if (r.extraData) { + attachment = { ...attachment, ...r.extraData }; + } } return attachment; @@ -243,18 +283,23 @@ export class AttachmentService< } } - private createPreview(file: File | Blob) { - const reader = new FileReader(); - reader.onload = (event) => { + private async createPreview(file: File | Blob) { + try { + const uri = await createUriFromBlob(file); const attachmentUploads = this.attachmentUploadsSubject.getValue(); const upload = attachmentUploads.find((upload) => upload.file === file); if (!upload) { return; } - upload.previewUri = event.target?.result || undefined; + upload.previewUri = uri; this.attachmentUploadsSubject.next([...attachmentUploads]); - }; - reader.readAsDataURL(file as Blob); + } catch (e: unknown) { + this.chatClientService?.chatClient?.logger( + 'error', + e instanceof Error ? e.message : `Can't create image preview`, + { error: e, tag: ['AttachmentService'] } + ); + } } private async uploadAttachments(uploads: AttachmentUpload[]) { diff --git a/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.spec.ts b/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.spec.ts index 322148e6..a44d663f 100644 --- a/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/channel-preview/channel-preview.component.spec.ts @@ -15,8 +15,9 @@ import { import { ChannelPreviewComponent } from './channel-preview.component'; import { Observable, Subject, of } from 'rxjs'; import { DefaultStreamChatGenerics } from '../types'; -import { IconPlaceholderComponent } from '../icon-placeholder/icon-placeholder.component'; import { DateParserService } from '../date-parser.service'; +import { IconModule } from '../icon/icon.module'; +import { IconPlaceholderComponent } from '../icon/icon-placeholder/icon-placeholder.component'; describe('ChannelPreviewComponent', () => { let fixture: ComponentFixture; @@ -42,12 +43,11 @@ describe('ChannelPreviewComponent', () => { user$: of({ id: 'currentUser' }), }; TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], + imports: [TranslateModule.forRoot(), IconModule], declarations: [ ChannelPreviewComponent, AvatarComponent, AvatarPlaceholderComponent, - IconPlaceholderComponent, ], providers: [ { provide: ChannelService, useValue: channelServiceMock }, diff --git a/projects/stream-chat-angular/src/lib/file-utils.ts b/projects/stream-chat-angular/src/lib/file-utils.ts new file mode 100644 index 00000000..84d06bc7 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/file-utils.ts @@ -0,0 +1,49 @@ +export const isImageFile = (file: File) => { + // photoshop files begin with 'image/' + return file.type.startsWith('image/') && !file.type.endsWith('.photoshop'); +}; + +export const readBlobAsArrayBuffer = (blob: Blob): Promise => + new Promise((resolve, reject) => { + const fileReader = new FileReader(); + fileReader.onload = () => { + resolve(fileReader.result as ArrayBuffer); + }; + + fileReader.onerror = () => { + reject(fileReader.error); + }; + + fileReader.readAsArrayBuffer(blob); + }); + +export const createFileFromBlobs = ({ + blobsArray, + fileName, + mimeType, +}: { + blobsArray: Blob[]; + fileName: string; + mimeType: string; +}) => { + const concatenatedBlob = new Blob(blobsArray, { type: mimeType }); + return new File([concatenatedBlob], fileName, { + type: concatenatedBlob.type, + }); +}; + +export const getExtensionFromMimeType = (mimeType: string) => { + const match = mimeType.match(/\/([^/;]+)/); + return match && match[1]; +}; + +export const createUriFromBlob = (blob: Blob) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + resolve(event.target?.result ?? undefined); + }; + reader.onerror = (e) => reject(e); + reader.readAsDataURL(blob); + }); +}; diff --git a/projects/stream-chat-angular/src/lib/format-duration.ts b/projects/stream-chat-angular/src/lib/format-duration.ts new file mode 100644 index 00000000..2d1d86ee --- /dev/null +++ b/projects/stream-chat-angular/src/lib/format-duration.ts @@ -0,0 +1,18 @@ +export const formatDuration = (durationInSeconds?: number) => { + if (durationInSeconds === undefined || durationInSeconds <= 0) return '00:00'; + + const [hours, hoursLeftover] = divMod(durationInSeconds, 3600); + const [minutes, seconds] = divMod(hoursLeftover, 60); + const roundedSeconds = Math.ceil(seconds); + + const prependHrsZero = hours.toString().length === 1 ? '0' : ''; + const prependMinZero = minutes.toString().length === 1 ? '0' : ''; + const prependSecZero = roundedSeconds.toString().length === 1 ? '0' : ''; + const minSec = `${prependMinZero}${minutes}:${prependSecZero}${roundedSeconds}`; + + return hours ? `${prependHrsZero}${hours}:` + minSec : minSec; +}; + +const divMod = (num: number, divisor: number) => { + return [Math.floor(num / divisor), num % divisor]; +}; diff --git a/projects/stream-chat-angular/src/lib/icon-placeholder/icon-placeholder.component.html b/projects/stream-chat-angular/src/lib/icon/icon-placeholder/icon-placeholder.component.html similarity index 100% rename from projects/stream-chat-angular/src/lib/icon-placeholder/icon-placeholder.component.html rename to projects/stream-chat-angular/src/lib/icon/icon-placeholder/icon-placeholder.component.html diff --git a/projects/stream-chat-angular/src/lib/icon-placeholder/icon-placeholder.component.spec.ts b/projects/stream-chat-angular/src/lib/icon/icon-placeholder/icon-placeholder.component.spec.ts similarity index 96% rename from projects/stream-chat-angular/src/lib/icon-placeholder/icon-placeholder.component.spec.ts rename to projects/stream-chat-angular/src/lib/icon/icon-placeholder/icon-placeholder.component.spec.ts index deebb220..2e4bf8fb 100644 --- a/projects/stream-chat-angular/src/lib/icon-placeholder/icon-placeholder.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/icon/icon-placeholder/icon-placeholder.component.spec.ts @@ -1,8 +1,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { IconComponent } from '../icon/icon.component'; import { IconPlaceholderComponent } from './icon-placeholder.component'; +import { IconComponent } from '../icon.component'; describe('IconPlaceholderComponent', () => { let component: IconPlaceholderComponent; diff --git a/projects/stream-chat-angular/src/lib/icon-placeholder/icon-placeholder.component.ts b/projects/stream-chat-angular/src/lib/icon/icon-placeholder/icon-placeholder.component.ts similarity index 85% rename from projects/stream-chat-angular/src/lib/icon-placeholder/icon-placeholder.component.ts rename to projects/stream-chat-angular/src/lib/icon/icon-placeholder/icon-placeholder.component.ts index e7ddd117..0916e089 100644 --- a/projects/stream-chat-angular/src/lib/icon-placeholder/icon-placeholder.component.ts +++ b/projects/stream-chat-angular/src/lib/icon/icon-placeholder/icon-placeholder.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnChanges } from '@angular/core'; -import { CustomTemplatesService } from '../custom-templates.service'; -import { Icon } from '../icon/icon.component'; -import { IconContext } from '../types'; +import { Icon } from '../icon.component'; +import { IconContext } from '../../types'; +import { CustomTemplatesService } from '../../custom-templates.service'; /** * The `IconPlaceholder` component displays the [default icons](./IconComponent.mdx) unless a [custom template](../services/CustomTemplatesService.mdx) is provided. This component is used by the SDK internally, you likely won't need to use it. diff --git a/projects/stream-chat-angular/src/lib/icon/icon.component.ts b/projects/stream-chat-angular/src/lib/icon/icon.component.ts index 3bc04abf..6a788b83 100644 --- a/projects/stream-chat-angular/src/lib/icon/icon.component.ts +++ b/projects/stream-chat-angular/src/lib/icon/icon.component.ts @@ -21,7 +21,8 @@ export type Icon = | 'error' | 'play' | 'pause' - | 'mic'; + | 'mic' + | 'bin'; /** * The `Icon` component can be used to display different icons (i. e. message delivered icon). diff --git a/projects/stream-chat-angular/src/lib/icon/icon.module.ts b/projects/stream-chat-angular/src/lib/icon/icon.module.ts new file mode 100644 index 00000000..57abbfae --- /dev/null +++ b/projects/stream-chat-angular/src/lib/icon/icon.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { IconComponent } from './icon.component'; +import { CommonModule } from '@angular/common'; +import { LoadingIndicatorComponent } from './loading-indicator/loading-indicator.component'; +import { IconPlaceholderComponent } from './icon-placeholder/icon-placeholder.component'; +import { LoadingIndicatorPlaceholderComponent } from './loading-indicator-placeholder/loading-indicator-placeholder.component'; + +@NgModule({ + declarations: [ + IconComponent, + IconPlaceholderComponent, + LoadingIndicatorComponent, + LoadingIndicatorPlaceholderComponent, + ], + imports: [CommonModule], + exports: [ + IconComponent, + IconPlaceholderComponent, + LoadingIndicatorComponent, + LoadingIndicatorPlaceholderComponent, + ], +}) +export class IconModule {} diff --git a/projects/stream-chat-angular/src/lib/loading-indicator-placeholder/loading-indicator-placeholder.component.html b/projects/stream-chat-angular/src/lib/icon/loading-indicator-placeholder/loading-indicator-placeholder.component.html similarity index 100% rename from projects/stream-chat-angular/src/lib/loading-indicator-placeholder/loading-indicator-placeholder.component.html rename to projects/stream-chat-angular/src/lib/icon/loading-indicator-placeholder/loading-indicator-placeholder.component.html diff --git a/projects/stream-chat-angular/src/lib/loading-indicator-placeholder/loading-indicator-placeholder.component.ts b/projects/stream-chat-angular/src/lib/icon/loading-indicator-placeholder/loading-indicator-placeholder.component.ts similarity index 89% rename from projects/stream-chat-angular/src/lib/loading-indicator-placeholder/loading-indicator-placeholder.component.ts rename to projects/stream-chat-angular/src/lib/icon/loading-indicator-placeholder/loading-indicator-placeholder.component.ts index 324dd429..dc2a5c59 100644 --- a/projects/stream-chat-angular/src/lib/loading-indicator-placeholder/loading-indicator-placeholder.component.ts +++ b/projects/stream-chat-angular/src/lib/icon/loading-indicator-placeholder/loading-indicator-placeholder.component.ts @@ -1,5 +1,5 @@ import { Component } from '@angular/core'; -import { CustomTemplatesService } from '../custom-templates.service'; +import { CustomTemplatesService } from '../../custom-templates.service'; /** * The `LoadingInficatorPlaceholder` component displays the [default loading indicator](./LoadingIndicatorComponent.mdx) unless a [custom template](../services/CustomTemplatesService.mdx) is provided. This component is used by the SDK internally, you likely won't need to use it. diff --git a/projects/stream-chat-angular/src/lib/loading-indicator/loading-indicator.component.html b/projects/stream-chat-angular/src/lib/icon/loading-indicator/loading-indicator.component.html similarity index 100% rename from projects/stream-chat-angular/src/lib/loading-indicator/loading-indicator.component.html rename to projects/stream-chat-angular/src/lib/icon/loading-indicator/loading-indicator.component.html diff --git a/projects/stream-chat-angular/src/lib/loading-indicator/loading-indicator.component.ts b/projects/stream-chat-angular/src/lib/icon/loading-indicator/loading-indicator.component.ts similarity index 100% rename from projects/stream-chat-angular/src/lib/loading-indicator/loading-indicator.component.ts rename to projects/stream-chat-angular/src/lib/icon/loading-indicator/loading-indicator.component.ts diff --git a/projects/stream-chat-angular/src/lib/is-image-file.ts b/projects/stream-chat-angular/src/lib/is-image-file.ts deleted file mode 100644 index bb6942c4..00000000 --- a/projects/stream-chat-angular/src/lib/is-image-file.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const isImageFile = (file: File) => { - // photoshop files begin with 'image/' - return file.type.startsWith('image/') && !file.type.endsWith('.photoshop'); -}; diff --git a/projects/stream-chat-angular/src/lib/is-safari.ts b/projects/stream-chat-angular/src/lib/is-safari.ts new file mode 100644 index 00000000..af3acb78 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/is-safari.ts @@ -0,0 +1,3 @@ +export const isSafari = /^((?!chrome|android).)*safari/i.test( + navigator.userAgent +); diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input-config.service.ts b/projects/stream-chat-angular/src/lib/message-input/message-input-config.service.ts index 729fc8dd..ebf9448b 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input-config.service.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input-config.service.ts @@ -23,11 +23,15 @@ export class MessageInputConfigService { * The scope for user mentions, either members of the current channel of members of the application */ mentionScope: 'channel' | 'application' | undefined = 'channel'; - /** * In `desktop` mode the `Enter` key will trigger message sending, in `mobile` mode the `Enter` key will insert a new line to the message input. */ inputMode: 'desktop' | 'mobile' = 'desktop'; + /** + * If `true` the recording will be sent as a message immediately after the recording is completed. + * If `false`, the recording will added to the attachment preview, and users can continue composing the message. + */ + sendVoiceRecordingImmediately = true; constructor() {} } diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.html b/projects/stream-chat-angular/src/lib/message-input/message-input.component.html index 388c3ad8..17ffbe20 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.html +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.html @@ -1,4 +1,10 @@ -
+
+
{{ "streamChat.Reply to Message" | translate }} @@ -161,6 +167,9 @@ *ngIf="displayVoiceRecordingButton" class="str-chat__start-recording-audio-button" data-testid="start-voice-recording" + [disabled]="voiceRecorderService.isRecorderVisible$.value" + (click)="startVoiceRecording()" + (keyup.enter)="startVoiceRecording()" > @@ -175,3 +184,8 @@
+ diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts index bf73a136..b9bd9099 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.spec.ts @@ -30,11 +30,11 @@ import { import { MessageInputComponent } from './message-input.component'; import { TextareaDirective } from './textarea.directive'; import { AutocompleteTextareaComponent } from './autocomplete-textarea/autocomplete-textarea.component'; -import { AvatarComponent } from '../avatar/avatar.component'; import { AttachmentListComponent } from '../attachment-list/attachment-list.component'; import { AvatarPlaceholderComponent } from '../avatar-placeholder/avatar-placeholder.component'; import { AttachmentPreviewListComponent } from '../attachment-preview-list/attachment-preview-list.component'; import { MessageActionsService } from '../message-actions.service'; +import { StreamAvatarModule } from '../stream-avatar.module'; describe('MessageInputComponent', () => { let nativeElement: HTMLElement; @@ -112,14 +112,12 @@ describe('MessageInputComponent', () => { }, }); TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], + imports: [TranslateModule.forRoot(), StreamAvatarModule], declarations: [ MessageInputComponent, TextareaDirective, AutocompleteTextareaComponent, - AvatarComponent, AttachmentListComponent, - AvatarPlaceholderComponent, AttachmentPreviewListComponent, ], providers: [ diff --git a/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts b/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts index e2fb51d1..03612c26 100644 --- a/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts +++ b/projects/stream-chat-angular/src/lib/message-input/message-input.component.ts @@ -4,6 +4,7 @@ import { Component, ComponentFactoryResolver, ComponentRef, + ContentChild, ElementRef, EventEmitter, HostBinding, @@ -28,6 +29,7 @@ import { NotificationService } from '../notification.service'; import { AttachmentPreviewListContext, AttachmentUpload, + AudioRecording, CustomAttachmentUploadContext, DefaultStreamChatGenerics, EmojiPickerContext, @@ -40,6 +42,7 @@ import { EmojiInputService } from './emoji-input.service'; import { CustomTemplatesService } from '../custom-templates.service'; import { v4 as uuidv4 } from 'uuid'; import { MessageActionsService } from '../message-actions.service'; +import { VoiceRecorderService } from './voice-recorder.service'; /** * The `MessageInput` component displays an input where users can type their messages and upload files, and sends the message to the active channel. The component can be used to compose new messages or update existing ones. To send messages, the chat user needs to have the necessary [channel capability](https://getstream.io/chat/docs/javascript/channel_capabilities/?language=javascript). @@ -48,7 +51,7 @@ import { MessageActionsService } from '../message-actions.service'; selector: 'stream-message-input', templateUrl: './message-input.component.html', styles: [], - providers: [AttachmentService, EmojiInputService], + providers: [AttachmentService, EmojiInputService, VoiceRecorderService], }) export class MessageInputComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit @@ -103,6 +106,10 @@ export class MessageInputComponent * You can enable/disable voice recordings with this input */ @Input() displayVoiceRecordingButton = false; + + @ContentChild(TemplateRef) voiceRecorderRef: + | TemplateRef<{ service: VoiceRecorderService }> + | undefined; /** * Emits when a message was successfuly sent or updated */ @@ -146,7 +153,7 @@ export class MessageInputComponent constructor( private channelService: ChannelService, private notificationService: NotificationService, - private attachmentService: AttachmentService, + public readonly attachmentService: AttachmentService, private configService: MessageInputConfigService, @Inject(textareaInjectionToken) private textareaType: Type, @@ -154,7 +161,8 @@ export class MessageInputComponent private cdRef: ChangeDetectorRef, private emojiInputService: EmojiInputService, private customTemplatesService: CustomTemplatesService, - private messageActionsService: MessageActionsService + private messageActionsService: MessageActionsService, + public readonly voiceRecorderService: VoiceRecorderService ) { this.textareaPlaceholder = this.defaultTextareaPlaceholder; this.subscriptions.push( @@ -249,6 +257,13 @@ export class MessageInputComponent } }) ); + this.subscriptions.push( + this.voiceRecorderService.recording$.subscribe((recording) => { + if (recording) { + void this.voiceRecordingReady(recording); + } + }) + ); } ngOnInit(): void { @@ -456,6 +471,21 @@ export class MessageInputComponent }; } + startVoiceRecording() { + this.voiceRecorderService.isRecorderVisible$.next(true); + } + + async voiceRecordingReady(recording: AudioRecording) { + try { + await this.attachmentService.uploadVoiceRecording(recording); + if (this.configService.sendVoiceRecordingImmediately) { + await this.messageSent(); + } + } finally { + this.voiceRecorderService.isRecorderVisible$.next(false); + } + } + get isUpdate() { return !!this.message; } diff --git a/projects/stream-chat-angular/src/lib/message-input/voice-recorder.service.spec.ts b/projects/stream-chat-angular/src/lib/message-input/voice-recorder.service.spec.ts new file mode 100644 index 00000000..ce5eaf8e --- /dev/null +++ b/projects/stream-chat-angular/src/lib/message-input/voice-recorder.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { VoiceRecorderService } from './voice-recorder.service'; + +describe('VoiceRecorderService', () => { + let service: VoiceRecorderService; + + beforeEach(() => { + TestBed.configureTestingModule({}); + service = TestBed.inject(VoiceRecorderService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); +}); diff --git a/projects/stream-chat-angular/src/lib/message-input/voice-recorder.service.ts b/projects/stream-chat-angular/src/lib/message-input/voice-recorder.service.ts new file mode 100644 index 00000000..7d022625 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/message-input/voice-recorder.service.ts @@ -0,0 +1,16 @@ +import { Injectable, NgModule } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { AudioRecording } from '../types'; + +/** + * The `VoiceRecorderService` provides a commincation outlet between the message input and voice recorder components. + */ +@Injectable({ + providedIn: NgModule, +}) +export class VoiceRecorderService { + isRecorderVisible$ = new BehaviorSubject(false); + recording$ = new BehaviorSubject(undefined); + + constructor() {} +} diff --git a/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts b/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts index 782341c1..4021db2b 100644 --- a/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts +++ b/projects/stream-chat-angular/src/lib/message-list/message-list.component.ts @@ -43,6 +43,7 @@ import { listUsers } from '../list-users'; import { DateParserService } from '../date-parser.service'; import { isOnSeparateDate } from '../is-on-separate-date'; import { VirtualizedMessageListService } from '../virtualized-message-list.service'; +import { isSafari } from '../is-safari'; /** * The `MessageList` component renders a scrollable list of messages. @@ -148,7 +149,6 @@ export class MessageListComponent typeof setTimeout >; private jumpToLatestButtonVisibilityTimeout?: ReturnType; - private isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); private forceRepaintSubject = new Subject(); private messageIdToAnchorTo?: string; private anchorMessageTopOffset?: number; @@ -468,7 +468,7 @@ export class MessageListComponent scrollToBottom(): void { this.scrollContainer.nativeElement.scrollTop = this.scrollContainer.nativeElement.scrollHeight + 0.1; - if (this.isSafari) { + if (isSafari) { this.forceRepaintSubject.next(); } } @@ -609,7 +609,7 @@ export class MessageListComponent (messageToAlignTo?.getBoundingClientRect()?.top || 0) - (this.anchorMessageTopOffset || 0); this.anchorMessageTopOffset = undefined; - if (this.isSafari) { + if (isSafari) { this.forceRepaintSubject.next(); } } diff --git a/projects/stream-chat-angular/src/lib/message/message.component.spec.ts b/projects/stream-chat-angular/src/lib/message/message.component.spec.ts index 146d9bc6..fc78ff42 100644 --- a/projects/stream-chat-angular/src/lib/message/message.component.spec.ts +++ b/projects/stream-chat-angular/src/lib/message/message.component.spec.ts @@ -7,7 +7,7 @@ import { import { MessageResponseBase, UserResponse } from 'stream-chat'; import { DefaultStreamChatGenerics, StreamMessage } from '../types'; -import { LoadingIndicatorComponent } from '../loading-indicator/loading-indicator.component'; +import { LoadingIndicatorComponent } from '../icon/loading-indicator/loading-indicator.component'; import { MessageComponent } from './message.component'; import { AvatarComponent } from '../avatar/avatar.component'; import { ChatClientService } from '../chat-client.service'; diff --git a/projects/stream-chat-angular/src/lib/stream-chat.module.ts b/projects/stream-chat-angular/src/lib/stream-chat.module.ts index 4522acd5..9b487fd3 100644 --- a/projects/stream-chat-angular/src/lib/stream-chat.module.ts +++ b/projects/stream-chat-angular/src/lib/stream-chat.module.ts @@ -7,8 +7,6 @@ import { MessageComponent } from './message/message.component'; import { MessageInputComponent } from './message-input/message-input.component'; import { MessageListComponent } from './message-list/message-list.component'; import { CommonModule } from '@angular/common'; -import { LoadingIndicatorComponent } from './loading-indicator/loading-indicator.component'; -import { IconComponent } from './icon/icon.component'; import { MessageActionsBoxComponent } from './message-actions-box/message-actions-box.component'; import { AttachmentListComponent } from './attachment-list/attachment-list.component'; import { MessageReactionsComponent } from './message-reactions/message-reactions.component'; @@ -19,16 +17,15 @@ import { ModalComponent } from './modal/modal.component'; import { TextareaDirective } from './message-input/textarea.directive'; import { StreamAvatarModule } from './stream-avatar.module'; import { ThreadComponent } from './thread/thread.component'; -import { IconPlaceholderComponent } from './icon-placeholder/icon-placeholder.component'; -import { LoadingIndicatorPlaceholderComponent } from './loading-indicator-placeholder/loading-indicator-placeholder.component'; import { MessageBouncePromptComponent } from './message-bounce-prompt/message-bounce-prompt.component'; -import { VoiceRecordingComponent } from './voice-recording/voice-recording.component'; -import { VoiceRecordingWavebarComponent } from './voice-recording/voice-recording-wavebar/voice-recording-wavebar.component'; import { NgxFloatUiModule } from 'ngx-float-ui'; import { TranslateModule } from '@ngx-translate/core'; import { MessageReactionsSelectorComponent } from './message-reactions-selector/message-reactions-selector.component'; import { PaginatedListComponent } from './paginated-list/paginated-list.component'; import { UserListComponent } from './user-list/user-list.component'; +import { VoiceRecordingModule } from './voice-recording/voice-recording.module'; +import { IconModule } from './icon/icon.module'; +import { VoiceRecorderService } from './message-input/voice-recorder.service'; @NgModule({ declarations: [ @@ -39,8 +36,6 @@ import { UserListComponent } from './user-list/user-list.component'; MessageComponent, MessageInputComponent, MessageListComponent, - LoadingIndicatorComponent, - IconComponent, MessageActionsBoxComponent, AttachmentListComponent, MessageReactionsComponent, @@ -50,11 +45,7 @@ import { UserListComponent } from './user-list/user-list.component'; ModalComponent, TextareaDirective, ThreadComponent, - IconPlaceholderComponent, - LoadingIndicatorPlaceholderComponent, MessageBouncePromptComponent, - VoiceRecordingComponent, - VoiceRecordingWavebarComponent, MessageReactionsSelectorComponent, UserListComponent, PaginatedListComponent, @@ -64,6 +55,8 @@ import { UserListComponent } from './user-list/user-list.component'; NgxFloatUiModule, StreamAvatarModule, TranslateModule, + VoiceRecordingModule, + IconModule, ], exports: [ ChannelComponent, @@ -73,8 +66,6 @@ import { UserListComponent } from './user-list/user-list.component'; MessageComponent, MessageInputComponent, MessageListComponent, - LoadingIndicatorComponent, - IconComponent, MessageActionsBoxComponent, AttachmentListComponent, MessageReactionsComponent, @@ -84,14 +75,13 @@ import { UserListComponent } from './user-list/user-list.component'; ModalComponent, StreamAvatarModule, ThreadComponent, - IconPlaceholderComponent, - LoadingIndicatorPlaceholderComponent, MessageBouncePromptComponent, - VoiceRecordingComponent, - VoiceRecordingWavebarComponent, + VoiceRecordingModule, MessageReactionsSelectorComponent, UserListComponent, PaginatedListComponent, + IconModule, ], + providers: [VoiceRecorderService], }) export class StreamChatModule {} diff --git a/projects/stream-chat-angular/src/lib/types.ts b/projects/stream-chat-angular/src/lib/types.ts index ec51c9fb..3dd6aaf9 100644 --- a/projects/stream-chat-angular/src/lib/types.ts +++ b/projects/stream-chat-angular/src/lib/types.ts @@ -93,9 +93,10 @@ export type AttachmentUpload< errorReason?: AttachmentUploadErrorReason; errorExtraInfo?: { param: string }[]; url?: string; - type: 'image' | 'file' | 'video'; + type: 'image' | 'file' | 'video' | 'voiceRecording'; previewUri?: string | ArrayBuffer; thumb_url?: string; + extraData?: Partial>; fromAttachment?: Attachment; }; @@ -472,3 +473,12 @@ export type VirtualizedListQueryState = { export type VirtualizedListQueryDirection = 'top' | 'bottom'; export type VirtualizedListVerticalItemPosition = 'top' | 'bottom' | 'middle'; + +export type AudioRecording = MediaRecording & { waveform_data: number[] }; + +export type MediaRecording = { + recording: File; + duration: number; + mime_type: string; + asset_url: string | ArrayBuffer | undefined; +}; diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/amplitude-recorder.service.ts b/projects/stream-chat-angular/src/lib/voice-recorder/amplitude-recorder.service.ts new file mode 100644 index 00000000..bc8cec20 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recorder/amplitude-recorder.service.ts @@ -0,0 +1,167 @@ +import { Injectable, NgModule } from '@angular/core'; +import { BehaviorSubject, Observable } from 'rxjs'; +import { ChatClientService } from '../chat-client.service'; + +const MAX_FREQUENCY_AMPLITUDE = 255 as const; + +const rootMeanSquare = (values: Uint8Array) => + Math.sqrt( + values.reduce((acc, val) => acc + Math.pow(val, 2), 0) / values.length + ); + +/** + * fftSize + * An unsigned integer, representing the window size of the FFT, given in number of samples. + * A higher value will result in more details in the frequency domain but fewer details + * in the amplitude domain. + * + * Must be a power of 2 between 2^5 and 2^15, so one of: 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, and 32768. + * Defaults to 32. + * + * maxDecibels + * A double, representing the maximum decibel value for scaling the FFT analysis data, + * where 0 dB is the loudest possible sound, -10 dB is a 10th of that, etc. + * The default value is -30 dB. + * + * minDecibels + * A double, representing the minimum decibel value for scaling the FFT analysis data, + * where 0 dB is the loudest possible sound, -10 dB is a 10th of that, etc. + * The default value is -100 dB. + */ +export type AmplitudeAnalyserConfig = Pick< + AnalyserNode, + 'fftSize' | 'maxDecibels' | 'minDecibels' +>; +export type AmplitudeRecorderConfig = { + analyserConfig: AmplitudeAnalyserConfig; + sampleCount: number; + samplingFrequencyMs: number; +}; + +export const DEFAULT_AMPLITUDE_RECORDER_CONFIG: AmplitudeRecorderConfig = { + analyserConfig: { + fftSize: 32, + maxDecibels: 0, + minDecibels: -100, + } as AmplitudeAnalyserConfig, + sampleCount: 100, + samplingFrequencyMs: 60, +}; + +/** + * The `AmplitudeRecorderService` is a utility service used to create amplitude values for voice recordings, making it possible to display a wave bar + */ +@Injectable({ providedIn: NgModule }) +export class AmplitudeRecorderService { + config = DEFAULT_AMPLITUDE_RECORDER_CONFIG; + amplitudes$: Observable; + error$: Observable; + + private amplitudesSubject = new BehaviorSubject([]); + private errorSubject = new BehaviorSubject(undefined); + private audioContext: AudioContext | undefined; + private analyserNode: AnalyserNode | undefined; + private microphone: MediaStreamAudioSourceNode | undefined; + private stream: MediaStream | undefined; + private amplitudeSamplingInterval: ReturnType | undefined; + + constructor(private chatService: ChatClientService) { + this.amplitudes$ = this.amplitudesSubject.asObservable(); + this.error$ = this.errorSubject.asObservable(); + } + + /** + * The recorded amplitudes + */ + get amplitudes() { + return this.amplitudesSubject.value; + } + + /** + * Start amplitude recording for the given media stream + * @param stream + */ + start = (stream: MediaStream) => { + this.stop(); + + this.stream = stream; + this.init(); + + this.resume(); + }; + + /** + * Temporarily pause amplitude recording, recording can be resumed with `resume` + */ + pause() { + clearInterval(this.amplitudeSamplingInterval); + this.amplitudeSamplingInterval = undefined; + } + + /** + * Resume amplited recording after it was pasued + */ + resume() { + this.amplitudeSamplingInterval = setInterval(() => { + if (!this.analyserNode) { + return; + } + const frequencyBins = new Uint8Array(this.analyserNode.frequencyBinCount); + try { + this.analyserNode.getByteFrequencyData(frequencyBins); + } catch (e) { + this.logError(e as Error); + this.errorSubject.next(e as Error); + return; + } + const normalizedSignalStrength = + rootMeanSquare(frequencyBins) / MAX_FREQUENCY_AMPLITUDE; + this.amplitudesSubject.next([ + ...this.amplitudesSubject.value, + normalizedSignalStrength, + ]); + }, this.config.samplingFrequencyMs); + } + + /** + * Stop the amplitude recording and frees up used resources + */ + stop() { + if (!this.stream) { + return; + } + this.stream = undefined; + clearInterval(this.amplitudeSamplingInterval); + this.amplitudeSamplingInterval = undefined; + this.amplitudesSubject.next([]); + this.errorSubject.next(undefined); + this.microphone?.disconnect(); + this.analyserNode?.disconnect(); + if (this.audioContext?.state !== 'closed') { + void this.audioContext?.close(); + } + } + + private init() { + if (!this.stream) { + return; + } + + this.audioContext = new AudioContext(); + this.analyserNode = this.audioContext.createAnalyser(); + const { analyserConfig } = this.config; + this.analyserNode.fftSize = analyserConfig.fftSize; + this.analyserNode.maxDecibels = analyserConfig.maxDecibels; + this.analyserNode.minDecibels = analyserConfig.minDecibels; + + this.microphone = this.audioContext.createMediaStreamSource(this.stream); + this.microphone.connect(this.analyserNode); + } + + private logError(error: Error) { + this.chatService.chatClient?.logger('error', error.message, { + error: error, + tag: ['AmplitudeRecorderService'], + }); + } +} diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/audio-recorder.service.ts b/projects/stream-chat-angular/src/lib/voice-recorder/audio-recorder.service.ts new file mode 100644 index 00000000..ee6fa79d --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recorder/audio-recorder.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@angular/core'; +import { AmplitudeRecorderService } from './amplitude-recorder.service'; +import { isSafari } from '../is-safari'; +import { MediaRecorderConfig, MultimediaRecorder } from './media-recorder'; +import { NotificationService } from '../notification.service'; +import { ChatClientService } from '../chat-client.service'; +import { TranscoderService } from './transcoder.service'; +import { resampleWaveForm } from '../wave-form-sampler'; +import { AudioRecording, MediaRecording } from '../types'; +import { NgModel } from '@angular/forms'; + +/** + * The `AudioRecorderService` can record an audio file, the SDK uses this to record a voice message + */ +@Injectable({ providedIn: NgModel }) +export class AudioRecorderService extends MultimediaRecorder< + Omit +> { + /** + * Due to browser restrictions the following config is used: + * - In Safari we record in audio/mp4 + * - For all other browsers we use audio/webm (which is then transcoded to wav) + */ + config: MediaRecorderConfig = { + mimeType: isSafari ? 'audio/mp4;codecs=mp4a.40.2' : 'audio/webm', + }; + + constructor( + notificationService: NotificationService, + chatService: ChatClientService, + transcoder: TranscoderService, + private amplitudeRecorder: AmplitudeRecorderService + ) { + super(notificationService, chatService, transcoder); + } + + protected enrichWithExtraData() { + const waveformData = resampleWaveForm( + this.amplitudeRecorder.amplitudes, + this.amplitudeRecorder.config.sampleCount + ); + + return { waveform_data: waveformData }; + } + + /** + * Start audio recording + */ + async start() { + const result = await super.start(); + + if (this.mediaRecorder?.stream) { + this.amplitudeRecorder.start(this.mediaRecorder?.stream); + } + + return result; + } + + /** + * Pause audio recording, it can be restarted using `resume` + */ + pause() { + const result = super.pause(); + + this.amplitudeRecorder.pause(); + + return result; + } + + /** + * Resume a previously paused recording + */ + resume() { + const result = super.resume(); + + this.amplitudeRecorder.resume(); + + return result; + } + + /** + * Stop the recording and free up used resources + * @param options + * @param options.cancel if this is `true` no recording will be created, but resources will be freed + * @returns the recording + */ + async stop(options?: { cancel: boolean }) { + try { + const result = await super.stop(options); + + return result; + } finally { + this.amplitudeRecorder.stop(); + } + } +} diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/media-recorder.ts b/projects/stream-chat-angular/src/lib/voice-recorder/media-recorder.ts new file mode 100644 index 00000000..90cf7c17 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recorder/media-recorder.ts @@ -0,0 +1,257 @@ +import { BehaviorSubject, Observable } from 'rxjs'; +import { + createFileFromBlobs, + createUriFromBlob, + getExtensionFromMimeType, +} from '../file-utils'; +import { NotificationService } from '../notification.service'; +import { ChatClientService } from '../chat-client.service'; +import fixWebmDuration from 'fix-webm-duration'; +import { TranscoderService } from './transcoder.service'; +import { MediaRecording } from '../types'; + +export type MediaRecorderConfig = Omit & + Required>; + +export enum MediaRecordingState { + PAUSED = 'paused', + RECORDING = 'recording', + STOPPED = 'stopped', + ERROR = 'error', +} + +const mediaRecordingState = new BehaviorSubject( + MediaRecordingState.STOPPED +); + +export type MediaRecordingTitleOptions = { + mimeType: string; +}; + +export abstract class MultimediaRecorder { + abstract config: MediaRecorderConfig; + customGenerateRecordingTitle: + | ((options: MediaRecordingTitleOptions) => string) + | undefined; + recordingState$ = mediaRecordingState.asObservable(); + recording$: Observable<(MediaRecording & T) | undefined>; + + protected recordingSubject = new BehaviorSubject< + (MediaRecording & T) | undefined + >(undefined); + + protected mediaRecorder: MediaRecorder | undefined; + protected startTime: number | undefined; + protected recordedChunkDurations: number[] = []; + + constructor( + protected notificationService: NotificationService, + protected chatService: ChatClientService, + private transcoder: TranscoderService + ) { + this.recording$ = this.recordingSubject.asObservable(); + } + + get durationMs() { + return ( + this.recordedChunkDurations.reduce((acc, val) => acc + val, 0) + + (this.startTime ? Date.now() - this.startTime : 0) + ); + } + + get mediaType() { + return this.config.mimeType.split('/')?.[0] || 'unknown'; + } + + generateRecordingTitle = (mimeType: string) => { + if (this.customGenerateRecordingTitle) { + return this.customGenerateRecordingTitle({ mimeType }); + } + return `${ + this.mediaType + }_recording_${new Date().toISOString()}.${getExtensionFromMimeType( + mimeType + )}`; // extension needed so that desktop Safari can play the asset + }; + + async makeRecording(blob: Blob) { + const { mimeType } = this.config; + try { + if (mimeType.includes('webm')) { + // The browser does not include duration metadata with the recorded blob + blob = await fixWebmDuration(blob, this.durationMs, { + logger: () => null, // prevents polluting the browser console + }); + } + blob = await this.transcoder.transcode(blob, { mimeType }); + + if (!blob) return; + + const file = createFileFromBlobs({ + blobsArray: [blob], + fileName: this.generateRecordingTitle(blob.type), + mimeType: blob.type, + }); + const previewUrl = await createUriFromBlob(file); + + const extraData = this.enrichWithExtraData(); + this.recordingSubject.next({ + recording: file, + duration: this.durationMs / 1000, + asset_url: previewUrl, + mime_type: mimeType, + ...extraData, + }); + return file; + } catch (error) { + this.logError(error as Error); + mediaRecordingState.next(MediaRecordingState.ERROR); + return undefined; + } + } + + handleErrorEvent = (e: Event) => { + /* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */ + this.logError((e as ErrorEvent).error); + mediaRecordingState.next(MediaRecordingState.ERROR); + this.notificationService.addTemporaryNotification( + 'streamChat.An error has occurred during recording' + ); + void this.stop({ cancel: true }); + }; + + handleDataavailableEvent = (e: BlobEvent) => { + if (!e.data.size) return; + void this.makeRecording(e.data); + }; + + async start() { + if ( + [MediaRecordingState.RECORDING, MediaRecordingState.PAUSED].includes( + mediaRecordingState.value + ) + ) { + const error = new Error( + 'Cannot start recording. Recording already in progress' + ); + this.logError(error); + mediaRecordingState.next(MediaRecordingState.ERROR); + return; + } + + this.recordingSubject.next(undefined); + + // account for requirement on iOS as per this bug report: https://bugs.webkit.org/show_bug.cgi?id=252303 + if (!navigator.mediaDevices) { + const error = new Error('Media recording is not supported'); + this.logError(error); + mediaRecordingState.next(MediaRecordingState.ERROR); + this.notificationService.addTemporaryNotification( + `streamChat.Media recording not supported` + ); + return; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + this.mediaRecorder = new MediaRecorder(stream, this.config); + + this.mediaRecorder.addEventListener( + 'dataavailable', + this.handleDataavailableEvent + ); + this.mediaRecorder.addEventListener('error', this.handleErrorEvent); + + this.startTime = new Date().getTime(); + this.mediaRecorder.start(); + + mediaRecordingState.next(MediaRecordingState.RECORDING); + } catch (error) { + this.logError(error as Error); + void this.stop({ cancel: true }); + mediaRecordingState.next(MediaRecordingState.ERROR); + // TODO handle permission errors + this.notificationService.addTemporaryNotification( + `streamChat.Error starting recording` + ); + } + } + + pause() { + if (mediaRecordingState.value !== MediaRecordingState.RECORDING) return; + if (this.startTime) { + this.recordedChunkDurations.push(new Date().getTime() - this.startTime); + this.startTime = undefined; + } + this.mediaRecorder?.pause(); + mediaRecordingState.next(MediaRecordingState.PAUSED); + } + + resume() { + if (mediaRecordingState.value !== MediaRecordingState.PAUSED) return; + this.startTime = new Date().getTime(); + this.mediaRecorder?.resume(); + mediaRecordingState.next(MediaRecordingState.RECORDING); + } + + async stop(options: { cancel: boolean } = { cancel: false }) { + if (this.startTime) { + this.recordedChunkDurations.push(new Date().getTime() - this.startTime); + this.startTime = undefined; + } + let recording!: MediaRecording & T; + this.mediaRecorder?.stop(); + try { + if ( + !options.cancel && + mediaRecordingState.value !== MediaRecordingState.ERROR + ) { + recording = await new Promise((resolve, reject) => { + this.recording$.subscribe((r) => { + if (r) { + resolve(r); + } + }); + this.recordingState$.subscribe((s) => { + if (s === MediaRecordingState.ERROR) { + reject(new Error(`Recording couldn't be created`)); + } + }); + }); + } + } catch { + this.notificationService.addTemporaryNotification( + 'streamChat.Error occured while creating the recording' + ); + } finally { + this.recordedChunkDurations = []; + this.startTime = undefined; + + this.mediaRecorder?.removeEventListener( + 'dataavailable', + this.handleDataavailableEvent + ); + this.mediaRecorder?.removeEventListener('error', this.handleErrorEvent); + if (this.mediaRecorder?.stream?.active) { + this.mediaRecorder?.stream?.getTracks().forEach((track) => { + track.stop(); + this.mediaRecorder?.stream?.removeTrack(track); + }); + this.mediaRecorder = undefined; + } + + mediaRecordingState.next(MediaRecordingState.STOPPED); + } + + return recording; + } + + protected abstract enrichWithExtraData(): T; + + protected logError(error: Error) { + this.chatService.chatClient?.logger('error', error.message, { + error: error, + tag: ['MediaRecorder'], + }); + } +} diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/transcoder.service.ts b/projects/stream-chat-angular/src/lib/voice-recorder/transcoder.service.ts new file mode 100644 index 00000000..a52f8c8d --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recorder/transcoder.service.ts @@ -0,0 +1,198 @@ +import { Injectable, NgModule } from '@angular/core'; +import { readBlobAsArrayBuffer } from '../file-utils'; + +export type TranscoderConfig = { + sampleRate: number; +}; + +export type TranscodeParams = TranscoderConfig & { + blob: Blob; +}; + +const WAV_HEADER_LENGTH_BYTES = 44 as const; +const BYTES_PER_SAMPLE = 2 as const; +const RIFF_FILE_MAX_BYTES = 4294967295 as const; + +const HEADER = { + AUDIO_FORMAT: { offset: 20, value: 1 }, // PCM = 1 + BITS_PER_SAMPLE: { offset: 34, value: BYTES_PER_SAMPLE * 8 }, // 16 bits encoding + BLOCK_ALIGN: { offset: 32 }, + BYTE_RATE: { offset: 28 }, + CHANNEL_COUNT: { offset: 22 }, // 1 - mono, 2 - stereo + CHUNK_ID: { offset: 0, value: 0x52494646 }, // hex representation of string "RIFF" (Resource Interchange File Format) - identifies the file structure that defines a class of more specific file formats, e.g. WAVE + CHUNK_SIZE: { offset: 4 }, + FILE_FORMAT: { offset: 8, value: 0x57415645 }, // hex representation of string "WAVE" + SAMPLE_RATE: { offset: 24 }, + SUBCHUNK1_ID: { offset: 12, value: 0x666d7420 }, // hex representation of string "fmt " - identifies the start of "format" section of the header + SUBCHUNK1_SIZE: { offset: 16, value: 16 }, // Subchunk1 Size without SUBCHUNK1_ID and SUBCHUNK1_SIZE fields + SUBCHUNK2_ID: { offset: 36, value: 0x64617461 }, // hex representation of string "data" - identifies the start of actual audio data section + SUBCHUNK2_SIZE: { offset: 40 }, // actual audio data size +} as const; + +type WriteWaveHeaderParams = { + arrayBuffer: ArrayBuffer; + // 1 - mono, 2 - stereo + channelCount: number; + // Number of samples per second, e.g. 44100Hz + sampleRate: number; +}; + +type WriteAudioDataParams = { + arrayBuffer: ArrayBuffer; + dataByChannel: Float32Array[]; +}; + +export type TranscoderOptions = { + mimeType: string; +}; + +/** + * The `TranscoderService` is used to transcibe audio recording to a format that's supported by all major browsers. The SDK uses this to create voice messages. + * + * If you want to use your own transcoder you can provide a `customTranscoder`. + */ +@Injectable({ providedIn: NgModule }) +export class TranscoderService { + config: TranscoderConfig = { + sampleRate: 16000, + }; + customTranscoder?: (blob: Blob, options: TranscoderOptions) => Promise; + constructor() {} + + /** + * The default transcoder will leave audio/mp4 files as is, and transcode webm files to wav. If you want to customize this, you can provide your own transcoder using the `customTranscoder` field + * @param blob + * @param options + * @returns the transcoded file + */ + async transcode(blob: Blob, options: TranscoderOptions) { + if (this.customTranscoder) { + return this.customTranscoder(blob, options); + } + if (options.mimeType === 'audio/mp4') { + return blob; + } + const audioBuffer = await this.renderAudio( + await this.toAudioBuffer(blob), + this.config.sampleRate + ); + const numberOfSamples = audioBuffer.duration * this.config.sampleRate; + const fileSizeBytes = + numberOfSamples * audioBuffer.numberOfChannels * BYTES_PER_SAMPLE + + WAV_HEADER_LENGTH_BYTES; + + const arrayBuffer = new ArrayBuffer(fileSizeBytes); + this.writeWavHeader({ + arrayBuffer, + channelCount: audioBuffer.numberOfChannels, + sampleRate: this.config.sampleRate, + }); + this.writeWavAudioData({ + arrayBuffer, + dataByChannel: this.splitDataByChannel(audioBuffer), + }); + return new Blob([arrayBuffer], { type: 'audio/wav' }); + } + + protected async renderAudio(audioBuffer: AudioBuffer, sampleRate: number) { + const offlineAudioCtx = new OfflineAudioContext( + audioBuffer.numberOfChannels, + audioBuffer.duration * sampleRate, + sampleRate + ); + const source = offlineAudioCtx.createBufferSource(); + source.buffer = audioBuffer; + source.connect(offlineAudioCtx.destination); + source.start(); + + return await offlineAudioCtx.startRendering(); + } + + protected async toAudioBuffer(blob: Blob) { + const audioCtx = new AudioContext(); + + const arrayBuffer = await readBlobAsArrayBuffer(blob); + const decodedData = await audioCtx.decodeAudioData(arrayBuffer); + if (audioCtx.state !== 'closed') await audioCtx.close(); + return decodedData; + } + + protected writeWavAudioData({ + arrayBuffer, + dataByChannel, + }: WriteAudioDataParams) { + const dataView = new DataView(arrayBuffer); + const channelCount = dataByChannel.length; + + dataByChannel.forEach((channelData, channelIndex) => { + let writeOffset = WAV_HEADER_LENGTH_BYTES + channelCount * channelIndex; + + channelData.forEach((float32Value) => { + dataView.setInt16( + writeOffset, + float32Value < 0 + ? Math.max(-1, float32Value) * 32768 + : Math.min(1, float32Value) * 32767, + true + ); + writeOffset += channelCount * BYTES_PER_SAMPLE; + }); + }); + } + + protected writeWavHeader({ + arrayBuffer, + channelCount, + sampleRate, + }: WriteWaveHeaderParams) { + const byteRate = sampleRate * channelCount * BYTES_PER_SAMPLE; // bytes/sec + const blockAlign = channelCount * BYTES_PER_SAMPLE; + + const dataView = new DataView(arrayBuffer); + /* + * The maximum size of a RIFF file is 4294967295 bytes and since the header takes up 44 bytes there are 4294967251 bytes left for the + * data chunk. + */ + const dataChunkSize = Math.min( + dataView.byteLength - WAV_HEADER_LENGTH_BYTES, + RIFF_FILE_MAX_BYTES - WAV_HEADER_LENGTH_BYTES + ); + + dataView.setUint32(HEADER.CHUNK_ID.offset, HEADER.CHUNK_ID.value); // "RIFF" + dataView.setUint32( + HEADER.CHUNK_SIZE.offset, + arrayBuffer.byteLength - 8, + true + ); // adjustment for the first two headers - chunk id + file size + dataView.setUint32(HEADER.FILE_FORMAT.offset, HEADER.FILE_FORMAT.value); // "WAVE" + + dataView.setUint32(HEADER.SUBCHUNK1_ID.offset, HEADER.SUBCHUNK1_ID.value); // "fmt " + dataView.setUint32( + HEADER.SUBCHUNK1_SIZE.offset, + HEADER.SUBCHUNK1_SIZE.value, + true + ); + dataView.setUint16( + HEADER.AUDIO_FORMAT.offset, + HEADER.AUDIO_FORMAT.value, + true + ); + dataView.setUint16(HEADER.CHANNEL_COUNT.offset, channelCount, true); + dataView.setUint32(HEADER.SAMPLE_RATE.offset, sampleRate, true); + dataView.setUint32(HEADER.BYTE_RATE.offset, byteRate, true); + dataView.setUint16(HEADER.BLOCK_ALIGN.offset, blockAlign, true); + dataView.setUint16( + HEADER.BITS_PER_SAMPLE.offset, + HEADER.BITS_PER_SAMPLE.value, + true + ); + + dataView.setUint32(HEADER.SUBCHUNK2_ID.offset, HEADER.SUBCHUNK2_ID.value); // "data" + dataView.setUint32(HEADER.SUBCHUNK2_SIZE.offset, dataChunkSize, true); + } + + protected splitDataByChannel = (audioBuffer: AudioBuffer) => + Array.from({ length: audioBuffer.numberOfChannels }, (_, i) => + audioBuffer.getChannelData(i) + ); +} diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.html b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.html new file mode 100644 index 00000000..5b2da986 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.html @@ -0,0 +1,17 @@ +
+ {{ formattedDuration }} +
+
+
+
+
+
diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.spec.ts b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.spec.ts new file mode 100644 index 00000000..a3bf4c7c --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VoiceRecorderWavebarComponent } from './voice-recorder-wavebar.component'; + +describe('VoiceRecorderWavebarComponent', () => { + let component: VoiceRecorderWavebarComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [VoiceRecorderWavebarComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(VoiceRecorderWavebarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.ts b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.ts new file mode 100644 index 00000000..d2f95dbc --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder-wavebar/voice-recorder-wavebar.component.ts @@ -0,0 +1,44 @@ +import { Component, OnDestroy } from '@angular/core'; +import { AmplitudeRecorderService } from '../amplitude-recorder.service'; +import { Observable } from 'rxjs'; +import { AudioRecorderService } from '../audio-recorder.service'; +import { formatDuration } from '../../format-duration'; + +/** + * The `VoiceRecorderWavebarComponent` displays the amplitudes of the recording while the recoding is in progress + */ +@Component({ + selector: 'stream-voice-recorder-wavebar', + templateUrl: './voice-recorder-wavebar.component.html', + styles: [], +}) +export class VoiceRecorderWavebarComponent implements OnDestroy { + amplitudes$: Observable; + formattedDuration: string; + durationComputeInterval: ReturnType; + isLongerThanOneHour = false; + + constructor( + private amplitudeRecorder: AmplitudeRecorderService, + private audioRecorder: AudioRecorderService + ) { + this.amplitudes$ = this.amplitudeRecorder.amplitudes$; + this.formattedDuration = formatDuration( + this.audioRecorder.durationMs / 1000 + ); + this.durationComputeInterval = setInterval(() => { + this.isLongerThanOneHour = this.audioRecorder.durationMs / 1000 > 3600; + this.formattedDuration = formatDuration( + this.audioRecorder.durationMs / 1000 + ); + }, 1000); + } + + trackByIndex(i: number) { + return i; + } + + ngOnDestroy(): void { + clearInterval(this.durationComputeInterval); + } +} diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.html b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.html new file mode 100644 index 00000000..8ef59d53 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.html @@ -0,0 +1,74 @@ +
+
+ + + + + + + + + + + + + +
+
diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.spec.ts b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.spec.ts new file mode 100644 index 00000000..96c17062 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { VoiceRecorderComponent } from './voice-recorder.component'; +import { VoiceRecorderModule } from './voice-recorder.module'; + +describe('VoiceRecorderComponent', () => { + let component: VoiceRecorderComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [VoiceRecorderModule], + }).compileComponents(); + + fixture = TestBed.createComponent(VoiceRecorderComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.ts b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.ts new file mode 100644 index 00000000..efa3f069 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.component.ts @@ -0,0 +1,89 @@ +import { + Component, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, +} from '@angular/core'; +import { AudioRecorderService } from './audio-recorder.service'; +import { MediaRecordingState } from './media-recorder'; +import { Subscription } from 'rxjs'; +import { AudioRecording } from '../types'; +import { VoiceRecorderService } from '../message-input/voice-recorder.service'; + +/** + * The `VoiceRecorderComponent` makes it possible to record audio, and then upload it as a voice recording attachment + */ +@Component({ + selector: 'stream-voice-recorder', + templateUrl: './voice-recorder.component.html', + styles: [], +}) +export class VoiceRecorderComponent implements OnInit, OnDestroy, OnChanges { + @Input() voiceRecorderService?: VoiceRecorderService; + recordState: MediaRecordingState = MediaRecordingState.STOPPED; + isLoading = false; + recording?: AudioRecording; + readonly MediaRecordingState = MediaRecordingState; + private subscriptions: Subscription[] = []; + private isVisibleSubscription?: Subscription; + + constructor(public readonly recorder: AudioRecorderService) {} + + ngOnInit(): void { + this.subscriptions.push( + this.recorder.recordingState$.subscribe((s) => { + this.recordState = s; + if (s === MediaRecordingState.ERROR) { + this.voiceRecorderService?.isRecorderVisible$.next(false); + } + }) + ); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.voiceRecorderService && this.voiceRecorderService) { + this.isVisibleSubscription = + this.voiceRecorderService.isRecorderVisible$.subscribe((isVisible) => { + if (isVisible) { + this.recording = undefined; + void this.recorder.start(); + } else { + this.isLoading = false; + } + }); + } else { + this.isVisibleSubscription?.unsubscribe(); + } + } + + ngOnDestroy(): void { + this.subscriptions.forEach((s) => s.unsubscribe()); + } + + cancel() { + void this.recorder.stop({ cancel: true }); + this.voiceRecorderService?.isRecorderVisible$.next(false); + } + + async stop() { + this.recording = await this.recorder.stop(); + } + + pause() { + this.recorder.pause(); + } + + resume() { + this.recorder.resume(); + } + + uploadRecording() { + if (!this.recording) { + return; + } + this.isLoading = true; + this.voiceRecorderService?.recording$.next(this.recording); + } +} diff --git a/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.module.ts b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.module.ts new file mode 100644 index 00000000..5ca07c91 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recorder/voice-recorder.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { VoiceRecorderComponent } from './voice-recorder.component'; +import { VoiceRecordingModule } from '../voice-recording/voice-recording.module'; +import { IconModule } from '../icon/icon.module'; +import { TranslateModule } from '@ngx-translate/core'; +import { AudioRecorderService } from './audio-recorder.service'; +import { TranscoderService } from './transcoder.service'; +import { AmplitudeRecorderService } from './amplitude-recorder.service'; +import { VoiceRecorderWavebarComponent } from './voice-recorder-wavebar/voice-recorder-wavebar.component'; + +@NgModule({ + declarations: [VoiceRecorderComponent, VoiceRecorderWavebarComponent], + imports: [CommonModule, VoiceRecordingModule, IconModule, TranslateModule], + exports: [VoiceRecorderComponent, VoiceRecorderWavebarComponent], + providers: [ + AudioRecorderService, + TranscoderService, + AmplitudeRecorderService, + ], +}) +export class VoiceRecorderModule {} diff --git a/projects/stream-chat-angular/src/lib/voice-recording/voice-recording-wavebar/voice-recording-wavebar.component.ts b/projects/stream-chat-angular/src/lib/voice-recording/voice-recording-wavebar/voice-recording-wavebar.component.ts index 7d022d9b..e9b8f60c 100644 --- a/projects/stream-chat-angular/src/lib/voice-recording/voice-recording-wavebar/voice-recording-wavebar.component.ts +++ b/projects/stream-chat-angular/src/lib/voice-recording/voice-recording-wavebar/voice-recording-wavebar.component.ts @@ -10,6 +10,7 @@ import { SimpleChanges, ViewChild, } from '@angular/core'; +import { resampleWaveForm } from '../../wave-form-sampler'; /** * This component can be used to visualize the wave bar of a voice recording @@ -57,10 +58,10 @@ export class VoiceRecordingWavebarComponent ngOnChanges(changes: SimpleChanges): void { if (changes.waveFormData) { - this.resampledWaveFormData = - this.waveFormData.length > this.sampleSize - ? this.downsample() - : this.upsample(); + this.resampledWaveFormData = resampleWaveForm( + this.waveFormData, + this.sampleSize + ); } if (changes.audioElement) { this.ngZone.runOutsideAngular(() => { @@ -124,10 +125,10 @@ export class VoiceRecordingWavebarComponent ) { this.ngZone.run(() => { this.sampleSize = sampleSize; - this.resampledWaveFormData = - this.waveFormData.length > this.sampleSize - ? this.downsample() - : this.upsample(); + this.resampledWaveFormData = resampleWaveForm( + this.waveFormData, + this.sampleSize + ); if (this.isViewInited) { this.cdRef.detectChanges(); } @@ -135,132 +136,4 @@ export class VoiceRecordingWavebarComponent } } } - - private downsample() { - if (this.waveFormData.length <= this.sampleSize) { - return this.waveFormData; - } - - if (this.sampleSize === 1) return [this.mean(this.waveFormData)]; - - const result: number[] = []; - // bucket size adjusted due to the fact that the first and the last item in the original data array is kept in target output - const bucketSize = (this.waveFormData.length - 2) / (this.sampleSize - 2); - let lastSelectedPointIndex = 0; - result.push(this.waveFormData[lastSelectedPointIndex]); // Always add the first point - let maxAreaPoint, maxArea, triangleArea; - - for ( - let bucketIndex = 1; - bucketIndex < this.sampleSize - 1; - bucketIndex++ - ) { - const previousBucketRefPoint = this.waveFormData[lastSelectedPointIndex]; - const nextBucketMean = this.getNextBucketMean( - this.waveFormData, - bucketIndex, - bucketSize - ); - - const currentBucketStartIndex = - Math.floor((bucketIndex - 1) * bucketSize) + 1; - const nextBucketStartIndex = Math.floor(bucketIndex * bucketSize) + 1; - const countUnitsBetweenAtoC = - 1 + nextBucketStartIndex - currentBucketStartIndex; - - maxArea = triangleArea = -1; - - for ( - let currentPointIndex = currentBucketStartIndex; - currentPointIndex < nextBucketStartIndex; - currentPointIndex++ - ) { - const countUnitsBetweenAtoB = - Math.abs(currentPointIndex - currentBucketStartIndex) + 1; - const countUnitsBetweenBtoC = - countUnitsBetweenAtoC - countUnitsBetweenAtoB; - const currentPointValue = this.waveFormData[currentPointIndex]; - - triangleArea = this.triangleAreaHeron( - this.triangleBase( - Math.abs(previousBucketRefPoint - currentPointValue), - countUnitsBetweenAtoB - ), - this.triangleBase( - Math.abs(currentPointValue - nextBucketMean), - countUnitsBetweenBtoC - ), - this.triangleBase( - Math.abs(previousBucketRefPoint - nextBucketMean), - countUnitsBetweenAtoC - ) - ); - - if (triangleArea > maxArea) { - maxArea = triangleArea; - maxAreaPoint = this.waveFormData[currentPointIndex]; - lastSelectedPointIndex = currentPointIndex; - } - } - - if (typeof maxAreaPoint !== 'undefined') result.push(maxAreaPoint); - } - - result.push(this.waveFormData[this.waveFormData.length - 1]); // Always add the last point - - return result; - } - - private upsample = () => { - if (this.sampleSize === this.waveFormData.length) return this.waveFormData; - - // eslint-disable-next-line prefer-const - let [bucketSize, remainder] = this.divMod( - this.sampleSize, - this.waveFormData.length - ); - const result: number[] = []; - - for (let i = 0; i < this.waveFormData.length; i++) { - const extra = remainder && remainder-- ? 1 : 0; - result.push( - ...Array(bucketSize + extra).fill(this.waveFormData[i]) - ); - } - return result; - }; - - private getNextBucketMean = ( - data: number[], - currentBucketIndex: number, - bucketSize: number - ) => { - const nextBucketStartIndex = - Math.floor(currentBucketIndex * bucketSize) + 1; - let nextNextBucketStartIndex = - Math.floor((currentBucketIndex + 1) * bucketSize) + 1; - nextNextBucketStartIndex = - nextNextBucketStartIndex < data.length - ? nextNextBucketStartIndex - : data.length; - - return this.mean( - data.slice(nextBucketStartIndex, nextNextBucketStartIndex) - ); - }; - - private mean = (values: number[]) => - values.reduce((acc, value) => acc + value, 0) / values.length; - - private triangleAreaHeron = (a: number, b: number, c: number) => { - const s = (a + b + c) / 2; - return Math.sqrt(s * (s - a) * (s - b) * (s - c)); - }; - - private triangleBase = (a: number, b: number) => - Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2)); - - private divMod = (num: number, divisor: number) => { - return [Math.floor(num / divisor), num % divisor]; - }; } diff --git a/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.ts b/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.ts index e571c0ae..d7e66205 100644 --- a/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.ts +++ b/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.component.ts @@ -12,6 +12,7 @@ import { import { Attachment } from 'stream-chat'; import { DefaultStreamChatGenerics } from '../types'; import prettybytes from 'pretty-bytes'; +import { formatDuration } from '../format-duration'; /** * This component can be used to display an attachment with type `voiceRecording`. The component allows playing the attachment inside the browser. @@ -95,18 +96,7 @@ export class VoiceRecordingComponent implements OnChanges, AfterViewInit { } private getFormattedDuration(duration?: number) { - if (duration === undefined || duration <= 0) return '00:00'; - - const [hours, hoursLeftover] = this.divMod(duration, 3600); - const [minutes, seconds] = this.divMod(hoursLeftover, 60); - const roundedSeconds = Math.ceil(seconds); - - const prependHrsZero = hours.toString().length === 1 ? '0' : ''; - const prependMinZero = minutes.toString().length === 1 ? '0' : ''; - const prependSecZero = roundedSeconds.toString().length === 1 ? '0' : ''; - const minSec = `${prependMinZero}${minutes}:${prependSecZero}${roundedSeconds}`; - - return hours ? `${prependHrsZero}${hours}:` + minSec : minSec; + return formatDuration(duration); } private getFileSize() { @@ -118,8 +108,4 @@ export class VoiceRecordingComponent implements OnChanges, AfterViewInit { } return prettybytes(Number(this.attachment.file_size || 0)); } - - private divMod(num: number, divisor: number) { - return [Math.floor(num / divisor), num % divisor]; - } } diff --git a/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.module.ts b/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.module.ts new file mode 100644 index 00000000..dca95b42 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/voice-recording/voice-recording.module.ts @@ -0,0 +1,13 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { VoiceRecordingComponent } from './voice-recording.component'; +import { VoiceRecordingWavebarComponent } from './voice-recording-wavebar/voice-recording-wavebar.component'; +import { IconModule } from '../icon/icon.module'; +import { TranslateModule } from '@ngx-translate/core'; + +@NgModule({ + declarations: [VoiceRecordingComponent, VoiceRecordingWavebarComponent], + imports: [CommonModule, IconModule, TranslateModule], + exports: [VoiceRecordingComponent, VoiceRecordingWavebarComponent], +}) +export class VoiceRecordingModule {} diff --git a/projects/stream-chat-angular/src/lib/wave-form-sampler.ts b/projects/stream-chat-angular/src/lib/wave-form-sampler.ts new file mode 100644 index 00000000..a8035f37 --- /dev/null +++ b/projects/stream-chat-angular/src/lib/wave-form-sampler.ts @@ -0,0 +1,124 @@ +export const resampleWaveForm = ( + waveFormData: number[], + sampleSize: number +) => { + return waveFormData.length > sampleSize + ? downsample(waveFormData, sampleSize) + : upsample(waveFormData, sampleSize); +}; + +const downsample = (waveFormData: number[], sampleSize: number) => { + if (waveFormData.length <= sampleSize) { + return waveFormData; + } + + if (sampleSize === 1) return [mean(waveFormData)]; + + const result: number[] = []; + // bucket size adjusted due to the fact that the first and the last item in the original data array is kept in target output + const bucketSize = (waveFormData.length - 2) / (sampleSize - 2); + let lastSelectedPointIndex = 0; + result.push(waveFormData[lastSelectedPointIndex]); // Always add the first point + let maxAreaPoint, maxArea, triangleArea; + + for (let bucketIndex = 1; bucketIndex < sampleSize - 1; bucketIndex++) { + const previousBucketRefPoint = waveFormData[lastSelectedPointIndex]; + const nextBucketMean = getNextBucketMean( + waveFormData, + bucketIndex, + bucketSize + ); + + const currentBucketStartIndex = + Math.floor((bucketIndex - 1) * bucketSize) + 1; + const nextBucketStartIndex = Math.floor(bucketIndex * bucketSize) + 1; + const countUnitsBetweenAtoC = + 1 + nextBucketStartIndex - currentBucketStartIndex; + + maxArea = triangleArea = -1; + + for ( + let currentPointIndex = currentBucketStartIndex; + currentPointIndex < nextBucketStartIndex; + currentPointIndex++ + ) { + const countUnitsBetweenAtoB = + Math.abs(currentPointIndex - currentBucketStartIndex) + 1; + const countUnitsBetweenBtoC = + countUnitsBetweenAtoC - countUnitsBetweenAtoB; + const currentPointValue = waveFormData[currentPointIndex]; + + triangleArea = triangleAreaHeron( + triangleBase( + Math.abs(previousBucketRefPoint - currentPointValue), + countUnitsBetweenAtoB + ), + triangleBase( + Math.abs(currentPointValue - nextBucketMean), + countUnitsBetweenBtoC + ), + triangleBase( + Math.abs(previousBucketRefPoint - nextBucketMean), + countUnitsBetweenAtoC + ) + ); + + if (triangleArea > maxArea) { + maxArea = triangleArea; + maxAreaPoint = waveFormData[currentPointIndex]; + lastSelectedPointIndex = currentPointIndex; + } + } + + if (typeof maxAreaPoint !== 'undefined') result.push(maxAreaPoint); + } + + result.push(waveFormData[waveFormData.length - 1]); // Always add the last point + + return result; +}; + +const upsample = (waveFormData: number[], sampleSize: number) => { + if (sampleSize === waveFormData.length) return waveFormData; + + // eslint-disable-next-line prefer-const + let [bucketSize, remainder] = divMod(sampleSize, waveFormData.length); + const result: number[] = []; + + for (let i = 0; i < waveFormData.length; i++) { + const extra = remainder && remainder-- ? 1 : 0; + result.push(...Array(bucketSize + extra).fill(waveFormData[i])); + } + return result; +}; + +const getNextBucketMean = ( + data: number[], + currentBucketIndex: number, + bucketSize: number +) => { + const nextBucketStartIndex = Math.floor(currentBucketIndex * bucketSize) + 1; + let nextNextBucketStartIndex = + Math.floor((currentBucketIndex + 1) * bucketSize) + 1; + nextNextBucketStartIndex = + nextNextBucketStartIndex < data.length + ? nextNextBucketStartIndex + : data.length; + + return mean(data.slice(nextBucketStartIndex, nextNextBucketStartIndex)); +}; + +const mean = (values: number[]) => + values.reduce((acc, value) => acc + value, 0) / values.length; + +const triangleAreaHeron = (a: number, b: number, c: number) => { + const s = (a + b + c) / 2; + return Math.sqrt(s * (s - a) * (s - b) * (s - c)); +}; + +const triangleBase = (a: number, b: number) => + Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2)); + +const divMod = (num: number, divisor: number) => { + return [Math.floor(num / divisor), num % divisor]; +}; diff --git a/projects/stream-chat-angular/src/public-api.ts b/projects/stream-chat-angular/src/public-api.ts index 5bc3c5b7..22eae390 100644 --- a/projects/stream-chat-angular/src/public-api.ts +++ b/projects/stream-chat-angular/src/public-api.ts @@ -11,9 +11,9 @@ export * from './lib/stream-i18n.service'; export * from './lib/avatar/avatar.component'; export * from './lib/avatar-placeholder/avatar-placeholder.component'; export * from './lib/icon/icon.component'; -export * from './lib/icon-placeholder/icon-placeholder.component'; -export * from './lib/loading-indicator/loading-indicator.component'; -export * from './lib/loading-indicator-placeholder/loading-indicator-placeholder.component'; +export * from './lib/icon/icon-placeholder/icon-placeholder.component'; +export * from './lib/icon/loading-indicator/loading-indicator.component'; +export * from './lib/icon/loading-indicator-placeholder/loading-indicator-placeholder.component'; export * from './lib/message-actions-box/message-actions-box.component'; export * from './lib/channel/channel.component'; export * from './lib/channel-header/channel-header.component'; @@ -43,7 +43,7 @@ export * from './lib/read-by'; export * from './lib/get-message-translation'; export * from './lib/get-channel-display-text'; export * from './lib/is-image-attachment'; -export * from './lib/is-image-file'; +export * from './lib/file-utils'; export * from './lib/message-preview'; export * from './lib/notification.service'; export * from './lib/transliteration.service'; @@ -67,3 +67,15 @@ export * from './lib/virtualized-list.service'; export * from './lib/virtualized-message-list.service'; export * from './lib/user-list/user-list.component'; export * from './lib/paginated-list/paginated-list.component'; +export * from './lib/is-safari'; +export * from './lib/voice-recorder/voice-recorder.module'; +export * from './lib/voice-recorder/amplitude-recorder.service'; +export * from './lib/voice-recorder/audio-recorder.service'; +export * from './lib/voice-recorder/media-recorder'; +export * from './lib/voice-recorder/transcoder.service'; +export * from './lib/voice-recorder/voice-recorder.component'; +export * from './lib/voice-recording/voice-recording.module'; +export * from './lib/icon/icon.module'; +export * from './lib/voice-recorder//voice-recorder-wavebar/voice-recorder-wavebar.component'; +export * from './lib/format-duration'; +export * from './lib/message-input/voice-recorder.service';