From 31a1eaf3db9c46a68d7ef0d1be3a17618d4487c7 Mon Sep 17 00:00:00 2001 From: 10G Meow <10gmeow@gmail.com> Date: Sat, 6 May 2023 14:03:15 +0300 Subject: [PATCH 01/19] send multiple files at once --- src/components/Chat.vue | 257 +++++++++++++++++++++++----------------- 1 file changed, 146 insertions(+), 111 deletions(-) diff --git a/src/components/Chat.vue b/src/components/Chat.vue index 74f7ec7..d242d4f 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -186,26 +186,38 @@ + accept="image/*, audio/*, video/*, .pdf" class="d-none" multiple/> -
- +
+ - - -
- file: {{ currentImageInputPath.name }} - - {{ currentImageInput.scaledDimensions.width }} x {{ currentImageInput.scaledDimensions.height }} - - {{ currentImageInput.dimensions.width }} x {{ currentImageInput.dimensions.height }} - - ({{ formatBytes(currentImageInput.scaledSize) }}) - ({{ formatBytes(currentImageInputPath.size) }}) - -
+ + +
{{ currentSendError }}
{{ currentSendProgress }}
@@ -338,8 +350,8 @@ export default { timelineWindowPaginating: false, scrollPosition: null, - currentImageInput: null, - currentImageInputPath: null, + currentImageInputs: null, + currentImageInputsPath: null, currentSendOperation: null, currentSendProgress: null, currentSendShowSendButton: true, @@ -423,6 +435,17 @@ export default { }, computed: { + isCurrentImageInputsPath() { + return Array.isArray(this.currentImageInputsPath) + }, + isCurrentImageInputsPathDialog: { + get() { + return this.isCurrentImageInputsPath + }, + set() { + this.currentImageInputsPath = null + } + }, chatContainer() { const container = this.$refs.chatContainer; if (this.useVoiceMode) { @@ -896,67 +919,73 @@ export default { this.$refs.attachment.click(); }, + optimizeImage(e,event,file) { + let currentImageInput = { + image: e.target.result, + dimensions: null, + }; + try { + currentImageInput.dimensions = sizeOf(dataUriToBuffer(e.target.result)); + + // Need to resize? + const w = currentImageInput.dimensions.width; + const h = currentImageInput.dimensions.height; + if (w > 640 || h > 640) { + var aspect = w / h; + var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed()); + var newHeight = parseInt((w > h ? 640 / aspect : 640).toFixed()); + var imageResize = new ImageResize({ + format: "png", + width: newWidth, + height: newHeight, + outputType: "blob", + }); + imageResize + .play(event.target) + .then((img) => { + Vue.set( + currentImageInput, + "scaled", + new File([img], file.name, { + type: img.type, + lastModified: Date.now(), + }) + ); + Vue.set(currentImageInput, "useScaled", true); + Vue.set(currentImageInput, "scaledSize", img.size); + Vue.set(currentImageInput, "scaledDimensions", { + width: newWidth, + height: newHeight, + }); + }) + .catch((err) => { + console.error("Resize failed:", err); + }); + } + } catch (error) { + console.error("Failed to get image dimensions: " + error); + } + return currentImageInput + }, + handleFileReader(event, file) { + if (file) { + var reader = new FileReader(); + reader.onload = (e) => { + this.currentSendShowSendButton = true; + if (file.type.startsWith("image/")) { + const currentImageInput = this.optimizeImage(e, event, file) + this.currentImageInputs = Array.isArray(this.currentImageInputs) ? [...this.currentImageInputs, currentImageInput] : [currentImageInput] + } + this.currentImageInputsPath = Array.isArray(this.currentImageInputsPath) ? [...this.currentImageInputsPath, file] : [file]; + }; + reader.readAsDataURL(file); + } + }, /** * Handle picked attachment */ handlePickedAttachment(event) { - if (event.target.files && event.target.files[0]) { - var reader = new FileReader(); - reader.onload = (e) => { - const file = event.target.files[0]; - this.currentSendShowSendButton = true; - if (file.type.startsWith("image/")) { - this.currentImageInput = { - image: e.target.result, - dimensions: null, - }; - try { - this.currentImageInput.dimensions = sizeOf(dataUriToBuffer(e.target.result)); - - // Need to resize? - const w = this.currentImageInput.dimensions.width; - const h = this.currentImageInput.dimensions.height; - if (w > 640 || h > 640) { - var aspect = w / h; - var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed()); - var newHeight = parseInt((w > h ? 640 / aspect : 640).toFixed()); - var imageResize = new ImageResize({ - format: "png", - width: newWidth, - height: newHeight, - outputType: "blob", - }); - imageResize - .play(event.target) - .then((img) => { - Vue.set( - this.currentImageInput, - "scaled", - new File([img], file.name, { - type: img.type, - lastModified: Date.now(), - }) - ); - Vue.set(this.currentImageInput, "useScaled", true); - Vue.set(this.currentImageInput, "scaledSize", img.size); - Vue.set(this.currentImageInput, "scaledDimensions", { - width: newWidth, - height: newHeight, - }); - }) - .catch((err) => { - console.error("Resize failed:", err); - }); - } - } catch (error) { - console.error("Failed to get image dimensions: " + error); - } - } - console.log(this.currentImageInput); - this.currentImageInputPath = file; - }; - reader.readAsDataURL(event.target.files[0]); - } + Object.values(event.target.files).forEach(file => this.handleFileReader(event, file)); }, showStickerPicker() { @@ -975,41 +1004,43 @@ export default { }); } }, - + sendImage(withText, inputFile) { + this.currentSendProgress = null; + this.currentSendOperation = util.sendImage( + this.$matrix.matrixClient, + this.roomId, + inputFile, + this.onUploadProgress + ); + this.currentSendOperation + .then(() => { + this.currentSendOperation = null; + this.currentImageInputs = null; + this.currentImageInputsPath = null; + this.currentSendProgress = null; + if (withText) { + this.sendMessage(withText); + } + }) + .catch((err) => { + if (err instanceof AbortError || err === "Abort") { + this.currentSendError = null; + } else { + this.currentSendError = err.LocaleString(); + } + this.currentSendOperation = null; + this.currentSendProgress = null; + }); + }, sendAttachment(withText) { this.$refs.attachment.value = null; - if (this.currentImageInputPath) { - var inputFile = this.currentImageInputPath; - if (this.currentImageInput && this.currentImageInput.scaled && this.currentImageInput.useScaled) { + if (this.isCurrentImageInputsPath) { + let inputFiles = this.currentImageInputsPath; + if (Array.isArray(this.currentImageInputs) && this.currentImageInputs.scaled && this.currentImageInputs.useScaled) { // Send scaled version of image instead! - inputFile = this.currentImageInput.scaled; + inputFiles = this.currentImageInputs.map(({scaled}) => scaled) } - this.currentSendProgress = null; - this.currentSendOperation = util.sendImage( - this.$matrix.matrixClient, - this.roomId, - inputFile, - this.onUploadProgress - ); - this.currentSendOperation - .then(() => { - this.currentSendOperation = null; - this.currentImageInput = null; - this.currentImageInputPath = null; - this.currentSendProgress = null; - if (withText) { - this.sendMessage(withText); - } - }) - .catch((err) => { - if (err instanceof AbortError || err === "Abort") { - this.currentSendError = null; - } else { - this.currentSendError = err.LocaleString(); - } - this.currentSendOperation = null; - this.currentSendProgress = null; - }); + inputFiles.forEach((inputFile) => this.sendImage(withText, inputFile)) } }, @@ -1019,8 +1050,8 @@ export default { this.currentSendOperation.abort(); } this.currentSendOperation = null; - this.currentImageInput = null; - this.currentImageInputPath = null; + this.currentImageInputs = null; + this.currentImageInputsPath = null; this.currentSendProgress = null; this.currentSendError = null; }, @@ -1399,8 +1430,12 @@ export default { }, onVoiceRecording(event) { + console.log('voice..') + console.log(event) + console.log(event.file) this.currentSendShowSendButton = false; - this.currentImageInputPath = event.file; + //this.currentImageInputsPath = event.file; + this.currentImageInputsPath = Array.isArray(this.currentImageInputsPath) ? [...this.currentImageInputsPath, event.file] : [event.file]; var text = undefined; if (this.currentInput && this.currentInput.length > 0) { text = this.currentInput; From 81808b3b74819f2229fecfd16f82e42e16ebef86 Mon Sep 17 00:00:00 2001 From: 10G Meow <10gmeow@gmail.com> Date: Thu, 18 May 2023 12:46:31 +0300 Subject: [PATCH 02/19] fix: popover icon and overlay in mobile device --- src/assets/css/chat.scss | 24 +++++++++++------------- src/components/ChatHeader.vue | 3 ++- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/assets/css/chat.scss b/src/assets/css/chat.scss index deb30e5..e58b88b 100644 --- a/src/assets/css/chat.scss +++ b/src/assets/css/chat.scss @@ -101,22 +101,17 @@ body { } .notification-alert { - display: inline-block; - background-color: $alert-bg-color; - width: 8px; - height: 8px; - border-radius: 4px; - margin-bottom: 2px; - position: relative; - overflow: visible; + .icon-circle { + color: $alert-bg-color; + } &.popup-open::after { - top: 20px; + top: 35px; color: #246bfd; } .missed-items-popup { position: absolute; bottom: -17px; - left: -20px; + left: -175px; transform: translateY(100%); background: #246bfd; border-radius: 8px; @@ -125,8 +120,10 @@ body { padding: 22px 18px 23px 18px; z-index: 300; user-select: none; + width: 300px; + justify-content: space-between; .text { - white-space: nowrap; + white-space: break-spaces; font-family: "Inter", sans-serif; font-style: normal; font-weight: 500; @@ -791,6 +788,7 @@ body { .room-name-inline { text-align: start; + min-width: 75px; } .room-name.no-upper { @@ -1286,7 +1284,7 @@ body { .option-warning { background: linear-gradient(0deg, #FFF3F3, #FFF3F3), #FFFBED; border-radius: 8px; - padding: 18px; + padding: 18px; font-family: 'Inter', sans-serif; font-style: normal; font-weight: 400; @@ -1294,7 +1292,7 @@ body { line-height: 17px; .v-icon { margin-right: 16px; - } + } } } diff --git a/src/components/ChatHeader.vue b/src/components/ChatHeader.vue index e523973..4660caa 100644 --- a/src/components/ChatHeader.vue +++ b/src/components/ChatHeader.vue @@ -19,6 +19,7 @@
$vuetify.icons.ic_dropdown
+ circle
@@ -144,7 +145,7 @@ export default { this.$matrix.invites.length > 0; }, notificationsText() { - const invitationCount = this.$matrix.invites.length + const invitationCount = this.$matrix.invites.length if (invitationCount > 0) { return this.$tc('room.invitations', invitationCount); } From 1282d6528c6a9cea6faab33fdc8b90466372b50a Mon Sep 17 00:00:00 2001 From: 10G Meow <10gmeow@gmail.com> Date: Thu, 18 May 2023 13:54:03 +0300 Subject: [PATCH 03/19] cleanup --- src/components/Chat.vue | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/components/Chat.vue b/src/components/Chat.vue index a551e1f..67e8f73 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -1472,11 +1472,7 @@ export default { }, onVoiceRecording(event) { - console.log('voice..') - console.log(event) - console.log(event.file) this.currentSendShowSendButton = false; - //this.currentImageInputsPath = event.file; this.currentImageInputsPath = Array.isArray(this.currentImageInputsPath) ? [...this.currentImageInputsPath, event.file] : [event.file]; var text = undefined; if (this.currentInput && this.currentInput.length > 0) { From 39b583d65ee4bcdbf1e13cb8f763e52f2e0dd618 Mon Sep 17 00:00:00 2001 From: 10G Meow <10gmeow@gmail.com> Date: Sat, 20 May 2023 11:31:28 +0300 Subject: [PATCH 04/19] Add translation to support EmojiPicker in all language --- src/assets/translations/en.json | 14 ++++++++++++++ src/components/Chat.vue | 18 ++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/assets/translations/en.json b/src/assets/translations/en.json index cc8c121..8437396 100644 --- a/src/assets/translations/en.json +++ b/src/assets/translations/en.json @@ -329,5 +329,19 @@ "fetched_n_of_total_events": "Fetched {count} of {total} events", "processed_n_of_total_events": "Processed media for {count} of {total} events", "export_filename": "Exported chat {date}" + }, + "emoji": { + "search": "Search...", + "categories": { + "activity": "Activity", + "flags": "Flags", + "foods": "Foods", + "frequently": "Frequently used", + "objects": "Objects", + "nature": "Nature", + "peoples": "Peoples", + "symbols": "Symbols", + "places": "Places" + } } } diff --git a/src/components/Chat.vue b/src/components/Chat.vue index c587fd1..ab5f466 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -225,7 +225,7 @@
- + @@ -393,7 +393,21 @@ export default { /** Calculated style for message operations. We position the "popup" at the selected message. */ opStyle: "", - isEmojiQuickReaction: true + isEmojiQuickReaction: true, + i18nEmoji: { + search: this.$t("emoji.search"), + categories: { + Activity: this.$t("emoji.categories.activity"), + Flags: this.$t("emoji.categories.flags"), + Foods: this.$t("emoji.categories.foods"), + Frequently: this.$t("emoji.categories.frequently"), + Objects: this.$t("emoji.categories.objects"), + Nature: this.$t("emoji.categories.nature"), + Peoples: this.$t("emoji.categories.peoples"), + Symbols: this.$t("emoji.categories.symbols"), + Places: this.$t("emoji.categories.places") + } + } }; }, From 3217065ce05549dbc6232ae35dd74700ca7d2547 Mon Sep 17 00:00:00 2001 From: N-Pex Date: Mon, 22 May 2023 15:46:50 +0200 Subject: [PATCH 05/19] Support the "m.login.registration_token" login flow --- README.md | 2 +- src/assets/config.json | 1 + src/assets/translations/en.json | 5 ++- src/components/InteractiveAuth.vue | 60 ++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d99478a..e100dcd 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The app loads runtime configutation from the server at "./config.json" and merge The following values can be set via the config file: * **logo** - An url or base64-encoded image data url that represents the app logotype. -* **accentColor** - The accent color of the app UI. +* **accentColor** - The accent color of the app UI. Use a HTML-style color value string, like "#ff0080". * **show_status_messages** - Whether to show only user joins/leaves and display name updates, or the full range of room status updates. Possible values are "never" (only the above), "moderators" (moderators will see all status updates) or "always" (everyone will see all status updates). Defaults to "always". diff --git a/src/assets/config.json b/src/assets/config.json index 488b681..bfbdea1 100644 --- a/src/assets/config.json +++ b/src/assets/config.json @@ -9,6 +9,7 @@ "productLink": "letsconvene.im", "defaultServer": "https://neo.keanu.im", "identityServer_unset": "", + "registrationToken_unset": "", "rtl": false, "accentColor_unset": "", "logo_unset": "", diff --git a/src/assets/translations/en.json b/src/assets/translations/en.json index cc8c121..ecfb5a0 100644 --- a/src/assets/translations/en.json +++ b/src/assets/translations/en.json @@ -164,7 +164,10 @@ "send_verification": "Send verification email", "sent_verification": "An email has been sent to {email}. Please use your regular email client to verify the address.", "resend_verification": "Resend verification email", - "email_not_valid": "Email address not valid" + "email_not_valid": "Email address not valid", + "registration_token": "Please enter registration token", + "send_token": "Send token", + "token_not_valid": "Invalid token" }, "profile": { "title": "My Profile", diff --git a/src/components/InteractiveAuth.vue b/src/components/InteractiveAuth.vue index 3d50110..813c7bf 100644 --- a/src/components/InteractiveAuth.vue +++ b/src/components/InteractiveAuth.vue @@ -61,6 +61,21 @@ + + + + +
{{ $t("login.registration_token") }}
+ + + {{ $t("login.send_token") }} + +
+
+
+ @@ -74,6 +89,7 @@ const steps = Object.freeze({ EXTERNAL_AUTH: 3, ENTER_EMAIL: 4, AWAITING_EMAIL_VERIFICATION: 5, + ENTER_TOKEN: 6, }); export default { @@ -85,6 +101,9 @@ export default { emailRules: [ v => this.validEmail(v) || this.$t("login.email_not_valid") ], + tokenRules: [ + v => this.validToken(v) || this.$t("login.token_not_valid") + ], policies: null, onPoliciesAccepted: () => { }, onEmailResend: () => { }, @@ -95,6 +114,7 @@ export default { emailVerificationAttempt: 1, emailVerificationSid: null, emailVerificationPollTimer: null, + token: "", }; }, @@ -105,6 +125,9 @@ export default { emailIsValid() { return this.validEmail(this.email); }, + tokenIsValid() { + return this.validToken(this.token); + } }, methods: { @@ -116,6 +139,15 @@ export default { } }, + validToken(token) { + // https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/3231-token-authenticated-registration.md + if (/^[A-Za-z0-9._~-]{1,64}$/.test(token)) { + return true; + } else { + return false; + } + }, + registrationFlowHandler(client, authData) { const flows = authData.flows; if (!flows || flows.length == 0) { @@ -257,6 +289,34 @@ export default { }); } }); + + case "m.login.registration_token": { + this.step = steps.CREATING; + return new Promise((resolve, reject) => { + if (this.$config.registrationToken) { + // We have a token in config, use that! + // + const data = { + session: authData.session, + type: nextStage, + token: this.$config.registrationToken + }; + submitStageData(resolve, reject, data); + } else { + this.step = steps.ENTER_TOKEN; + this.onTokenEntered = (token) => { + this.step = steps.CREATING; + const data = { + session: authData.session, + type: nextStage, + token: token + }; + submitStageData(resolve, reject, data); + }; + } + }); + } + default: this.step = steps.EXTERNAL_AUTH; return new Promise((resolve, reject) => { From 4af77e2480872d43b65a90666878665e7f00a9db Mon Sep 17 00:00:00 2001 From: N-Pex Date: Tue, 23 May 2023 10:20:42 +0200 Subject: [PATCH 06/19] Handle RTL for avatar popup --- src/components/Chat.vue | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/Chat.vue b/src/components/Chat.vue index ab5f466..d705ada 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -514,19 +514,24 @@ export default { // const ref = this.selectedEvent && this.$refs[this.selectedEvent.getId()]; var top = 0; - var left = 0; + var left = "unset"; + var right = "unset"; if (ref && ref[0]) { if (this.showAvatarMenuAnchor) { var rectAnchor = this.showAvatarMenuAnchor.getBoundingClientRect(); var rectChat = this.$refs.avatarOperationsStrut.getBoundingClientRect(); top = rectAnchor.top - rectChat.top; - left = rectAnchor.left - rectChat.left; + if (this.$vuetify.rtl) { + right = (rectAnchor.right - rectChat.right)+ "px"; + } else { + left = (rectAnchor.left - rectChat.left) + "px"; + } // if (left + 250 > rectChat.right) { // left = rectChat.right - 250; // Pretty ugly, but we want to make sure it does not escape the screen, and we don't have the exakt width of it (yet)! // } } } - return "top:" + top + "px;left:" + left + "px"; + return "top:" + top + "px;left:" + left + ";right:" + right; }, canRecordAudio() { return util.browserCanRecordAudio(); From b097fd51d8e46c5475de5d0c064e1c4fc0e5c17c Mon Sep 17 00:00:00 2001 From: N-Pex Date: Tue, 23 May 2023 16:29:17 +0200 Subject: [PATCH 07/19] Send room display name as part of invite link (if needed) --- src/components/CreateRoom.vue | 2 +- src/components/Join.vue | 10 +++++++++- src/components/roomInfoMixin.js | 2 +- src/plugins/utils.js | 7 +++++++ src/router/index.js | 9 +++++++-- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/components/CreateRoom.vue b/src/components/CreateRoom.vue index efc0ec2..a87b0bd 100644 --- a/src/components/CreateRoom.vue +++ b/src/components/CreateRoom.vue @@ -295,7 +295,7 @@ export default { } if (room) { this.publicRoomLink = this.$router.getRoomLink( - room.getCanonicalAlias() || roomId + room.getCanonicalAlias(), roomId, room.name ); } }); diff --git a/src/components/Join.vue b/src/components/Join.vue index e35d8b1..c5548d5 100644 --- a/src/components/Join.vue +++ b/src/components/Join.vue @@ -13,7 +13,7 @@ {{ roomId && roomId.startsWith("@") ? $t("join.title_user") : $t("join.title") }}
- {{ roomName }} + {{ roomDisplayName || roomName }}
@@ -215,6 +215,14 @@ export default { let activeLanguages = [...this.getLanguages()]; return activeLanguages.filter((lang) => lang.value === this.$i18n.locale); }, + roomDisplayName() { + // If there is a display name in to invite link, use that! + try { + return new URL(location.href).searchParams.get('roomName'); + } catch(ignoredError) { + return undefined; + } + } }, watch: { roomId: { diff --git a/src/components/roomInfoMixin.js b/src/components/roomInfoMixin.js index cb420f5..53cb205 100644 --- a/src/components/roomInfoMixin.js +++ b/src/components/roomInfoMixin.js @@ -57,7 +57,7 @@ export default { publicRoomLink() { if (this.room && this.roomJoinRule == "public") { return this.$router.getRoomLink( - this.room.getCanonicalAlias() || this.room.roomId + this.room.getCanonicalAlias(), this.room.roomId, this.room.name ); } return null; diff --git a/src/plugins/utils.js b/src/plugins/utils.js index 873b126..898e93e 100644 --- a/src/plugins/utils.js +++ b/src/plugins/utils.js @@ -778,6 +778,13 @@ class Util { return _browserCanRecordAudio; } + getRoomNameFromAlias(alias) { + if (alias && alias.startsWith('#') && alias.indexOf(':') > 0) { + return alias.slice(1).split(':')[0]; + } + return undefined; + } + getUniqueAliasForRoomName(matrixClient, roomName, homeServer, iterationCount) { return new Promise((resolve, reject) => { var preferredAlias = roomName.replace(/\s/g, "").toLowerCase(); diff --git a/src/router/index.js b/src/router/index.js index 1599551..7c85163 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -140,8 +140,13 @@ router.beforeEach((to, from, next) => { } }); -router.getRoomLink = function (roomId) { - return window.location.origin + window.location.pathname + "#/room/" + encodeURIComponent(util.sanitizeRoomId(roomId)); +router.getRoomLink = function (alias, roomId, roomName) { + if ((!alias || roomName.replace(/\s/g, "").toLowerCase() !== util.getRoomNameFromAlias(alias)) && roomName) { + // There is no longer a correlation between alias and room name, probably because room name has + // changed. Include the "?roomName" part + return window.location.origin + window.location.pathname + "?roomName=" + encodeURIComponent(roomName) + "#/room/" + encodeURIComponent(util.sanitizeRoomId(alias || roomId)); + } + return window.location.origin + window.location.pathname + "#/room/" + encodeURIComponent(util.sanitizeRoomId(alias || roomId)); } export default router From daa52be9c00251db9b3fcacf678b71a7c65e5185 Mon Sep 17 00:00:00 2001 From: N Pex Date: Fri, 26 May 2023 15:56:59 +0000 Subject: [PATCH 08/19] Resolve "for chat mode, auto-play next audio message" --- package-lock.json | 97 +++++++- package.json | 2 +- src/components/AudioLayout.vue | 208 +++++----------- src/components/Chat.vue | 24 +- src/components/VoiceRecorder.vue | 3 +- src/components/messages/AudioPlayer.vue | 101 ++------ .../messages/MessageIncomingAudio.vue | 6 +- .../messages/MessageOutgoingAudio.vue | 4 +- src/main.js | 6 +- src/plugins/utils.js | 23 +- src/services/audio.service.js | 233 ++++++++++++++++++ 11 files changed, 455 insertions(+), 252 deletions(-) create mode 100644 src/services/audio.service.js diff --git a/package-lock.json b/package-lock.json index e04c4eb..a3fe23c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@matrix-org/olm": "^3.2.12", "aes-js": "^3.1.2", - "axios": "^0.21.0", + "axios": "^1.4.0", "browserify-fs": "^1.0.0", "buffer": "^6.0.3", "clean-insights-sdk": "^2.4", @@ -4097,6 +4097,11 @@ "lodash": "^4.17.14" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -4153,11 +4158,13 @@ } }, "node_modules/axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", "dependencies": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, "node_modules/babel-eslint": { @@ -5497,6 +5504,17 @@ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -6378,6 +6396,14 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegate": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", @@ -7869,6 +7895,19 @@ "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -11929,6 +11968,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", @@ -19358,6 +19402,11 @@ "lodash": "^4.17.14" } }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -19386,11 +19435,13 @@ } }, "axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz", + "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==", "requires": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" } }, "babel-eslint": { @@ -20464,6 +20515,14 @@ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", "dev": true }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -21145,6 +21204,11 @@ "isobject": "^3.0.1" } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "delegate": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", @@ -22325,6 +22389,16 @@ "resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz", "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -25482,6 +25556,11 @@ } } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "prr": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", diff --git a/package.json b/package.json index 156bd9f..ee3deaa 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dependencies": { "@matrix-org/olm": "^3.2.12", "aes-js": "^3.1.2", - "axios": "^0.21.0", + "axios": "^1.4.0", "browserify-fs": "^1.0.0", "buffer": "^6.0.3", "clean-insights-sdk": "^2.4", diff --git a/src/components/AudioLayout.vue b/src/components/AudioLayout.vue index 735573e..081891c 100644 --- a/src/components/AudioLayout.vue +++ b/src/components/AudioLayout.vue @@ -37,20 +37,18 @@
{{ currentTime }} / {{ totalTime }}
-
- + $vuetify.icons.rewind - + + $vuetify.icons.pause_circle $vuetify.icons.play_circle - + $vuetify.icons.forward
@@ -102,87 +100,38 @@ export default { }, data() { return { - src: null, + info: null, currentAudioEvent: null, autoPlayNextEvent: false, - currentAudioSource: null, - player: null, - duration: 0, - playPercent: 0, - playTime: 0, - playing: false, analyzer: null, analyzerDataArray: null, showReadOnlyToast: false, }; }, mounted() { + this.$root.$on('audio-playback-started', this.audioPlaybackStarted); + this.$root.$on('audio-playback-paused', this.audioPlaybackPaused); + this.$root.$on('audio-playback-ended', this.audioPlaybackEnded); document.body.classList.add("dark"); - this.$root.$on('playback-start', this.onPlaybackStart); - this.player = this.$refs.player; - this.player.autoplay = false; - this.player.addEventListener("timeupdate", this.updateProgressBar); - this.player.addEventListener("play", () => { - if (!this.analyser) { - - const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); - let audioSource = null; - if (audioCtx) { - audioSource = audioCtx.createMediaElementSource(this.player); - this.analyser = audioCtx.createAnalyser(); - audioSource.connect(this.analyser); - this.analyser.connect(audioCtx.destination); - - this.analyser.fftSize = 128; - const bufferLength = this.analyser.frequencyBinCount; - this.analyzerDataArray = new Uint8Array(bufferLength); - } - } - - - this.playing = true; - this.updateVisualization(); - if (this.currentAudioEvent) { - this.$emit("mark-read", this.currentAudioEvent.getId(), this.currentAudioEvent.getId()); - } - }); - this.player.addEventListener("pause", () => { - this.playing = false; - this.clearVisualization(); - }); - this.player.addEventListener("ended", () => { - this.pause(); - this.playing = false; - this.clearVisualization(); - this.onPlaybackEnd(); - }); + this.$audioPlayer.setAutoplay(false); }, beforeDestroy() { + this.$root.$off('audio-playback-started', this.audioPlaybackStarted); + this.$root.$off('audio-playback-paused', this.audioPlaybackPaused); + this.$root.$off('audio-playback-ended', this.audioPlaybackEnded); document.body.classList.remove("dark"); + this.$audioPlayer.removeListener(this._uid); this.currentAudioEvent = null; - this.loadAudioAttachmentSource(); // Release - this.$root.$off('playback-start', this.onPlaybackStart); }, computed: { canRecordAudio() { return !this.$matrix.currentRoomIsReadOnlyForUser && util.browserCanRecordAudio(); }, currentTime() { - return util.formatDuration(this.playTime); + return util.formatDuration(this.info ? this.info.currentTime : 0); }, totalTime() { - return util.formatDuration(this.duration); - }, - playheadPercent: { - get: function () { - return this.playPercent; - }, - set: function (percent) { - if (this.player.src) { - this.playPercent = percent; - this.player.currentTime = (percent / 100) * this.player.duration; - } - }, + return util.formatDuration(this.info ? this.info.duration : 0); }, recordingMembersExceptMe() { return this.recordingMembers.filter((member) => { @@ -202,18 +151,14 @@ export default { events: { immediate: true, handler(events, ignoredOldValue) { - console.log("Events changed", this.currentAudioEvent, this.autoPlayNextEvent); if (!this.currentAudioEvent || this.autoPlayNextEvent) { // Make sure all events are decrypted! const eventsBeingDecrypted = events.filter((e) => e.isBeingDecrypted()); if (eventsBeingDecrypted.length > 0) { - console.log("All not decrypted, wait"); Promise.allSettled(eventsBeingDecrypted.map((e) => e.getDecryptionPromise())).then(() => { - console.log("DONE DECRYPTING!") this.loadNext(this.autoPlayNextEvent && this.autoplay); }); } else { - console.log("All decrypted, load next"); this.loadNext(this.autoPlayNextEvent && this.autoplay); } } @@ -222,85 +167,78 @@ export default { currentAudioEvent: { immediate: true, handler(value, oldValue) { - console.log("Current audio derom", value, oldValue); if (value && oldValue && value.getId && oldValue.getId && value.getId() === oldValue.getId()) { - console.log("Ignoring change!!!"); return; } if (!value || !value.getId) { return; } - this.src = null; + + this.info = this.$audioPlayer.addListener(this._uid, value); + const autoPlayWasSet = this.autoPlayNextEvent; this.autoPlayNextEvent = false; if (value.getSender() == this.$matrix.currentUserId) { // Sent by us. Don't autoplay if we just sent this (i.e. it is ahead of our read marker) if (this.room && !this.room.getReceiptsForEvent(value).includes(value.getSender())) { - this.player.autoplay = false; + this.$audioPlayer.setAutoplay(false); this.autoPlayNextEvent = autoPlayWasSet; } } - this.loadAudioAttachmentSource(); + this.$audioPlayer.load(value); } }, - src: { - immediate: true, - handler(value, ignoredOldValue) { - console.log("Source changed to", value, ignoredOldValue); - } - } }, methods: { play() { - if (this.player.src) { - this.$root.$emit("playback-start", this); - if (this.player.paused) { - this.player.play(); - } else if (this.player.ended) { - // restart - this.player.currentTime = 0; - this.player.play(); - } + if (this.currentAudioEvent) { + this.$audioPlayer.setAutoplay(false); + this.$audioPlayer.play(this.currentAudioEvent); } }, pause() { - this.player.autoplay = false; - if (this.player.src) { - this.player.pause(); + this.$audioPlayer.setAutoplay(false); + if (this.currentAudioEvent) { + this.$audioPlayer.pause(this.currentAudioEvent); } }, rewind() { - if (this.player.src) { - this.player.currentTime = Math.max(0, this.player.currentTime - 15); + if (this.currentAudioEvent) { + this.$audioPlayer.seekRelative(this.currentAudioEvent, -15000); } }, forward() { - if (this.player.src) { - this.player.currentTime = Math.min(this.player.duration, this.player.currentTime + 15); + if (this.currentAudioEvent) { + this.$audioPlayer.seekRelative(this.currentAudioEvent, 15000); } }, - updateProgressBar() { - if (this.player.duration > 0) { - this.playPercent = Math.floor( - (100 / this.player.duration) * this.player.currentTime - ); - } else { - this.playPercent = 0; + audioPlaybackStarted() { + if (!this.analyser) { + const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + let audioSource = null; + if (audioCtx) { + audioSource = audioCtx.createMediaElementSource(this.$audioPlayer.getPlayerElement()); + this.analyser = audioCtx.createAnalyser(); + audioSource.connect(this.analyser); + this.analyser.connect(audioCtx.destination); + + this.analyser.fftSize = 128; + const bufferLength = this.analyser.frequencyBinCount; + this.analyzerDataArray = new Uint8Array(bufferLength); + } } - this.playTime = 1000 * this.player.currentTime; - }, - updateDuration() { - this.duration = 1000 * this.player.duration; - }, - onPlaybackStart(item) { - this.player.autoplay = false; - if (item != this && this.playing) { - this.pause(); + this.updateVisualization(); + if (this.currentAudioEvent) { + this.$emit("mark-read", this.currentAudioEvent.getId(), this.currentAudioEvent.getId()); } }, - onPlaybackEnd() { + audioPlaybackPaused() { + this.clearVisualization(); + }, + audioPlaybackEnded() { + this.clearVisualization(); this.loadNext(true && this.autoplay); }, loadPrevious() { @@ -332,11 +270,11 @@ export default { if (e.getId() === this.readMarker) { if (i < (audioMessages.length - 1)) { this.pause(); - this.player.autoplay = autoplay; + this.$audioPlayer.setAutoplay(autoplay); this.currentAudioEvent = audioMessages[i + 1]; } else { this.autoPlayNextEvent = true; - this.player.autoplay = autoplay; + this.$audioPlayer.setAutoplay(autoplay); this.currentAudioEvent = e; this.$emit("loadnext"); } @@ -347,7 +285,7 @@ export default { // No read marker found. Just use the first event here... if (audioMessages.length > 0) { this.pause(); - this.player.autoplay = autoplay; + this.$audioPlayer.setAutoplay(autoplay); this.currentAudioEvent = audioMessages[0]; } return; @@ -358,11 +296,11 @@ export default { if (e.getId() === this.currentAudioEvent.getId()) { if (i < (audioMessages.length - 1)) { this.pause(); - this.player.autoplay = autoplay; + this.$audioPlayer.setAutoplay(autoplay); this.currentAudioEvent = audioMessages[i + 1]; } else { this.autoPlayNextEvent = true; - this.player.autoplay = autoplay; + this.$audioPlayer.setAutoplay(autoplay); this.$emit("loadnext"); } break; @@ -391,7 +329,7 @@ export default { const color = 80 + (value * (256 - 80)) / 256; volume.style.backgroundColor = `rgb(${color},${color},${color})`; - if (this.playing) { + if (this.info && this.info.playing) { requestAnimationFrame(this.updateVisualization); } else { this.clearVisualization(); @@ -404,36 +342,6 @@ export default { volume.style.height = "0px"; volume.style.backgroundColor = "transparent"; }, - loadAudioAttachmentSource() { - console.log("loadAUto"); - if (this.src) { - const objectUrl = this.src; - this.src = null; - URL.revokeObjectURL(objectUrl); - } - if (this.currentAudioEvent) { - console.log("Will load"); - if (this.currentAudioSource) { - this.currentAudioSource.reject("Aborted"); - } - this.currentAudioSource = - util - .getAttachment(this.$matrix.matrixClient, this.currentAudioEvent, (progress) => { - this.downloadProgress = progress; - }) - .then((url) => { - console.log("Loaded", url); - this.src = url; - this.currentAudioSource = null; - this.$nextTick(() => { - this.player.load(); - }); - }) - .catch((err) => { - console.log("Failed to fetch attachment: ", err); - }); - } - }, memberAvatar(member) { if (member) { return member.getAvatarUrl( diff --git a/src/components/Chat.vue b/src/components/Chat.vue index d705ada..a068ebd 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -422,6 +422,7 @@ export default { }, mounted() { + this.$root.$on('audio-playback-ended', this.audioPlaybackEnded); const container = this.chatContainer; if (container) { this.scrollPosition = new ScrollPosition(container); @@ -432,6 +433,8 @@ export default { }, beforeDestroy() { + this.$root.$off('audio-playback-ended', this.audioPlaybackEnded); + this.$audioPlayer.pause(); this.stopRRTimer(); }, @@ -1527,8 +1530,27 @@ export default { } else { this.showNoRecordingAvailableDialog = true; } - } + }, + /** + * Called when an audio message has played to the end. We listen to this so we can optionally auto-play + * the next audio event. + * @param matrixEvent The event that stopped playing + */ + audioPlaybackEnded(matrixEventId) { + if (!this.useVoiceMode) { // Voice mode has own autoplay handling inside "AudioLayout"! + // Auto play consecutive audio messages, either incoming or sent. + const filteredEvents = this.filteredEvents; + const index = filteredEvents.findIndex(e => e.getId() === matrixEventId); + if (index >= 0 && index < (filteredEvents.length - 1)) { + const nextEvent = filteredEvents[index + 1]; + if (nextEvent.getContent().msgtype === "m.audio") { + // Yes, audio event! + this.$audioPlayer.play(nextEvent); + } + } + } + } }, }; diff --git a/src/components/VoiceRecorder.vue b/src/components/VoiceRecorder.vue index 4ba61bb..1d1bc65 100644 --- a/src/components/VoiceRecorder.vue +++ b/src/components/VoiceRecorder.vue @@ -453,7 +453,7 @@ export default { this.$emit("file", { file: this.recordedFile }); }, getFile(send) { - //const duration = Date.now() - this.recordStartedAt; + const duration = Date.now() - this.recordStartedAt; this.recorder .stop() .getMp3() @@ -468,6 +468,7 @@ export default { lastModified: Date.now(), } ); + this.recordedFile.duration = duration; if (send) { this.send(); } diff --git a/src/components/messages/AudioPlayer.vue b/src/components/messages/AudioPlayer.vue index b5c0815..1f21d2b 100644 --- a/src/components/messages/AudioPlayer.vue +++ b/src/components/messages/AudioPlayer.vue @@ -1,25 +1,13 @@ @@ -28,8 +16,8 @@ import util from "../../plugins/utils"; export default { props: { - src: { - type: String, + event: { + type: Object, default: function () { return null; }, @@ -37,86 +25,37 @@ export default { }, data() { return { - player: null, - duration: 0, - playPercent: 0, - playTime: 0, - playing: false, + info: this.install(), }; }, - mounted() { - this.$root.$on('playback-start', this.onPlaybackStart); - this.player = this.$refs.player; - this.player.addEventListener("timeupdate", this.updateProgressBar); - this.player.addEventListener("play", () => { - this.playing = true; - }); - this.player.addEventListener("pause", () => { - this.playing = false; - }); - this.player.addEventListener("ended", () => { - this.pause(); - this.playing = false; - this.$emit("playback-ended"); - }); - }, beforeDestroy() { - this.$root.$off('playback-start', this.onPlaybackStart); + this.$audioPlayer.removeListener(this._uid); }, computed: { currentTime() { - return util.formatDuration(this.playTime); + return util.formatDuration(this.info.currentTime); }, totalTime() { - return util.formatDuration(this.duration); - }, - playheadPercent: { - get: function () { - return this.playPercent; - }, - set: function (percent) { - if (this.player.src) { - this.playPercent = percent; - this.player.currentTime = (percent / 100) * this.player.duration; - } - }, + return util.formatDuration(this.info.duration); }, }, methods: { + install() { + return this.$audioPlayer.addListener(this._uid, this.event); + }, play() { - if (this.player.src) { - this.$root.$emit("playback-start", this); - if (this.player.paused) { - this.player.play(); - } else if (this.player.ended) { - // restart - this.player.currentTime = 0; - this.player.play(); - } - } + this.$audioPlayer.play(this.event); }, pause() { - if (this.player.src) { - this.player.pause(); - } - }, - updateProgressBar() { - if (this.player.duration > 0) { - this.playPercent = Math.floor( - (100 / this.player.duration) * this.player.currentTime - ); - } else { - this.playPercent = 0; - } - this.playTime = 1000 * this.player.currentTime; - }, - updateDuration() { - this.duration = 1000 * this.player.duration; + this.$audioPlayer.pause(this.event); }, onPlaybackStart(item) { - if (item != this && this.playing) { + if (item != this.src && this.info.playing) { this.pause(); } + }, + seeked(percent) { + this.$audioPlayer.seek(this.event, percent); } }, }; diff --git a/src/components/messages/MessageIncomingAudio.vue b/src/components/messages/MessageIncomingAudio.vue index 95ddab9..763781e 100644 --- a/src/components/messages/MessageIncomingAudio.vue +++ b/src/components/messages/MessageIncomingAudio.vue @@ -1,20 +1,18 @@ diff --git a/src/components/messages/MessageOutgoingAudio.vue b/src/components/messages/MessageOutgoingAudio.vue index f8cea39..c28e6ee 100644 --- a/src/components/messages/MessageOutgoingAudio.vue +++ b/src/components/messages/MessageOutgoingAudio.vue @@ -1,20 +1,18 @@ diff --git a/src/main.js b/src/main.js index 9e8ee19..c0fdf09 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,7 @@ import matrix from './services/matrix.service' import navigation from './services/navigation.service' import config from './services/config.service' import analytics from './services/analytics.service' +import audioPlayer from './services/audio.service'; import 'roboto-fontface/css/roboto/roboto-fontface.css' import 'material-design-icons-iconfont/dist/material-design-icons.css' import VEmojiPicker from 'v-emoji-picker'; @@ -35,6 +36,7 @@ const configLoadedPromise = new Promise((resolve, ignoredreject) => { }); Vue.use(analytics); Vue.use(VueClipboard); +Vue.use(audioPlayer); const vuetify = createVuetify(config); @@ -176,9 +178,11 @@ const vueInstance = new Vue({ matrix, config, analytics, - render: h => h(App) + audioPlayer, + render: h => h(App), }); vueInstance.$vuetify.theme.themes.light.primary = vueInstance.$config.accentColor; +vueInstance.$audioPlayer.$root = vueInstance; // Make sure a $root is available here configLoadedPromise.then((config) => { vueInstance.$vuetify.theme.themes.light.primary = config.accentColor; vueInstance.$mount('#app'); diff --git a/src/plugins/utils.js b/src/plugins/utils.js index 898e93e..38b862e 100644 --- a/src/plugins/utils.js +++ b/src/plugins/utils.js @@ -53,7 +53,22 @@ class UploadPromise extends Promise { } class Util { - getAttachment(matrixClient, event, progressCallback, asBlob = false) { + getAttachmentUrlAndDuration(event) { + return new Promise((resolve, reject) => { + const content = event.getContent(); + if (content.url != null) { + resolve([content.url, content.info.duration]); + return; + } + if (content.file && content.file.url) { + resolve([content.file.url, content.info.duration]); + } else { + reject("No url found!"); + } + }); + } + + getAttachment(matrixClient, event, progressCallback, asBlob = false, abortController = undefined) { return new Promise((resolve, reject) => { const content = event.getContent(); if (content.url != null) { @@ -73,6 +88,7 @@ class Util { } axios.get(url, { + signal: abortController ? abortController.signal : undefined, responseType: 'arraybuffer', onDownloadProgress: progressEvent => { let percentCompleted = Math.floor((progressEvent.loaded * 100) / progressEvent.total); if (progressCallback) { @@ -337,6 +353,11 @@ class Util { mimetype: file.type, size: file.size }; + + // If audio, send duration in ms as well + if (file.duration) { + info.duration = file.duration; + } var description = file.name; var msgtype = 'm.image'; diff --git a/src/services/audio.service.js b/src/services/audio.service.js new file mode 100644 index 0000000..2470e5c --- /dev/null +++ b/src/services/audio.service.js @@ -0,0 +1,233 @@ +import utils from "../plugins/utils"; + +/** + * This plugin (available in all vue components as $audioPlayer) handles + * access to the shared audio player, and events related to loading and + * playback of audio attachments. + * + * Components use this by calling "addListener" (and corresponding removeListener) with + * an audio matrix event and a unique component id (for example the ._uid property). + */ +export default { + install(Vue) { + class SharedAudioPlayer { + constructor() { + this.player = new Audio(); + this.currentEvent = null; + this.infoMap = new Map(); + this.player.addEventListener("durationchange", this.onDurationChange.bind(this)); + this.player.addEventListener("timeupdate", this.onTimeUpdate.bind(this)); + this.player.addEventListener("play", this.onPlay.bind(this)); + this.player.addEventListener("pause", this.onPause.bind(this)); + this.player.addEventListener("ended", this.onEnded.bind(this)); + } + + getPlayerElement() { + return this.player; + } + + addListener(uid, event) { + const eventId = event.getId(); + var entry = this.infoMap.get(eventId); + if (!entry) { + // Listeners is just a Set of component "uid" entries for now. + entry = { url: null, listeners: new Set() }; + // Make these reactive, so AudioPlayer (and others) can listen to them + Vue.set(entry, "loading", false); + Vue.set(entry, "loadPercent", 0); + Vue.set(entry, "duration", 0); + Vue.set(entry, "currentTime", 0); + Vue.set(entry, "playPercent", 0); + Vue.set(entry, "playing", false); + this.infoMap.set(eventId, entry); + + // Get duration information + utils + .getAttachmentUrlAndDuration(event) + .then(([ignoredurl, duration]) => { + entry.duration = duration; + }) + .catch((err) => { + console.error("Failed to fetch attachment duration: ", err); + }); + } + entry.listeners.add(uid); + return entry; + } + removeListener(uid) { + [...this.infoMap].forEach(([ignoredeventid, info]) => { + info.listeners.delete(uid); + if (info.listeners.size == 0 && info.url) { + // No more listeners, release audio blob + URL.revokeObjectURL(info.url); + info.url = null; + } + }); + this.infoMap = new Map( + [...this.infoMap].filter(([ignoredeventid, info]) => { + return info.listeners.size > 0; + }) + ); + } + + play(event) { + this.play_(event, false); + } + + load(event) { + this.play_(event, true); + } + + play_(event, onlyLoad) { + const eventId = event.getId(); + if (this.currentEvent != eventId) { + // Media change, pause the one currently playing. + this.player.pause(); + var entry = this.infoMap.get(this.currentEvent); + if (entry) { + entry.playing = false; + } + } + this.currentEvent = eventId; + const info = this.infoMap.get(eventId); + if (info) { + if (info.url) { + // Restart from beginning? + if (info.currentTime == info.duration) { + info.currentTime = 0; + info.playPercent = 0; + } + if (this.player.src != info.url) { + this.player.src = info.url; + this.player.currentTime = (info.currentTime || 0) / 1000; + } + if (onlyLoad) { + this.player.load(); + } else { + this.player.play(); + } + } else { + // Download it! + info.loadPercent = 0; + info.loading = true; + info.abortController = new AbortController(); + utils + .getAttachment(this.$root.$matrix.matrixClient, event, (progress) => { + info.loadPercent = progress; + }, false, info.abortController) + .then((url) => { + info.url = url; + + // Still on this item? Call ourselves recursively. + if (this.currentEvent == eventId) { + if (onlyLoad) { + this.load(event); + } else { + this.play(event); + } + } + }) + .catch((err) => { + console.error("Failed to fetch attachment: ", err); + }) + .finally(() => { + info.loading = false; + info.abortController = undefined; + }); + } + } + } + + /** + * Set the "autoplay" property on the underlying player object. + * @param {} autoplay + */ + setAutoplay(autoplay) { + this.player.autoplay = autoplay; + } + + pause(event) { + if (!event || this.currentEvent == event.getId()) { + this.player.pause(); + } + + if (event) { + // If downloading, abort that! + var entry = this.infoMap.get(event.getId()); + if (entry && entry.abortController) { + entry.abortController.abort(); + } + } + } + seek(event, percent) { + var entry = this.infoMap.get(event.getId()); + if (entry) { + entry.currentTime = ((percent / 100) * (entry.duration || 0)); + this.updatePlayPercent(entry); + if (this.currentEvent == event.getId()) { + this.player.currentTime = entry.currentTime / 1000; + } + } + } + seekRelative(event, milliseconds) { + var entry = this.infoMap.get(event.getId()); + if (entry) { + entry.currentTime = Math.max(0, Math.min(entry.currentTime + milliseconds, entry.duration)); + this.updatePlayPercent(entry); + if (this.currentEvent == event.getId()) { + this.player.currentTime = entry.currentTime / 1000; + } + } + } + onPlay() { + var entry = this.infoMap.get(this.currentEvent); + if (entry) { + entry.playing = true; + } + this.$root.$emit("audio-playback-started", this.currentEvent); + } + onPause() { + var entry = this.infoMap.get(this.currentEvent); + if (entry) { + entry.playing = false; + } + this.$root.$emit("audio-playback-paused", this.currentEvent); + } + onEnded() { + var entry = this.infoMap.get(this.currentEvent); + if (entry) { + entry.playing = false; + entry.currentTime = entry.duration; // Next time restart + } + this.$root.$emit("audio-playback-ended", this.currentEvent); + } + onTimeUpdate() { + var entry = this.infoMap.get(this.currentEvent); + if (entry) { + entry.currentTime = 1000 * this.player.currentTime; + this.updatePlayPercent(entry); + } + } + onDurationChange() { + const duration = + this.player.duration && isFinite(this.player.duration) && !isNaN(this.player.duration) + ? 1000 * this.player.duration + : 0; + var entry = this.infoMap.get(this.currentEvent); + if (entry) { + entry.duration = duration; + this.updatePlayPercent(entry); + } + } + updatePlayPercent(entry) { + if (entry.duration > 0) { + entry.playPercent = Math.floor((100 / entry.duration) * entry.currentTime); + } else { + entry.playPercent = 0; + } + } + } + + Vue.prototype.$audioPlayer = new SharedAudioPlayer(); + }, +}; From 2b41e91908e21cedccb32cb79cc3f6f782ccc5f1 Mon Sep 17 00:00:00 2001 From: 10G Meow <10gmeow@gmail.com> Date: Sun, 28 May 2023 20:37:52 +0300 Subject: [PATCH 09/19] 1. review changes 2. improve different file type handling 3. File upload dialogue improvements which now shows all file type --- src/assets/translations/en.json | 6 +- src/components/Chat.vue | 113 ++++++++++++++++---------------- 2 files changed, 60 insertions(+), 59 deletions(-) diff --git a/src/assets/translations/en.json b/src/assets/translations/en.json index cc8c121..15fe496 100644 --- a/src/assets/translations/en.json +++ b/src/assets/translations/en.json @@ -90,7 +90,11 @@ "incoming_message_deleted_text": "This message was deleted.", "not_allowed_to_send": "Only admins and moderators are allowed to send to the room", "reaction_count_more": "{reactionCount} more", - "seen_by": "Seen by no members | Seen by 1 member | Seen by {count} members" + "seen_by": "Seen by no members | Seen by 1 member | Seen by {count} members", + "file": "File", + "files": "Files", + "images": "Images", + "send_attachements_dialog_title": "Do you want to send following attachments ?" }, "room": { "invitations": "You have no invitations | You have 1 invitation | You have {count} invitations", diff --git a/src/components/Chat.vue b/src/components/Chat.vue index 67e8f73..829f1e9 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -190,10 +190,13 @@ -
- +
+ -