mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:41:09 +02:00
✨ feat(matrix-web): add VoIP/video call support
- Add VoIP types: CallState, CallType, CallDirection, SimpleCall, CallCallbacks - Add VoIP state management to Matrix store with place/answer/reject/hangup methods - Add mic/camera mute toggle functionality - Create CallView component for active calls with video streams - Create IncomingCallDialog component for incoming call notifications - Enable call buttons in RoomHeader (DMs only) - Integrate call components into chat page
This commit is contained in:
parent
22a0feeacd
commit
c64b4d6ac9
7 changed files with 782 additions and 10 deletions
203
apps/matrix/apps/web/src/lib/components/call/CallView.svelte
Normal file
203
apps/matrix/apps/web/src/lib/components/call/CallView.svelte
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
<script lang="ts">
|
||||
import { matrixStore, type SimpleCall } from '$lib/matrix';
|
||||
import {
|
||||
PhoneDisconnect,
|
||||
Microphone,
|
||||
MicrophoneSlash,
|
||||
VideoCamera,
|
||||
VideoCameraSlash,
|
||||
User,
|
||||
} from '@manacore/shared-icons';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
call: SimpleCall;
|
||||
onHangup?: () => void;
|
||||
}
|
||||
|
||||
let { call, onHangup }: Props = $props();
|
||||
|
||||
// Video refs need to work with bind:this - not reactive
|
||||
let localVideoRef: HTMLVideoElement | undefined = $state();
|
||||
let remoteVideoRef: HTMLVideoElement | undefined = $state();
|
||||
let callDuration = $state(0);
|
||||
let durationInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Start duration timer when call connects
|
||||
$effect(() => {
|
||||
if (call.state === 'connected' && !durationInterval) {
|
||||
durationInterval = setInterval(() => {
|
||||
callDuration++;
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
// Attach local stream to video element
|
||||
$effect(() => {
|
||||
if (localVideoRef && call.localStream) {
|
||||
localVideoRef.srcObject = call.localStream;
|
||||
}
|
||||
});
|
||||
|
||||
// Attach remote stream to video element
|
||||
$effect(() => {
|
||||
if (remoteVideoRef && call.remoteStream) {
|
||||
remoteVideoRef.srcObject = call.remoteStream;
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (durationInterval) {
|
||||
clearInterval(durationInterval);
|
||||
}
|
||||
});
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function handleMicToggle() {
|
||||
matrixStore.toggleMicMute();
|
||||
}
|
||||
|
||||
function handleCameraToggle() {
|
||||
matrixStore.toggleCameraMute();
|
||||
}
|
||||
|
||||
function handleHangup() {
|
||||
matrixStore.hangupCall();
|
||||
onHangup?.();
|
||||
}
|
||||
|
||||
function getStateText(state: string): string {
|
||||
switch (state) {
|
||||
case 'invite_sent':
|
||||
return 'Anrufen...';
|
||||
case 'ringing':
|
||||
return 'Klingelt...';
|
||||
case 'connecting':
|
||||
return 'Verbinden...';
|
||||
case 'connected':
|
||||
return formatDuration(callDuration);
|
||||
case 'ended':
|
||||
return 'Beendet';
|
||||
default:
|
||||
return 'Verbinden...';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-[100] bg-zinc-900 flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 bg-black/30">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if call.opponentAvatar}
|
||||
<img
|
||||
src={call.opponentAvatar}
|
||||
alt={call.opponentName}
|
||||
class="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center"
|
||||
>
|
||||
<User class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="font-medium text-white">{call.opponentName || 'Unbekannt'}</p>
|
||||
<p class="text-sm text-white/70">
|
||||
{call.type === 'video' ? 'Videoanruf' : 'Sprachanruf'} · {getStateText(call.state)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video area -->
|
||||
<div class="flex-1 relative">
|
||||
{#if call.type === 'video'}
|
||||
<!-- Remote video (full screen) -->
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video bind:this={remoteVideoRef} autoplay playsinline class="w-full h-full object-cover"
|
||||
></video>
|
||||
|
||||
<!-- Local video (picture-in-picture) -->
|
||||
<div
|
||||
class="absolute bottom-24 right-4 w-32 h-48 rounded-xl overflow-hidden shadow-xl border-2 border-white/20"
|
||||
>
|
||||
<video
|
||||
bind:this={localVideoRef}
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
class="w-full h-full object-cover"
|
||||
></video>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Voice call - show avatar -->
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
{#if call.opponentAvatar}
|
||||
<img
|
||||
src={call.opponentAvatar}
|
||||
alt={call.opponentName}
|
||||
class="w-32 h-32 rounded-full object-cover mb-6 ring-4 ring-white/20"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-32 h-32 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center mb-6 ring-4 ring-white/20"
|
||||
>
|
||||
<User class="w-16 h-16 text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-2xl font-semibold text-white">{call.opponentName || 'Unbekannt'}</p>
|
||||
<p class="text-lg text-white/70 mt-2">{getStateText(call.state)}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div
|
||||
class="flex items-center justify-center gap-6 p-8 bg-gradient-to-t from-black/50 to-transparent"
|
||||
>
|
||||
<!-- Mute mic -->
|
||||
<button
|
||||
class="w-14 h-14 rounded-full flex items-center justify-center transition-colors
|
||||
{call.isMicMuted ? 'bg-red-500 hover:bg-red-600' : 'bg-white/20 hover:bg-white/30'}"
|
||||
onclick={handleMicToggle}
|
||||
title={call.isMicMuted ? 'Mikrofon aktivieren' : 'Mikrofon stumm'}
|
||||
>
|
||||
{#if call.isMicMuted}
|
||||
<MicrophoneSlash class="w-6 h-6 text-white" />
|
||||
{:else}
|
||||
<Microphone class="w-6 h-6 text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Mute camera (video calls only) -->
|
||||
{#if call.type === 'video'}
|
||||
<button
|
||||
class="w-14 h-14 rounded-full flex items-center justify-center transition-colors
|
||||
{call.isCameraMuted ? 'bg-red-500 hover:bg-red-600' : 'bg-white/20 hover:bg-white/30'}"
|
||||
onclick={handleCameraToggle}
|
||||
title={call.isCameraMuted ? 'Kamera aktivieren' : 'Kamera aus'}
|
||||
>
|
||||
{#if call.isCameraMuted}
|
||||
<VideoCameraSlash class="w-6 h-6 text-white" />
|
||||
{:else}
|
||||
<VideoCamera class="w-6 h-6 text-white" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Hang up -->
|
||||
<button
|
||||
class="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center transition-colors"
|
||||
onclick={handleHangup}
|
||||
title="Auflegen"
|
||||
>
|
||||
<PhoneDisconnect class="w-7 h-7 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
<script lang="ts">
|
||||
import { matrixStore, type SimpleCall } from '$lib/matrix';
|
||||
import { Phone, PhoneDisconnect, VideoCamera, User } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
call: SimpleCall;
|
||||
onAnswer?: () => void;
|
||||
onReject?: () => void;
|
||||
}
|
||||
|
||||
let { call, onAnswer, onReject }: Props = $props();
|
||||
|
||||
function handleAnswer() {
|
||||
matrixStore.answerCall();
|
||||
onAnswer?.();
|
||||
}
|
||||
|
||||
function handleReject() {
|
||||
matrixStore.rejectCall();
|
||||
onReject?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div class="bg-zinc-900 rounded-3xl p-8 shadow-2xl max-w-sm w-full mx-4 animate-bounce-in">
|
||||
<!-- Caller info -->
|
||||
<div class="flex flex-col items-center text-center mb-8">
|
||||
{#if call.opponentAvatar}
|
||||
<img
|
||||
src={call.opponentAvatar}
|
||||
alt={call.opponentName}
|
||||
class="w-24 h-24 rounded-full object-cover mb-4 ring-4 ring-violet-500/50 animate-pulse"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="w-24 h-24 rounded-full bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center mb-4 ring-4 ring-violet-500/50 animate-pulse"
|
||||
>
|
||||
<User class="w-12 h-12 text-white" />
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-xl font-semibold text-white">{call.opponentName || 'Unbekannt'}</p>
|
||||
<p class="text-white/70 mt-1">
|
||||
{call.type === 'video' ? 'Eingehender Videoanruf' : 'Eingehender Sprachanruf'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Call type indicator -->
|
||||
<div class="flex items-center justify-center gap-2 mb-8">
|
||||
{#if call.type === 'video'}
|
||||
<VideoCamera class="w-5 h-5 text-violet-400" />
|
||||
<span class="text-violet-400 text-sm">Video</span>
|
||||
{:else}
|
||||
<Phone class="w-5 h-5 text-green-400" />
|
||||
<span class="text-green-400 text-sm">Audio</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center justify-center gap-8">
|
||||
<!-- Reject -->
|
||||
<button
|
||||
class="w-16 h-16 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center transition-all hover:scale-110 shadow-lg shadow-red-500/30"
|
||||
onclick={handleReject}
|
||||
title="Ablehnen"
|
||||
>
|
||||
<PhoneDisconnect class="w-7 h-7 text-white" />
|
||||
</button>
|
||||
|
||||
<!-- Answer -->
|
||||
<button
|
||||
class="w-16 h-16 rounded-full bg-green-500 hover:bg-green-600 flex items-center justify-center transition-all hover:scale-110 shadow-lg shadow-green-500/30 animate-ring"
|
||||
onclick={handleAnswer}
|
||||
title="Annehmen"
|
||||
>
|
||||
<Phone class="w-7 h-7 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ring {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
10%,
|
||||
30%,
|
||||
50%,
|
||||
70%,
|
||||
90% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounce-in 0.4s ease-out;
|
||||
}
|
||||
|
||||
.animate-ring {
|
||||
animation: ring 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
2
apps/matrix/apps/web/src/lib/components/call/index.ts
Normal file
2
apps/matrix/apps/web/src/lib/components/call/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as CallView } from './CallView.svelte';
|
||||
export { default as IncomingCallDialog } from './IncomingCallDialog.svelte';
|
||||
|
|
@ -16,9 +16,14 @@
|
|||
onMenuClick?: () => void;
|
||||
onInfoClick?: () => void;
|
||||
onSearchClick?: () => void;
|
||||
onVoiceCall?: () => void;
|
||||
onVideoCall?: () => void;
|
||||
}
|
||||
|
||||
let { onMenuClick, onInfoClick, onSearchClick }: Props = $props();
|
||||
let { onMenuClick, onInfoClick, onSearchClick, onVoiceCall, onVideoCall }: Props = $props();
|
||||
|
||||
// Check if calls are possible (DMs only for now)
|
||||
let canCall = $derived(matrixStore.currentSimpleRoom?.isDirect ?? false);
|
||||
|
||||
let room = $derived(matrixStore.currentSimpleRoom);
|
||||
let cryptoReady = $derived(matrixStore.cryptoReady);
|
||||
|
|
@ -111,18 +116,22 @@
|
|||
<MagnifyingGlass class="h-5 w-5 text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
|
||||
title="Sprachanruf"
|
||||
disabled
|
||||
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm transition-colors
|
||||
{canCall ? 'hover:bg-green-500/10 hover:text-green-500' : 'opacity-40 cursor-not-allowed'}"
|
||||
title={canCall ? 'Sprachanruf' : 'Anrufe nur in Direktnachrichten verfügbar'}
|
||||
disabled={!canCall}
|
||||
onclick={onVoiceCall}
|
||||
>
|
||||
<Phone class="h-5 w-5 text-muted-foreground" />
|
||||
<Phone class="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm disabled:opacity-40"
|
||||
title="Videoanruf"
|
||||
disabled
|
||||
class="hidden sm:flex p-2.5 rounded-xl glass-button shadow-sm transition-colors
|
||||
{canCall ? 'hover:bg-violet-500/10 hover:text-violet-500' : 'opacity-40 cursor-not-allowed'}"
|
||||
title={canCall ? 'Videoanruf' : 'Anrufe nur in Direktnachrichten verfügbar'}
|
||||
disabled={!canCall}
|
||||
onclick={onVideoCall}
|
||||
>
|
||||
<VideoCamera class="h-5 w-5 text-muted-foreground" />
|
||||
<VideoCamera class="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2.5 rounded-xl glass-button shadow-sm"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { browser } from '$app/environment';
|
||||
import type { MatrixClient, Room, MatrixEvent, RoomMember as SDKRoomMember } from 'matrix-js-sdk';
|
||||
import { showMessageNotification, canShowNotifications, isDocumentFocused } from '$lib/notifications';
|
||||
import {
|
||||
showMessageNotification,
|
||||
canShowNotifications,
|
||||
isDocumentFocused,
|
||||
} from '$lib/notifications';
|
||||
import type {
|
||||
SyncState,
|
||||
MatrixCredentials,
|
||||
|
|
@ -17,6 +21,11 @@ import type {
|
|||
CrossSigningStatus,
|
||||
PresenceState,
|
||||
UserPresence,
|
||||
SimpleCall,
|
||||
CallCallbacks,
|
||||
CallState as CallStateType,
|
||||
CallType,
|
||||
CallDirection,
|
||||
} from './types';
|
||||
|
||||
const STORAGE_KEY = 'matrix_credentials';
|
||||
|
|
@ -46,6 +55,13 @@ class MatrixStore {
|
|||
private _crossSigningReady = $state(false);
|
||||
private _cryptoCallbacks: CryptoCallbacks = {};
|
||||
|
||||
// VoIP / Call State
|
||||
private _activeCall = $state<SimpleCall | null>(null);
|
||||
private _incomingCall = $state<SimpleCall | null>(null);
|
||||
private _callCallbacks: CallCallbacks = {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private _matrixCall: any = null; // The actual MatrixCall object
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Public Getters
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
|
@ -82,6 +98,20 @@ class MatrixStore {
|
|||
return this._crossSigningReady;
|
||||
}
|
||||
|
||||
// VoIP Getters
|
||||
get activeCall() {
|
||||
return this._activeCall;
|
||||
}
|
||||
get incomingCall() {
|
||||
return this._incomingCall;
|
||||
}
|
||||
get hasActiveCall() {
|
||||
return this._activeCall !== null;
|
||||
}
|
||||
get hasIncomingCall() {
|
||||
return this._incomingCall !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get presence for a specific user
|
||||
*/
|
||||
|
|
@ -331,6 +361,19 @@ class MatrixStore {
|
|||
this._timeline = [...(room.getLiveTimeline().getEvents() || [])];
|
||||
}
|
||||
});
|
||||
|
||||
// Incoming calls
|
||||
// CallEvent is exported from matrix-js-sdk, but CallState needs dynamic import from webrtc module
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this._client.on('Call.incoming' as any, async (call: any) => {
|
||||
console.log('Incoming call:', call.callId);
|
||||
try {
|
||||
const webrtc = await import('matrix-js-sdk/lib/webrtc/call');
|
||||
this.handleIncomingCall(call, webrtc.CallEvent, webrtc.CallState);
|
||||
} catch (err) {
|
||||
console.error('Error handling incoming call:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1270,6 +1313,283 @@ class MatrixStore {
|
|||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// VoIP / Call Actions
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set call callbacks for UI notifications
|
||||
*/
|
||||
setCallCallbacks(callbacks: CallCallbacks) {
|
||||
this._callCallbacks = callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a voice call to the current room
|
||||
*/
|
||||
async placeVoiceCall(roomId?: string): Promise<boolean> {
|
||||
const targetRoomId = roomId || this._currentRoomId;
|
||||
if (!this._client || !targetRoomId) return false;
|
||||
|
||||
try {
|
||||
// Import WebRTC types from the submodule
|
||||
const webrtc = await import('matrix-js-sdk/lib/webrtc/call');
|
||||
const { CallEvent, CallState } = webrtc;
|
||||
|
||||
// Create the call
|
||||
const call = this._client.createCall(targetRoomId);
|
||||
if (!call) {
|
||||
console.error('Failed to create call');
|
||||
return false;
|
||||
}
|
||||
|
||||
this._matrixCall = call;
|
||||
|
||||
// Set up event handlers
|
||||
this.setupCallEventHandlers(call, CallEvent, CallState);
|
||||
|
||||
// Place the voice call
|
||||
await call.placeVoiceCall();
|
||||
|
||||
// Update active call state
|
||||
this._activeCall = this.matrixCallToSimpleCall(call, 'voice', 'outbound');
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error placing voice call:', err);
|
||||
this._error = 'Failed to start voice call';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a video call to the current room
|
||||
*/
|
||||
async placeVideoCall(roomId?: string): Promise<boolean> {
|
||||
const targetRoomId = roomId || this._currentRoomId;
|
||||
if (!this._client || !targetRoomId) return false;
|
||||
|
||||
try {
|
||||
// Import WebRTC types from the submodule
|
||||
const webrtc = await import('matrix-js-sdk/lib/webrtc/call');
|
||||
const { CallEvent, CallState } = webrtc;
|
||||
|
||||
// Create the call
|
||||
const call = this._client.createCall(targetRoomId);
|
||||
if (!call) {
|
||||
console.error('Failed to create call');
|
||||
return false;
|
||||
}
|
||||
|
||||
this._matrixCall = call;
|
||||
|
||||
// Set up event handlers
|
||||
this.setupCallEventHandlers(call, CallEvent, CallState);
|
||||
|
||||
// Place the video call
|
||||
await call.placeVideoCall();
|
||||
|
||||
// Update active call state
|
||||
this._activeCall = this.matrixCallToSimpleCall(call, 'video', 'outbound');
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error placing video call:', err);
|
||||
this._error = 'Failed to start video call';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer an incoming call
|
||||
*/
|
||||
async answerCall(): Promise<boolean> {
|
||||
if (!this._matrixCall || !this._incomingCall) return false;
|
||||
|
||||
try {
|
||||
await this._matrixCall.answer();
|
||||
this._activeCall = { ...this._incomingCall };
|
||||
this._incomingCall = null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error answering call:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject an incoming call
|
||||
*/
|
||||
rejectCall(): boolean {
|
||||
if (!this._matrixCall || !this._incomingCall) return false;
|
||||
|
||||
try {
|
||||
this._matrixCall.reject();
|
||||
this._incomingCall = null;
|
||||
this._matrixCall = null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error rejecting call:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang up the current call
|
||||
*/
|
||||
hangupCall(): boolean {
|
||||
if (!this._matrixCall) return false;
|
||||
|
||||
try {
|
||||
this._matrixCall.hangup('user_hangup', false);
|
||||
this._activeCall = null;
|
||||
this._matrixCall = null;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error hanging up call:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle microphone mute
|
||||
*/
|
||||
toggleMicMute(): boolean {
|
||||
if (!this._matrixCall || !this._activeCall) return false;
|
||||
|
||||
try {
|
||||
const muted = this._matrixCall.isMicrophoneMuted();
|
||||
this._matrixCall.setMicrophoneMuted(!muted);
|
||||
this._activeCall = { ...this._activeCall, isMicMuted: !muted };
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling mic mute:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle camera mute (for video calls)
|
||||
*/
|
||||
toggleCameraMute(): boolean {
|
||||
if (!this._matrixCall || !this._activeCall) return false;
|
||||
|
||||
try {
|
||||
const muted = this._matrixCall.isLocalVideoMuted();
|
||||
this._matrixCall.setLocalVideoMuted(!muted);
|
||||
this._activeCall = { ...this._activeCall, isCameraMuted: !muted };
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error toggling camera mute:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up call event handlers
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private setupCallEventHandlers(call: any, CallEvent: any, CallState: any) {
|
||||
// State changes
|
||||
call.on(CallEvent.State, (state: string, oldState: string) => {
|
||||
console.log(`Call state: ${oldState} -> ${state}`);
|
||||
|
||||
if (this._activeCall) {
|
||||
this._activeCall = {
|
||||
...this._activeCall,
|
||||
state: state as CallStateType,
|
||||
};
|
||||
this._callCallbacks.onCallStateChange?.(this._activeCall);
|
||||
}
|
||||
|
||||
// Handle call ending
|
||||
if (state === CallState.Ended) {
|
||||
const reason = call.hangupReason;
|
||||
const endedCall = this._activeCall;
|
||||
this._activeCall = null;
|
||||
this._matrixCall = null;
|
||||
if (endedCall) {
|
||||
this._callCallbacks.onCallEnded?.(endedCall, reason);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Feeds changed (audio/video streams)
|
||||
call.on(CallEvent.FeedsChanged, (feeds: any[]) => {
|
||||
if (this._activeCall) {
|
||||
const localFeed = feeds.find((f) => f.isLocal());
|
||||
const remoteFeed = feeds.find((f) => !f.isLocal());
|
||||
|
||||
this._activeCall = {
|
||||
...this._activeCall,
|
||||
localStream: localFeed?.stream,
|
||||
remoteStream: remoteFeed?.stream,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Error handling
|
||||
call.on(CallEvent.Error, (error: any) => {
|
||||
console.error('Call error:', error);
|
||||
this._error = `Call error: ${error.message || 'Unknown error'}`;
|
||||
});
|
||||
|
||||
// Hangup
|
||||
call.on(CallEvent.Hangup, () => {
|
||||
const endedCall = this._activeCall;
|
||||
this._activeCall = null;
|
||||
this._matrixCall = null;
|
||||
if (endedCall) {
|
||||
this._callCallbacks.onCallEnded?.(endedCall, call.hangupReason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming call from the SDK
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private handleIncomingCall(call: any, CallEvent: any, CallState: any) {
|
||||
this._matrixCall = call;
|
||||
|
||||
// Determine call type from the call object
|
||||
const callType: CallType = call.type === 'video' ? 'video' : 'voice';
|
||||
|
||||
// Create simple call representation
|
||||
const simpleCall = this.matrixCallToSimpleCall(call, callType, 'inbound');
|
||||
this._incomingCall = simpleCall;
|
||||
|
||||
// Set up event handlers
|
||||
this.setupCallEventHandlers(call, CallEvent, CallState);
|
||||
|
||||
// Notify UI
|
||||
this._callCallbacks.onIncomingCall?.(simpleCall);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert MatrixCall to SimpleCall
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private matrixCallToSimpleCall(call: any, type: CallType, direction: CallDirection): SimpleCall {
|
||||
const opponent = call.getOpponentMember?.();
|
||||
const room = this._client?.getRoom(call.roomId);
|
||||
|
||||
return {
|
||||
callId: call.callId,
|
||||
roomId: call.roomId,
|
||||
state: (call.state || 'fledgling') as CallStateType,
|
||||
type,
|
||||
direction,
|
||||
opponentUserId: opponent?.userId,
|
||||
opponentName: opponent?.name || room?.name || 'Unbekannt',
|
||||
opponentAvatar: opponent?.getAvatarUrl?.(this._client?.baseUrl || '', 48, 48, 'scale'),
|
||||
isMicMuted: call.isMicrophoneMuted?.() || false,
|
||||
isCameraMuted: call.isLocalVideoMuted?.() || false,
|
||||
isScreenSharing: false,
|
||||
isRemoteOnHold: call.isRemoteOnHold?.() || false,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// Cleanup
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
|
@ -1278,6 +1598,15 @@ class MatrixStore {
|
|||
* Stop the client and clean up
|
||||
*/
|
||||
destroy() {
|
||||
// Hang up any active call
|
||||
if (this._matrixCall) {
|
||||
try {
|
||||
this._matrixCall.hangup('user_hangup', false);
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
this._client?.stopClient();
|
||||
this._client = null;
|
||||
this._syncState = 'STOPPED';
|
||||
|
|
@ -1293,6 +1622,11 @@ class MatrixStore {
|
|||
this._keyBackupEnabled = false;
|
||||
this._crossSigningReady = false;
|
||||
this._cryptoCallbacks = {};
|
||||
// Reset call state
|
||||
this._activeCall = null;
|
||||
this._incomingCall = null;
|
||||
this._matrixCall = null;
|
||||
this._callCallbacks = {};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -247,3 +247,62 @@ export interface CrossSigningStatus {
|
|||
privateKeysInSecretStorage: boolean;
|
||||
privateKeysCachedLocally: boolean;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// VoIP / Call Types
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Call state
|
||||
*/
|
||||
export type CallState =
|
||||
| 'fledgling'
|
||||
| 'invite_sent'
|
||||
| 'wait_local_media'
|
||||
| 'create_offer'
|
||||
| 'create_answer'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'ringing'
|
||||
| 'ended';
|
||||
|
||||
/**
|
||||
* Call type (voice or video)
|
||||
*/
|
||||
export type CallType = 'voice' | 'video';
|
||||
|
||||
/**
|
||||
* Call direction
|
||||
*/
|
||||
export type CallDirection = 'inbound' | 'outbound';
|
||||
|
||||
/**
|
||||
* Simplified call info for UI
|
||||
*/
|
||||
export interface SimpleCall {
|
||||
callId: string;
|
||||
roomId: string;
|
||||
state: CallState;
|
||||
type: CallType;
|
||||
direction: CallDirection;
|
||||
opponentUserId?: string;
|
||||
opponentName?: string;
|
||||
opponentAvatar?: string;
|
||||
startTime?: number;
|
||||
duration?: number;
|
||||
isMicMuted: boolean;
|
||||
isCameraMuted: boolean;
|
||||
isScreenSharing: boolean;
|
||||
isRemoteOnHold: boolean;
|
||||
localStream?: MediaStream;
|
||||
remoteStream?: MediaStream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call event callbacks for UI handling
|
||||
*/
|
||||
export interface CallCallbacks {
|
||||
onIncomingCall?: (call: SimpleCall) => void;
|
||||
onCallStateChange?: (call: SimpleCall) => void;
|
||||
onCallEnded?: (call: SimpleCall, reason?: string) => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,14 @@
|
|||
import CreateRoomDialog from '$lib/components/chat/CreateRoomDialog.svelte';
|
||||
import RoomSettingsPanel from '$lib/components/chat/RoomSettingsPanel.svelte';
|
||||
import SearchDialog from '$lib/components/chat/SearchDialog.svelte';
|
||||
import { CallView, IncomingCallDialog } from '$lib/components/call';
|
||||
import { ChatCircle, Plus, Gear } from '@manacore/shared-icons';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
// Call state
|
||||
let activeCall = $derived(matrixStore.activeCall);
|
||||
let incomingCall = $derived(matrixStore.incomingCall);
|
||||
|
||||
// Start with sidebar closed on mobile
|
||||
let sidebarOpen = $state(browser ? window.innerWidth >= 1024 : true);
|
||||
let showCreateRoom = $state(false);
|
||||
|
|
@ -69,6 +74,31 @@
|
|||
function handleRoomCreated(roomId: string) {
|
||||
matrixStore.selectRoom(roomId);
|
||||
}
|
||||
|
||||
// Call handlers
|
||||
async function handleVoiceCall() {
|
||||
if (matrixStore.currentRoom) {
|
||||
await matrixStore.placeVoiceCall(matrixStore.currentRoom.roomId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVideoCall() {
|
||||
if (matrixStore.currentRoom) {
|
||||
await matrixStore.placeVideoCall(matrixStore.currentRoom.roomId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCallHangup() {
|
||||
// Call ended - UI will update automatically
|
||||
}
|
||||
|
||||
function handleCallAnswer() {
|
||||
// Call answered - UI will update automatically
|
||||
}
|
||||
|
||||
function handleCallReject() {
|
||||
// Call rejected - UI will update automatically
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="chat-layout flex h-full min-h-0 overflow-hidden bg-background relative">
|
||||
|
|
@ -139,6 +169,8 @@
|
|||
onMenuClick={toggleSidebar}
|
||||
onInfoClick={() => (showRoomSettings = true)}
|
||||
onSearchClick={() => (showSearch = true)}
|
||||
onVoiceCall={handleVoiceCall}
|
||||
onVideoCall={handleVideoCall}
|
||||
/>
|
||||
|
||||
<!-- Timeline -->
|
||||
|
|
@ -202,3 +234,13 @@
|
|||
|
||||
<!-- Search Dialog -->
|
||||
<SearchDialog open={showSearch} onClose={() => (showSearch = false)} />
|
||||
|
||||
<!-- Active Call View -->
|
||||
{#if activeCall}
|
||||
<CallView call={activeCall} onHangup={handleCallHangup} />
|
||||
{/if}
|
||||
|
||||
<!-- Incoming Call Dialog -->
|
||||
{#if incomingCall && !activeCall}
|
||||
<IncomingCallDialog call={incomingCall} onAnswer={handleCallAnswer} onReject={handleCallReject} />
|
||||
{/if}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue