Files
MeshChatX/meshchatx/src/frontend/components/call/CallOverlay.vue

330 lines
14 KiB
Vue

<template>
<div
v-if="activeCall"
class="fixed bottom-4 right-4 z-[100] w-72 bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transition-all duration-300"
:class="{ 'ring-2 ring-red-500 ring-opacity-50': isEnded }"
>
<!-- Header -->
<div class="p-3 flex items-center bg-gray-50 dark:bg-zinc-800/50 border-b border-gray-100 dark:border-zinc-800">
<div class="flex-1 flex items-center space-x-2">
<div
class="size-2 rounded-full"
:class="isEnded || wasDeclined ? 'bg-red-500' : 'bg-green-500 animate-pulse'"
></div>
<span class="text-[10px] font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
{{
wasDeclined
? $t("call.call_declined")
: isEnded
? $t("call.call_ended")
: activeCall.is_voicemail
? $t("call.recording_voicemail")
: activeCall.status === 6
? $t("call.active_call")
: $t("call.call_status")
}}
</span>
</div>
<button
v-if="!isEnded"
type="button"
class="p-1 hover:bg-gray-200 dark:hover:bg-zinc-700 rounded-lg transition-colors"
@click="isMinimized = !isMinimized"
>
<MaterialDesignIcon
:icon-name="isMinimized ? 'chevron-up' : 'chevron-down'"
class="size-4 text-gray-500"
/>
</button>
</div>
<div v-show="!isMinimized" class="p-4">
<!-- icon and name -->
<div class="flex flex-col items-center mb-4">
<div
class="p-4 rounded-full mb-3"
:class="
isEnded || wasDeclined ? 'bg-red-100 dark:bg-red-900/30' : 'bg-blue-100 dark:bg-blue-900/30'
"
>
<LxmfUserIcon
v-if="activeCall.remote_icon"
:icon-name="activeCall.remote_icon.icon_name"
:icon-foreground-colour="activeCall.remote_icon.foreground_colour"
:icon-background-colour="activeCall.remote_icon.background_colour"
class="size-8"
/>
<MaterialDesignIcon
v-else
icon-name="account"
class="size-8"
:class="
isEnded || wasDeclined
? 'text-red-600 dark:text-red-400'
: 'text-blue-600 dark:text-blue-400'
"
/>
</div>
<div class="text-center w-full min-w-0">
<div class="font-bold text-gray-900 dark:text-white truncate px-2">
{{ activeCall.remote_identity_name || $t("call.unknown") }}
</div>
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate px-4">
{{
activeCall.remote_identity_hash
? formatDestinationHash(activeCall.remote_identity_hash)
: ""
}}
</div>
</div>
</div>
<!-- Status -->
<div class="text-center mb-6">
<div
class="text-sm font-medium"
:class="[
isEnded || wasDeclined
? 'text-red-600 dark:text-red-400 animate-pulse'
: activeCall.status === 6
? 'text-green-600 dark:text-green-400'
: 'text-gray-600 dark:text-zinc-400',
]"
>
<span v-if="wasDeclined">{{ $t("call.call_declined") }}</span>
<span v-else-if="isEnded">{{ $t("call.call_ended") }}</span>
<span v-else-if="activeCall.is_incoming && activeCall.status === 4">{{
$t("call.incoming_call")
}}</span>
<span v-else-if="activeCall.status === 0">{{ $t("call.busy") }}</span>
<span v-else-if="activeCall.status === 1">{{ $t("call.rejected") }}</span>
<span v-else-if="activeCall.status === 2">{{ $t("call.calling") }}</span>
<span v-else-if="activeCall.status === 3">{{ $t("call.available") }}</span>
<span v-else-if="activeCall.status === 4">{{ $t("call.ringing") }}</span>
<span v-else-if="activeCall.status === 5">{{ $t("call.connecting") }}</span>
<span v-else-if="activeCall.status === 6">{{ $t("call.connected") }}</span>
<span v-else>{{ $t("call.status") }}: {{ activeCall.status }}</span>
</div>
</div>
<!-- Stats (only when connected and not minimized) -->
<div
v-if="activeCall.status === 6 && !isEnded"
class="mb-4 p-2 bg-gray-50 dark:bg-zinc-800/50 rounded-lg text-[10px] text-gray-500 dark:text-zinc-400 grid grid-cols-2 gap-1"
>
<div class="flex items-center space-x-1">
<MaterialDesignIcon icon-name="arrow-up" class="size-3" />
<span>{{ formatBytes(activeCall.tx_bytes || 0) }}</span>
</div>
<div class="flex items-center space-x-1">
<MaterialDesignIcon icon-name="arrow-down" class="size-3" />
<span>{{ formatBytes(activeCall.rx_bytes || 0) }}</span>
</div>
</div>
<!-- Controls -->
<div v-if="!isEnded" class="flex justify-center space-x-3">
<!-- Mute Mic -->
<button
type="button"
:title="isMicMuted ? 'Unmute Mic' : 'Mute Mic'"
class="p-3 rounded-full transition-all duration-200"
:class="
isMicMuted
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
: 'bg-gray-100 dark:bg-zinc-800 text-gray-600 dark:text-zinc-300 hover:bg-gray-200 dark:hover:bg-zinc-700'
"
@click="toggleMicrophone"
>
<MaterialDesignIcon :icon-name="isMicMuted ? 'microphone-off' : 'microphone'" class="size-6" />
</button>
<!-- Mute Speaker -->
<button
type="button"
:title="isSpeakerMuted ? 'Unmute Speaker' : 'Mute Speaker'"
class="p-3 rounded-full transition-all duration-200"
:class="
isSpeakerMuted
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
: 'bg-gray-100 dark:bg-zinc-800 text-gray-600 dark:text-zinc-300 hover:bg-gray-200 dark:hover:bg-zinc-700'
"
@click="toggleSpeaker"
>
<MaterialDesignIcon :icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'" class="size-6" />
</button>
<!-- Hangup -->
<button
type="button"
:title="
activeCall.is_incoming && activeCall.status === 4
? $t('call.decline_call')
: $t('call.hangup_call')
"
class="p-3 rounded-full bg-red-600 text-white hover:bg-red-700 shadow-lg shadow-red-600/30 transition-all duration-200"
@click="hangupCall"
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
</button>
<!-- Send to Voicemail (if incoming) -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
type="button"
:title="$t('call.send_to_voicemail')"
class="p-3 rounded-full bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/30 transition-all duration-200"
@click="sendToVoicemail"
>
<MaterialDesignIcon icon-name="voicemail" class="size-6" />
</button>
<!-- Answer (if incoming) -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
type="button"
:title="$t('call.answer_call')"
class="p-3 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce"
@click="answerCall"
>
<MaterialDesignIcon icon-name="phone" class="size-6" />
</button>
</div>
</div>
<!-- Minimized State -->
<div
v-show="isMinimized && !isEnded"
class="px-4 py-2 flex items-center justify-between bg-white dark:bg-zinc-900"
>
<div class="flex items-center space-x-2 overflow-hidden mr-2 min-w-0">
<LxmfUserIcon
v-if="activeCall.remote_icon"
:icon-name="activeCall.remote_icon.icon_name"
:icon-foreground-colour="activeCall.remote_icon.foreground_colour"
:icon-background-colour="activeCall.remote_icon.background_colour"
class="size-5 shrink-0"
/>
<MaterialDesignIcon v-else icon-name="account" class="size-5 text-blue-500 shrink-0" />
<span class="text-sm font-medium text-gray-700 dark:text-zinc-200 truncate block">
{{ activeCall.remote_identity_name || $t("call.unknown") }}
</span>
</div>
<div class="flex items-center space-x-1">
<button
type="button"
class="p-1.5 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded transition-colors"
@click="toggleMicrophone"
>
<MaterialDesignIcon
:icon-name="isMicMuted ? 'microphone-off' : 'microphone'"
class="size-4"
:class="isMicMuted ? 'text-red-500' : 'text-gray-400'"
/>
</button>
<button
type="button"
class="p-1.5 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors"
@click="hangupCall"
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-4 text-red-500 rotate-[135deg]" />
</button>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import Utils from "../../js/Utils";
import ToastUtils from "../../js/ToastUtils";
export default {
name: "CallOverlay",
components: { MaterialDesignIcon, LxmfUserIcon },
props: {
activeCall: {
type: Object,
required: true,
},
isEnded: {
type: Boolean,
default: false,
},
wasDeclined: {
type: Boolean,
default: false,
},
},
data() {
return {
isMinimized: false,
};
},
computed: {
isMicMuted() {
return this.activeCall?.is_mic_muted ?? false;
},
isSpeakerMuted() {
return this.activeCall?.is_speaker_muted ?? false;
},
},
methods: {
formatDestinationHash(hash) {
return Utils.formatDestinationHash(hash);
},
formatBytes(bytes) {
return Utils.formatBytes(bytes || 0);
},
async answerCall() {
try {
await window.axios.get("/api/v1/telephone/answer");
} catch {
ToastUtils.error("Failed to answer call");
}
},
async hangupCall() {
try {
this.$emit("hangup");
await window.axios.get("/api/v1/telephone/hangup");
} catch {
ToastUtils.error("Failed to hangup call");
}
},
async sendToVoicemail() {
try {
await window.axios.get("/api/v1/telephone/send-to-voicemail");
ToastUtils.success("Call sent to voicemail");
} catch {
ToastUtils.error("Failed to send call to voicemail");
}
},
async toggleMicrophone() {
try {
const endpoint = this.isMicMuted
? "/api/v1/telephone/unmute-transmit"
: "/api/v1/telephone/mute-transmit";
await window.axios.get(endpoint);
// eslint-disable-next-line vue/no-mutating-props
this.activeCall.is_mic_muted = !this.isMicMuted;
} catch {
ToastUtils.error("Failed to toggle microphone");
}
},
async toggleSpeaker() {
try {
const endpoint = this.isSpeakerMuted
? "/api/v1/telephone/unmute-receive"
: "/api/v1/telephone/mute-receive";
await window.axios.get(endpoint);
// eslint-disable-next-line vue/no-mutating-props
this.activeCall.is_speaker_muted = !this.isSpeakerMuted;
} catch {
ToastUtils.error("Failed to toggle speaker");
}
},
},
};
</script>