448 lines
No EOL
15 KiB
Vue
448 lines
No EOL
15 KiB
Vue
<template>
|
|
<div v-bind="{ ...$props, ...$attrs }" v-on="$listeners" class="messageIn">
|
|
<div class="load-earlier clickable" @click="loadPrevious">
|
|
<v-icon color="white" size="28">expand_less</v-icon>
|
|
</div>
|
|
|
|
<!-- Currently recording users -->
|
|
<div class="typing-users">
|
|
<transition-group name="list" tag="div">
|
|
<v-avatar v-for="(member) in recordingMembersExceptMe" :key="member.userId" class="typing-user" size="32" color="grey">
|
|
<AuthedImage v-if="memberAvatar(member)" :src="memberAvatar(member)" />
|
|
<span v-else class="white--text headline">{{
|
|
member.name.substring(0, 1).toUpperCase()
|
|
}}</span>
|
|
</v-avatar>
|
|
</transition-group>
|
|
</div>
|
|
|
|
<div class="sound-wave-view">
|
|
<div class="volume-container">
|
|
<div ref="volume"></div>
|
|
</div>
|
|
<v-avatar v-if="currentAudioEvent" class="avatar" ref="avatar" size="32" color="#ededed"
|
|
@click.stop="otherAvatarClicked($refs.avatar.$el)">
|
|
<img v-if="messageEventAvatar(currentAudioEvent)" :src="messageEventAvatar(currentAudioEvent)" />
|
|
<span v-else class="white--text headline">{{
|
|
eventSenderDisplayName(currentAudioEvent).substring(0, 1).toUpperCase()
|
|
}}</span>
|
|
</v-avatar>
|
|
</div>
|
|
|
|
<!-- Current emoji reactions -->
|
|
<div class="typing-users">
|
|
<transition-group name="list" tag="div">
|
|
<v-avatar v-for="reaction in reactions" :key="reaction.member.userId" class="typing-user" size="32" color="grey">
|
|
<AuthedImage v-if="memberAvatar(reaction.member)" :src="memberAvatar(reaction.member)" />
|
|
<span v-else class="white--text headline">{{
|
|
reaction.member.name.substring(0, 1).toUpperCase()
|
|
}}</span>
|
|
<div class="reaction-emoji">{{ reaction.emoji }}</div>
|
|
</v-avatar>
|
|
</transition-group>
|
|
</div>
|
|
|
|
<div v-if="currentAudioEvent" class="senderAndTime">
|
|
<div class="sender">{{ eventSenderDisplayName(currentAudioEvent) }}</div>
|
|
<div class="time">
|
|
{{ formatTimeAgo(currentAudioEvent.event.origin_server_ts) }}
|
|
</div>
|
|
</div>
|
|
<div class="play-time">
|
|
{{ currentTime }} / {{ totalTime }}
|
|
</div>
|
|
<div v-if="currentAudioEvent" class="auto-audio-player">
|
|
<v-btn id="btn-rewind" :disabled="!info || info.loading" @click.stop="rewind" icon>
|
|
<v-icon size="28">$vuetify.icons.rewind</v-icon>
|
|
</v-btn>
|
|
<v-progress-circular v-if="info && info.loading" :value="info.loadPercent" @click.stop="pause" size="36" width="2" style="margin:26px"></v-progress-circular>
|
|
<v-btn v-else-if="info && info.playing" id="btn-pause" @click.stop="pause" icon>
|
|
<v-icon size="56">$vuetify.icons.pause_circle</v-icon>
|
|
</v-btn>
|
|
<v-btn v-else id="btn-play" @click.stop="play" icon>
|
|
<v-icon size="56">$vuetify.icons.play_circle</v-icon>
|
|
</v-btn>
|
|
<v-btn id="btn-forward" :disabled="!info || info.loading" @click.stop="forward" icon>
|
|
<v-icon size="28">$vuetify.icons.forward</v-icon>
|
|
</v-btn>
|
|
</div>
|
|
|
|
<div class="load-later">
|
|
<div style="align-self: flex-end;">
|
|
<v-btn class="clap-button clickable" text elevation="0" v-blur @click.stop="clapButtonClicked()">👏</v-btn>
|
|
<v-btn :class="{'mic-button': true, 'dimmed': !canRecordAudio}" ref="mic_button" fab small elevation="0" v-blur
|
|
@click.stop="micButtonClicked()">
|
|
<v-icon color="white">mic</v-icon>
|
|
</v-btn>
|
|
</div>
|
|
<v-icon class="clickable" @click="loadNext" color="white" size="28">expand_more</v-icon>
|
|
</div>
|
|
|
|
<div v-if="showReadOnlyToast" class="toast-at-bottom visible">{{ $t("message.not_allowed_to_send") }}</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import messageMixin from "./messages/messageMixin";
|
|
import util from "../plugins/utils";
|
|
import AuthedImage from "./AuthedImage.vue";
|
|
import clapping from "@/assets/sounds/clapping.mp3";
|
|
|
|
export default {
|
|
mixins: [messageMixin],
|
|
components: { AuthedImage },
|
|
props: {
|
|
autoplay: {
|
|
type: Boolean,
|
|
default: function () {
|
|
return true
|
|
}
|
|
},
|
|
events: {
|
|
type: Array,
|
|
default: function () {
|
|
return []
|
|
}
|
|
},
|
|
readMarker: {
|
|
type: String,
|
|
default: function () {
|
|
return null;
|
|
}
|
|
},
|
|
recordingMembers: {
|
|
type: Array,
|
|
default: function () {
|
|
return []
|
|
}
|
|
},
|
|
},
|
|
data() {
|
|
return {
|
|
REACTION_ANIMATION_TIME: 2500,
|
|
info: null,
|
|
currentAudioEvent: null,
|
|
autoPlayNextEvent: false,
|
|
analyzer: null,
|
|
analyzerDataArray: null,
|
|
showReadOnlyToast: false,
|
|
reactions: [],
|
|
updateReactionsTimer: null,
|
|
};
|
|
},
|
|
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);
|
|
this.$root.$on('audio-playback-reaction', this.audioPlaybackReaction);
|
|
document.body.classList.add("dark");
|
|
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);
|
|
this.$root.$off('audio-playback-reaction', this.audioPlaybackReaction);
|
|
document.body.classList.remove("dark");
|
|
this.$audioPlayer.removeListener(this._uid);
|
|
this.currentAudioEvent = null;
|
|
},
|
|
computed: {
|
|
canRecordAudio() {
|
|
return this.$matrix.userCanSendMessageInCurrentRoom && util.browserCanRecordAudio();
|
|
},
|
|
currentTime() {
|
|
return util.formatDuration(this.info ? this.info.currentTime : 0);
|
|
},
|
|
currentTimeMs() {
|
|
return this.info ? this.info.currentTime : 0;
|
|
},
|
|
totalTime() {
|
|
return util.formatDuration(this.info ? this.info.duration : 0);
|
|
},
|
|
recordingMembersExceptMe() {
|
|
return this.recordingMembers.filter((member) => {
|
|
return member.userId !== this.$matrix.currentUserId;
|
|
});
|
|
},
|
|
},
|
|
watch: {
|
|
autoplay: {
|
|
immediate: true,
|
|
handler(autoplay, ignoredOldValue) {
|
|
if (!autoplay) {
|
|
this.pause();
|
|
}
|
|
}
|
|
},
|
|
events: {
|
|
immediate: true,
|
|
handler(events, ignoredOldValue) {
|
|
if (!this.currentAudioEvent || this.autoPlayNextEvent) {
|
|
// Make sure all events are decrypted!
|
|
const eventsBeingDecrypted = events.filter((e) => e.isBeingDecrypted());
|
|
if (eventsBeingDecrypted.length > 0) {
|
|
Promise.allSettled(eventsBeingDecrypted.map((e) => e.getDecryptionPromise())).then(() => {
|
|
this.loadNext(this.autoPlayNextEvent && this.autoplay);
|
|
});
|
|
} else {
|
|
this.loadNext(this.autoPlayNextEvent && this.autoplay);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
currentAudioEvent: {
|
|
immediate: true,
|
|
handler(value, oldValue) {
|
|
if (value && oldValue && value.getId && oldValue.getId && value.getId() === oldValue.getId()) {
|
|
return;
|
|
}
|
|
if (!value || !value.getId) {
|
|
return;
|
|
}
|
|
|
|
this.clearReactions();
|
|
|
|
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.$audioPlayer.setAutoplay(false);
|
|
this.autoPlayNextEvent = autoPlayWasSet;
|
|
}
|
|
}
|
|
|
|
this.$audioPlayer.load(value, this.timelineSet);
|
|
}
|
|
},
|
|
},
|
|
methods: {
|
|
play() {
|
|
if (this.currentAudioEvent) {
|
|
this.$audioPlayer.setAutoplay(false);
|
|
this.$audioPlayer.play(this.currentAudioEvent, this.timelineSet);
|
|
}
|
|
},
|
|
pause() {
|
|
this.$audioPlayer.setAutoplay(false);
|
|
if (this.currentAudioEvent) {
|
|
this.$audioPlayer.pause(this.currentAudioEvent);
|
|
}
|
|
},
|
|
rewind() {
|
|
if (this.currentAudioEvent) {
|
|
this.$audioPlayer.seekRelative(this.currentAudioEvent, -15000);
|
|
}
|
|
},
|
|
forward() {
|
|
if (this.currentAudioEvent) {
|
|
this.$audioPlayer.seekRelative(this.currentAudioEvent, 15000);
|
|
}
|
|
},
|
|
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.updateVisualization();
|
|
if (this.currentAudioEvent) {
|
|
this.$emit("mark-read", [this.currentAudioEvent]);
|
|
}
|
|
},
|
|
audioPlaybackPaused() {
|
|
this.clearVisualization();
|
|
},
|
|
audioPlaybackEnded() {
|
|
this.clearVisualization();
|
|
this.loadNext(true && this.autoplay);
|
|
},
|
|
audioPlaybackReaction(reaction) {
|
|
// Play sound!
|
|
const audio = new Audio(clapping);
|
|
audio.volume = 0.6;
|
|
audio.play();
|
|
|
|
const member = this.room.getMember(reaction.sender);
|
|
if (member) {
|
|
this.reactions.push(Object.assign({ addedAt: Date.now(), member: member}, reaction));
|
|
if (!this.updateReactionsTimer) {
|
|
this.updateReactionsTimer = setInterval(this.updateReactions, 300);
|
|
}
|
|
}
|
|
},
|
|
loadPrevious() {
|
|
const audioMessages = this.events.filter((e) => e.getContent().msgtype === "m.audio");
|
|
for (let i = 0; i < audioMessages.length; i++) {
|
|
const e = audioMessages[i];
|
|
if (this.currentAudioEvent && e.getId() === this.currentAudioEvent.getId()) {
|
|
if (i > 0) {
|
|
this.pause();
|
|
this.currentAudioEvent = audioMessages[i - 1];
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
this.$emit("loadprevious");
|
|
},
|
|
loadNext(autoplay = false) {
|
|
const audioMessages = this.events.filter((e) => e.getContent().msgtype === "m.audio");
|
|
if (audioMessages.length == 0) {
|
|
// Try to load earlier
|
|
this.$emit("loadprevious");
|
|
return;
|
|
}
|
|
if (!this.currentAudioEvent) {
|
|
// Figure out which audio event to start with, i.e. our "read marker"
|
|
for (let i = 0; i < audioMessages.length; i++) {
|
|
const e = audioMessages[i];
|
|
if (e.getId() === this.readMarker) {
|
|
if (i < (audioMessages.length - 1)) {
|
|
this.pause();
|
|
this.$audioPlayer.setAutoplay(autoplay);
|
|
this.currentAudioEvent = audioMessages[i + 1];
|
|
} else {
|
|
this.autoPlayNextEvent = true;
|
|
this.$audioPlayer.setAutoplay(autoplay);
|
|
this.currentAudioEvent = e;
|
|
this.$emit("loadnext");
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// No read marker found. Just use the first event here...
|
|
if (audioMessages.length > 0) {
|
|
this.pause();
|
|
this.$audioPlayer.setAutoplay(autoplay);
|
|
this.currentAudioEvent = audioMessages[0];
|
|
}
|
|
return;
|
|
}
|
|
|
|
for (let i = 0; i < audioMessages.length; i++) {
|
|
const e = audioMessages[i];
|
|
if (e.getId() === this.currentAudioEvent.getId()) {
|
|
if (i < (audioMessages.length - 1)) {
|
|
this.pause();
|
|
this.$audioPlayer.setAutoplay(autoplay);
|
|
this.currentAudioEvent = audioMessages[i + 1];
|
|
} else {
|
|
this.autoPlayNextEvent = true;
|
|
this.$audioPlayer.setAutoplay(autoplay);
|
|
this.$emit("loadnext");
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
updateVisualization() {
|
|
|
|
const volume = this.$refs.volume;
|
|
if (volume && this.analyser) {
|
|
const volumeContainer = volume.parentElement;
|
|
|
|
const bufferLength = this.analyser.frequencyBinCount;
|
|
|
|
this.analyser.getByteFrequencyData(this.analyzerDataArray);
|
|
var value = 0;
|
|
for (let i = 0; i < bufferLength; i++) {
|
|
value += this.analyzerDataArray[i];
|
|
}
|
|
value = value / bufferLength;
|
|
const avatarWidth = 1.1 * this.$refs.avatar ? this.$refs.avatar.clientWidth : 104;
|
|
const range = Math.max(0, (volumeContainer.clientWidth - avatarWidth));
|
|
const w = avatarWidth + (value * range) / 256;
|
|
volume.style.width = "" + w + "px";
|
|
volume.style.height = "" + w + "px";
|
|
const color = 80 + (value * (256 - 80)) / 256;
|
|
volume.style.backgroundColor = `rgb(${color},${color},${color})`;
|
|
if (this.info && this.info.playing) {
|
|
requestAnimationFrame(this.updateVisualization);
|
|
} else {
|
|
this.clearVisualization();
|
|
}
|
|
}
|
|
},
|
|
clearVisualization() {
|
|
const volume = this.$refs.volume;
|
|
volume.style.width = "0px";
|
|
volume.style.height = "0px";
|
|
volume.style.backgroundColor = "transparent";
|
|
},
|
|
updateReactions() {
|
|
const now = Date.now();
|
|
this.reactions = this.reactions.filter(r => {
|
|
return (r.addedAt + this.REACTION_ANIMATION_TIME > now);
|
|
});
|
|
if (this.reactions.length == 0) {
|
|
this.clearReactions();
|
|
}
|
|
},
|
|
|
|
clearReactions() {
|
|
if (this.updateReactionsTimer) {
|
|
clearInterval(this.updateReactionsTimer);
|
|
this.updateReactionsTimer = null;
|
|
}
|
|
this.reactions = [];
|
|
},
|
|
|
|
memberAvatar(member) {
|
|
if (member) {
|
|
return member.getAvatarUrl(
|
|
this.$matrix.matrixClient.getHomeserverUrl(),
|
|
40,
|
|
40,
|
|
"scale",
|
|
true,
|
|
false,
|
|
this.$matrix.useAuthedMedia,
|
|
);
|
|
}
|
|
return null;
|
|
},
|
|
|
|
micButtonClicked() {
|
|
if (!this.$matrix.userCanSendMessageInCurrentRoom) {
|
|
this.showReadOnlyToast = true;
|
|
setTimeout(() => {
|
|
this.showReadOnlyToast = false;
|
|
}, 3000);
|
|
} else {
|
|
this.$emit('start-recording');
|
|
}
|
|
},
|
|
|
|
clapButtonClicked() {
|
|
if (this.currentAudioEvent) {
|
|
this.$emit("sendclap", { event: this.currentAudioEvent, timeOffset: this.currentTimeMs })
|
|
|
|
// Also, play locally
|
|
this.audioPlaybackReaction({
|
|
sender: this.$matrix.currentUserId,
|
|
emoji: "👏"
|
|
});
|
|
}
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
@import "@/assets/css/chat.scss";
|
|
</style> |