From daa52be9c00251db9b3fcacf678b71a7c65e5185 Mon Sep 17 00:00:00 2001 From: N Pex Date: Fri, 26 May 2023 15:56:59 +0000 Subject: [PATCH] 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(); + }, +};