diff options
author | Marco Ambrosini <marcoambrosini@pm.me> | 2021-05-17 15:34:08 +0300 |
---|---|---|
committer | Marco Ambrosini <marcoambrosini@pm.me> | 2021-06-11 10:24:43 +0300 |
commit | b7ec08402a6093db1d03d26cec7b2b105bfae2f1 (patch) | |
tree | bddd36eb3f6fb26f4f8cd28da125695cd61d37b0 /src/components/NewMessageForm | |
parent | ebff88cd28597b8933eb0e5970a6d4132fa5263e (diff) |
Add ability to record audio files
Signed-off-by: Marco Ambrosini <marcoambrosini@pm.me>
Diffstat (limited to 'src/components/NewMessageForm')
3 files changed, 310 insertions, 9 deletions
diff --git a/src/components/NewMessageForm/AdvancedInput/AdvancedInput.vue b/src/components/NewMessageForm/AdvancedInput/AdvancedInput.vue index e72d2febc..91df6c2f8 100644 --- a/src/components/NewMessageForm/AdvancedInput/AdvancedInput.vue +++ b/src/components/NewMessageForm/AdvancedInput/AdvancedInput.vue @@ -216,7 +216,7 @@ export default { watch: { text(text) { this.$nextTick(() => { - this.$emit('update:contentEditable', this.$refs.contentEditable.cloneNode(true)) + this.$('update:contentEditable', this.$refs.contentEditable.cloneNode(true)) }) this.$emit('update:value', text) @@ -235,6 +235,7 @@ export default { } }, }, + mounted() { this.focusInput() /** @@ -245,10 +246,12 @@ export default { this.atWhoPanelExtraClasses = 'talk candidate-mentions' }, + beforeDestroy() { EventBus.$off('routeChange', this.focusInput) EventBus.$off('focusChatInput', this.focusInput) }, + methods: { onBlur() { // requires a short delay to avoid blocking click event handlers diff --git a/src/components/NewMessageForm/AudioRecorder/AudioRecorder.vue b/src/components/NewMessageForm/AudioRecorder/AudioRecorder.vue new file mode 100644 index 000000000..5a4119e95 --- /dev/null +++ b/src/components/NewMessageForm/AudioRecorder/AudioRecorder.vue @@ -0,0 +1,277 @@ +<!-- + - @copyright Copyright (c) 2021 Marco Ambrosini <marcoambrosini@pm.me> + - + - @author Marco Ambrosini <marcoambrosini@pm.me> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. +--> + +<template> + <div + class="audio-recorder"> + <button + v-if="!isRecording" + class="audio-recorder__trigger nc-button nc-button__main" + @click="start"> + <Microphone + :size="16" + title="" + decorative /> + </button> + <div v-else class="wrapper"> + <button + class="audio-recorder__stop nc-button nc-button__main" + @click="abortRecording"> + <Close + :size="16" + title="" + decorative /> + </button> + <div class="audio-recorder__info"> + <div class="recording-indicator fadeOutIn" /> + <span + class="time"> + {{ parsedRecordTime }}</span> + </div> + <button + class="audio-recorder__trigger nc-button nc-button__main" + :class="{'audio-recorder__trigger--recording': isRecording}" + @click="stop"> + <Check + :size="16" + title="" + decorative /> + </button> + </div> + </div> +</template> + +<script> +import Microphone from 'vue-material-design-icons/Microphone' +import Close from 'vue-material-design-icons/Close' +import Check from 'vue-material-design-icons/Check' + +import { mediaDevicesManager } from '../../../utils/webrtc/index' + +export default { + name: 'AudioRecorder', + + components: { + Microphone, + Close, + Check, + }, + + data() { + return { + // The audio stream object + audioStream: null, + // The media recorder which generate the recorded chunks + mediaRecorder: null, + // The chunks array + chunks: [], + // The final audio file blob + blob: null, + // The blob url + URL: '', + // Switched to true if the recording is aborted + aborted: false, + // recordTimer + recordTimer: null, + // the record timer + recordTime: { + minutes: 0, + seconds: 0, + }, + } + }, + + computed: { + // Recording state of the mediaRecorder + isRecording() { + if (this.mediaRecorder) { + return this.mediaRecorder.state === 'recording' + } else { + return false + } + }, + + parsedRecordTime() { + const seconds = this.recordTime.seconds.toString().length === 2 ? this.recordTime.seconds : `0${this.recordTime.seconds}` + const minutes = this.recordTime.minutes.toString().length === 2 ? this.recordTime.minutes : `0${this.recordTime.minutes}` + return `${minutes}:${seconds}` + }, + }, + + watch: { + + isRecording(newValue) { + console.debug('isRecording', newValue) + }, + }, + + methods: { + /** + * Initialize the media stream and start capturing the audio + */ + async start() { + try { + // Create new audio stream + this.audioStream = await mediaDevicesManager.getUserMedia({ + audio: true, + }) + // Create a mediarecorder to capture the stream + this.mediaRecorder = new MediaRecorder(this.audioStream) + // Add event handler to ondataAvailable + this.mediaRecorder.ondataavailable = (e) => { + this.chunks.push(e.data) + } + // Add event handler to onstop + this.mediaRecorder.onstop = this.generateFile + // Start the recording + this.mediaRecorder.start() + // Start the timer + this.recordTimer = setInterval(() => { + if (this.recordTime.seconds === 59) { + this.recordTime.minutes++ + this.recordTime.seconds = 0 + } + this.recordTime.seconds++ + }, 1000) + // Forward an event to let the parent NewMessageForm component + // that there's an undergoing recording operation + this.$emit('recording', true) + console.debug(this.mediaRecorder.state) + } catch (exception) { + console.debug(exception) + } + }, + + /** + * Stop the mediaRecorder + */ + stop() { + this.mediaRecorder.stop() + clearInterval(this.recordTimer) + this.$emit('recording', false) + }, + + /** + * Generate the file + */ + generateFile() { + if (!this.aborted) { + this.blob = new Blob(this.chunks, { 'type': 'audio/mpeg-3' }) + // Convert blob to file + const audioFile = new File([this.blob], `${Date.now()}.mp3`) + this.$emit('audioFile', audioFile) + this.$emit('recording', false) + this.URL = window.URL.createObjectURL(this.blob) + } + this.resetComponentData() + }, + + /** + * Aborts the recording operation. + */ + abortRecording() { + this.aborted = true + this.stop() + }, + + /** + * Resets this component to its initial state + */ + resetComponentData() { + this.audioStream = null + this.mediaRecorder = null + this.chunks = [] + this.blob = null + this.aborted = false + this.isAudiorecorderActive = false + this.recordTime = { + minutes: 0, + seconds: 0, + } + }, + }, +} +</script> + +<style lang="scss" scoped> + +@import '../../../assets/buttons'; + +.audio-recorder { + display: flex; + // Audio record button + &__trigger { + &--recording { + background-color: var(--color-success) !important; + opacity: .8; + color: white; + &:hover, + &:focus { + opacity: 1; + } + } + } + + &__stop { + margin-left: 4px; + background-color: var(--color-error) !important; + color: white; + opacity: .8; + &:hover, + &:focus { + opacity: 1; + } + } + + &__info { + width: 86px; + display: flex; + justify-content: center; + align-items: center; + .time { + flex: 0 0 50px; + } + .recording-indicator { + width: 16px; + height: 16px; + flex: 0 0 16px; + border-radius: 8px; + background-color: var(--color-error); + margin: 8px; + } + } +} + +.wrapper { + display: flex; +} + +@keyframes fadeOutIn { + 0% { opacity:1; } + 50% { opacity:.3; } + 100% { opacity:1; } +} + +.fadeOutIn { + animation: fadeOutIn 3s infinite; +} + +</style> diff --git a/src/components/NewMessageForm/NewMessageForm.vue b/src/components/NewMessageForm/NewMessageForm.vue index 3abc8f56d..0cf2b45ec 100644 --- a/src/components/NewMessageForm/NewMessageForm.vue +++ b/src/components/NewMessageForm/NewMessageForm.vue @@ -103,7 +103,14 @@ @submit="handleSubmit" @files-pasted="handlePastedFiles" /> </div> + + <AudioRecorder + v-if="!hasText" + @recording="handleRecording" + @audioFile="handleAudioFile" /> + <button + v-if="hasText" :disabled="disabled" type="submit" :aria-label="t('spreed', 'Send message')" @@ -133,6 +140,7 @@ import { CONVERSATION } from '../../constants' import Paperclip from 'vue-material-design-icons/Paperclip' import EmoticonOutline from 'vue-material-design-icons/EmoticonOutline' import Send from 'vue-material-design-icons/Send' +import AudioRecorder from './AudioRecorder/AudioRecorder' const picker = getFilePickerBuilder(t('spreed', 'File to share')) .setMultiSelect(false) @@ -152,6 +160,7 @@ export default { EmojiPicker, EmoticonOutline, Send, + AudioRecorder, }, props: { @@ -160,13 +169,17 @@ export default { required: true, }, }, + data: function() { return { text: '', parsedText: '', conversationIsFirstInList: false, + // True when the audiorecorder component is recording + isRecordingAudio: false, } }, + computed: { /** * The current conversation token @@ -188,7 +201,7 @@ export default { }, disabled() { - return this.isReadOnly || !this.currentConversationIsJoined + return this.isReadOnly || !this.currentConversationIsJoined || this.isRecordingAudio }, placeholderText() { @@ -227,6 +240,10 @@ export default { currentConversationIsJoined() { return this.$store.getters.currentConversationIsJoined }, + + hasText() { + return this.text !== '' + }, }, watch: { @@ -255,6 +272,7 @@ export default { EventBus.$on('uploadStart', this.handleUploadStart) EventBus.$on('retryMessage', this.handleRetryMessage) this.text = this.$store.getters.currentMessageInput(this.token) || '' + // this.startRecording() }, beforeDestroy() { @@ -442,6 +460,14 @@ export default { range.setStartAfter(emojiTextNode) }, + + handleAudioFile(payload) { + this.handleFiles([payload]) + }, + + handleRecording(payload) { + this.isRecordingAudio = payload + }, }, } </script> @@ -454,6 +480,7 @@ export default { justify-content: center; padding: 12px 0; border-top: 1px solid var(--color-border); + height: 69px; &--chatScrolledToBottom { border-top: none; } @@ -467,7 +494,7 @@ export default { display: flex; position:relative; flex: 0 1 650px; - margin: 0 48px; + margin: 0 4px; &__emoji-picker { position: absolute; left: 6px; @@ -489,12 +516,6 @@ export default { bottom: 0; } - &__send-button { - position: absolute; - right: -44px; - bottom: 0; - } - &__input { flex-grow: 1; max-height: $message-form-max-height; |