Voice and video calls: TURN endpoint and web client calls

Server: GET /voip/turnServer, configured under client_api.turn. A shared
secret (coturn use-auth-secret) gives each user expiring TURN REST API
credentials; static credentials are the fallback; with nothing configured
it answers {} so clients still try a direct connection.

Web client: 1:1 calls (Matrix VoIP via matrix-js-sdk) in rooms with one
other person. Voice and video buttons in the room header, a ringing card
with answer, answer without video and decline, and a call panel that
stays up across rooms: mute, camera, screen sharing, expand, hang up,
ring tones. Call events show in the timeline and room list. Works around
the SDK keeping a failed microphone request forever.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-18 18:04:02 +02:00
1 parent 69ce927b6d
commit 325431a5be
25 files changed
+930 -2

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
<script setup lang="ts">
import type { CallFeed } from 'matrix-js-sdk/lib/webrtc/callFeed'
import { computed, onBeforeUnmount, ref, watch, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { WIcon } from '@warp/ui'
import type { IconName } from '@warp/ui'
import MxcAvatar from '@/components/MxcAvatar.vue'
import { formatDuration } from '@/lib/format'
import { useCall } from '@/stores/call'
// The call on screen: a ringing card for an incoming call, else a panel in
// the corner that a video call can expand to fill the window. It sits above
// every view, so a call goes on while you move between rooms.
const call = useCall()
const router = useRouter()
const expanded = ref(false)
function feedVideo(feed: CallFeed | undefined): MediaStream | undefined {
if (!feed || feed.isVideoMuted() || !feed.stream.getVideoTracks().length) return undefined
return feed.stream
}
const view = computed(() => {
void call.version
const c = call.call
if (!c) return null
const remoteCamera = feedVideo(c.remoteUsermediaFeed)
const remoteScreen = c.remoteScreensharingStream
const localCamera = feedVideo(c.localUsermediaFeed)
const localScreen = c.localScreensharingStream
// Their screen beats their camera on the big stage; whatever is left of
// theirs or yours goes in the small picture.
const main = remoteScreen ?? remoteCamera
const pip = remoteScreen && remoteCamera ? remoteCamera : (localScreen ?? localCamera)
return {
main,
pip,
pipMirrored: pip !== undefined && pip === localCamera,
remoteAudio: c.remoteUsermediaStream,
micMuted: c.isMicrophoneMuted(),
cameraOn: Boolean(localCamera),
sharing: c.isScreensharing(),
remoteOnHold: c.isRemoteOnHold(),
localOnHold: c.isLocalOnHold(),
roomId: c.roomId,
}
})
const stage = computed(() => call.phase !== 'ringing' && Boolean(view.value?.main || view.value?.pip || call.isVideo))
const canShare = typeof navigator !== 'undefined' && Boolean(navigator.mediaDevices?.getDisplayMedia)
const status = computed(() => {
const v = view.value
switch (call.phase) {
case 'ringing':
return call.isVideo ? 'Incoming video call' : 'Incoming voice call'
case 'calling':
return 'Calling…'
case 'connecting':
return 'Connecting…'
case 'connected':
if (v?.localOnHold) return 'You’re on hold'
if (v?.remoteOnHold) return 'On hold'
return formatDuration(call.length)
case 'ended':
return call.endReason
default:
return ''
}
})
// Streams go to the media elements by hand: srcObject is not an attribute.
const mainEl = ref<HTMLVideoElement>()
const pipEl = ref<HTMLVideoElement>()
const audioEl = ref<HTMLAudioElement>()
function bind(el: HTMLMediaElement | undefined, stream: MediaStream | undefined) {
if (!el || el.srcObject === (stream ?? null)) return
el.srcObject = stream ?? null
if (stream) void el.play().catch(() => undefined)
}
watchEffect(() => bind(mainEl.value, view.value?.main), { flush: 'post' })
watchEffect(() => bind(pipEl.value, view.value?.pip), { flush: 'post' })
watchEffect(() => bind(audioEl.value, view.value?.remoteAudio), { flush: 'post' })
watch(
() => call.call,
(c) => {
if (!c) expanded.value = false
},
)
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && expanded.value) expanded.value = false
}
window.addEventListener('keydown', onKey)
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
function openRoom() {
const roomId = view.value?.roomId
if (!roomId) return
expanded.value = false
router.push({ name: 'room', params: { roomId } })
}
interface Control {
icon: IconName
label: string
action: () => void
on?: boolean
tone?: 'danger' | 'accept'
}
const controls = computed<Control[]>(() => {
const v = view.value
if (!v) return []
if (call.phase === 'ringing') {
const list: Control[] = [{ icon: 'phone-off', label: 'Decline', action: call.decline, tone: 'danger' }]
if (call.isVideo) list.push({ icon: 'phone', label: 'Answer without video', action: () => call.answer(false) })
list.push({ icon: call.isVideo ? 'video' : 'phone', label: 'Answer', action: () => call.answer(), tone: 'accept' })
return list
}
if (call.phase === 'ended') return [{ icon: 'close', label: 'Close', action: call.hangup }]
const list: Control[] = [
{ icon: v.micMuted ? 'mic-off' : 'mic', label: v.micMuted ? 'Unmute' : 'Mute', action: call.toggleMic, on: v.micMuted },
{ icon: v.cameraOn ? 'video' : 'video-off', label: v.cameraOn ? 'Turn camera off' : 'Turn camera on', action: call.toggleCamera, on: !v.cameraOn && call.isVideo },
]
if (canShare && call.phase === 'connected') {
list.push({ icon: 'screen-share', label: v.sharing ? 'Stop sharing' : 'Share screen', action: call.toggleScreenShare, on: v.sharing })
}
list.push({ icon: 'phone-off', label: 'Hang up', action: call.hangup, tone: 'danger' })
return list
})
function controlClass(c: Control): string {
if (c.tone === 'danger') return 'bg-danger text-[#ffffff] hover:brightness-110'
if (c.tone === 'accept') return 'bg-success text-[#ffffff] hover:brightness-110'
if (c.on) return 'bg-[#eceeee] text-[#111418] hover:bg-[#ffffff]'
return 'bg-[#ffffff1f] text-[#eceeee] hover:bg-[#ffffff33]'
}
</script>
<template>
<section
v-if="call.call && view"
:class="[
'fixed z-40 flex flex-col overflow-hidden bg-[#111418] text-[#eceeee] shadow-float',
expanded ? 'inset-0' : 'right-4 bottom-4 left-4 rounded-lg sm:left-auto sm:w-[360px]',
]"
:aria-label="`Call with ${call.peerName}`"
:role="call.phase === 'ringing' ? 'alertdialog' : 'region'"
>
<audio ref="audioEl" autoplay class="hidden" />
<div v-if="stage" :class="['relative bg-[#000000]', expanded ? 'min-h-0 flex-1' : 'aspect-video']">
<video v-show="view.main" ref="mainEl" autoplay playsinline muted class="h-full w-full object-contain" />
<div v-if="!view.main" class="absolute inset-0 flex items-center justify-center">
<MxcAvatar :name="call.peerName" :mxc="call.peerAvatarMxc" :size="expanded ? 120 : 72" />
</div>
<video
v-show="view.pip"
ref="pipEl"
autoplay
playsinline
muted
:class="['absolute right-3 bottom-3 w-1/4 min-w-24 rounded-md bg-[#000000] object-cover shadow-float', view.pipMirrored && '-scale-x-100']"
/>
<button
v-if="call.isVideo || view.main"
type="button"
class="absolute top-2 right-2 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-0 bg-[#00000080] text-[#eceeee] hover:bg-[#000000b3]"
:aria-label="expanded ? 'Shrink' : 'Expand'"
:title="expanded ? 'Shrink' : 'Expand'"
@click="expanded = !expanded"
>
<WIcon :name="expanded ? 'minimize' : 'maximize'" :size="18" />
</button>
</div>
<div class="flex items-center gap-3 px-4 pt-4">
<MxcAvatar v-if="!stage" :name="call.peerName" :mxc="call.peerAvatarMxc" :size="48" />
<button type="button" class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-left text-[#eceeee]" @click="openRoom">
<span class="body-strong block truncate">{{ call.peerName }}</span>
<span class="small block truncate text-[#eceeeeb3] tabular-nums" aria-live="polite">{{ status }}</span>
</button>
</div>
<div class="flex items-center justify-center gap-3 px-4 pt-3 pb-4">
<button
v-for="c in controls"
:key="c.label"
type="button"
:class="['flex h-12 w-12 cursor-pointer items-center justify-center rounded-full border-0 transition-colors', controlClass(c)]"
:aria-label="c.label"
:aria-pressed="c.on === undefined || c.tone ? undefined : c.on"
:title="c.label"
@click="c.action()"
>
<WIcon :name="c.icon" :size="22" />
</button>
</div>
</section>
</template>