TT#145001 Call - Implement Stage-3-WebRTC-API

- Audio call (Chrome => Chrome)
- Audio call (Firefox => Firefox)
- Audio call (Chrome => Firefox)
- Audio call (Firefox => Chrome)

- Video call (Chrome => Chrome)
- Video call (Firefox => Firefox)

- Error message if JsSIP connection error occurs
- Fix error visibility timer

Change-Id: I20d55ba4a4f217fde0b5d46a2615314915ba81f0
pull/9/head
Hans-Peter Herzog 4 years ago
parent 238f78cb05
commit 83d622fab1

@ -8,30 +8,68 @@ let $baseWebSocketUrl = null
let $subscriber = null let $subscriber = null
let $socket = null let $socket = null
let $userAgent = null let $userAgent = null
let $isVideoScreen = false
let $outgoingRtcSession = null let $outgoingRtcSession = null
let $incomingRtcSession = null let $incomingRtcSession = null
let $localMediaStream = null let $localMediaStream = null
let $remoteMediaStream = null let $remoteMediaStream = null
let $isVideoScreen = false let $videoTransceiver = null
let $audioTransceiver = null
const TERMINATION_OPTIONS = { const TERMINATION_OPTIONS = {
status_code: 603, status_code: 603,
reason_phrase: 'Decline' reason_phrase: 'Decline'
} }
const MEDIA_VIDEO_DEFAULT_CONFIG = {
width: {
ideal: 4096
},
height: {
ideal: 2160
}
}
export const callEvent = new EventEmitter() export const callEvent = new EventEmitter()
function handleRemoteMediaStream (trackEvent) { function callTrackMuteHandler () {
const stream = trackEvent.streams[0] if ($audioTransceiver) {
if (!$remoteMediaStream) { $remoteMediaStream = new MediaStream([
$remoteMediaStream = stream $audioTransceiver.receiver.track
])
callEvent.emit('remoteStream', $remoteMediaStream) callEvent.emit('remoteStream', $remoteMediaStream)
} else if ($remoteMediaStream && $remoteMediaStream.id !== stream.id) { }
$remoteMediaStream = stream }
function callTrackUnMuteHandler () {
if ($audioTransceiver && $videoTransceiver) {
$remoteMediaStream = new MediaStream([
$audioTransceiver.receiver.track,
$videoTransceiver.receiver.track
])
callEvent.emit('remoteStream', $remoteMediaStream) callEvent.emit('remoteStream', $remoteMediaStream)
} }
} }
function handleRemoteMediaStream ({ transceiver }) {
if (!$audioTransceiver && transceiver.receiver.track.kind === 'audio') {
$audioTransceiver = transceiver
} else if (!$videoTransceiver && transceiver.receiver.track.kind === 'video') {
$videoTransceiver = transceiver
$videoTransceiver.receiver.track.onmute = callTrackMuteHandler
$videoTransceiver.receiver.track.onunmute = callTrackUnMuteHandler
}
const tracks = []
if ($audioTransceiver) {
tracks.push($audioTransceiver.receiver.track)
}
if ($videoTransceiver) {
tracks.push($videoTransceiver.receiver.track)
}
$remoteMediaStream = new MediaStream(tracks)
callEvent.emit('remoteStream', $remoteMediaStream)
}
function getSubscriberUri () { function getSubscriberUri () {
return 'sip:' + $subscriber.username + '@' + $subscriber.domain return 'sip:' + $subscriber.username + '@' + $subscriber.domain
} }
@ -118,10 +156,6 @@ export async function callStart ({ number }) {
mediaStream: $localMediaStream mediaStream: $localMediaStream
}) })
$outgoingRtcSession.connection.ontrack = handleRemoteMediaStream $outgoingRtcSession.connection.ontrack = handleRemoteMediaStream
const delegateEvent = (eventName, newName) => {
$outgoingRtcSession.on(eventName, (event) => callEvent.emit(newName, event))
}
delegateEvent('failed', 'outgoingFailed')
} }
export async function callAccept () { export async function callAccept () {
@ -165,56 +199,58 @@ export function callGetRtcSession () {
} }
} }
export async function callChangeVideoStream (stream) { export function callGetRtcConnection () {
if ($localMediaStream && callGetRtcSession()) { return callGetRtcSession().connection
callGetRtcSession().connection.getSenders().forEach( }
sender => callGetRtcSession().connection.removeTrack(sender)
) async function callStopVideo () {
$localMediaStream.getTracks().forEach(track => track.stop()) if ($videoTransceiver && $localMediaStream) {
$localMediaStream = stream $localMediaStream.removeTrack($videoTransceiver.sender.track)
$localMediaStream.getTracks().forEach( $videoTransceiver.sender.track.stop()
track => callGetRtcSession().connection.addTrack(track, $localMediaStream) $videoTransceiver.direction = 'recvonly'
) await $videoTransceiver.sender.replaceTrack(null)
await callRenegotiate() await callRenegotiate()
} }
} }
export async function callAddCamera () { export async function callSendVideo (stream, audioMuted) {
await callChangeVideoStream(await navigator.mediaDevices.getUserMedia({ const videoTrack = stream.getVideoTracks()[0]
video: { if ($videoTransceiver?.sender?.track) {
width: { $localMediaStream.removeTrack($videoTransceiver.sender.track)
ideal: 4096 $videoTransceiver.sender.track.stop()
},
height: {
ideal: 2160
} }
}, $localMediaStream.addTrack(videoTrack)
audio: true callEvent.emit('localStream', $localMediaStream)
})) if (!$videoTransceiver) {
$videoTransceiver = callGetRtcConnection().addTransceiver(videoTrack, { direction: 'sendrecv' })
$videoTransceiver.receiver.track.onmute = callTrackMuteHandler
$videoTransceiver.receiver.track.onunmute = callTrackUnMuteHandler
} else {
$videoTransceiver.direction = 'sendrecv'
await $videoTransceiver.sender.replaceTrack(videoTrack)
}
await callRenegotiate()
}
export async function callAddCamera () {
$isVideoScreen = false $isVideoScreen = false
await callSendVideo(await navigator.mediaDevices.getUserMedia({
video: MEDIA_VIDEO_DEFAULT_CONFIG,
audio: false
}))
} }
export async function callAddScreen () { export async function callAddScreen () {
$isVideoScreen = true $isVideoScreen = true
await callChangeVideoStream(await navigator.mediaDevices.getDisplayMedia({ await callSendVideo(await navigator.mediaDevices.getDisplayMedia({
video: { video: MEDIA_VIDEO_DEFAULT_CONFIG,
width: { audio: false
ideal: 4096
},
height: {
ideal: 2160
}
},
audio: true
})) }))
} }
export async function callRemoveVideo () { export async function callRemoveVideo () {
$isVideoScreen = false $isVideoScreen = false
await callChangeVideoStream(await navigator.mediaDevices.getUserMedia({ await callStopVideo()
video: false,
audio: true
}))
} }
export async function callRenegotiate () { export async function callRenegotiate () {
@ -226,11 +262,11 @@ export async function callRenegotiate () {
} }
export function callHasRemoteVideo () { export function callHasRemoteVideo () {
return $remoteMediaStream?.getVideoTracks?.()?.length > 0 return $videoTransceiver?.receiver?.track?.enabled
} }
export function callHasLocalVideo () { export function callHasLocalVideo () {
return $localMediaStream?.getVideoTracks?.()?.length > 0 return $videoTransceiver?.sender?.track?.enabled
} }
export function callHasLocalCamera () { export function callHasLocalCamera () {
@ -242,21 +278,24 @@ export function callHasLocalScreen () {
} }
export function callToggleMicrophone () { export function callToggleMicrophone () {
const config = { if ($audioTransceiver?.sender?.track) {
audio: true, $audioTransceiver.sender.track.enabled = !$audioTransceiver.sender.track.enabled
video: false
}
const rtcSession = callGetRtcSession()
const muted = rtcSession?.isMuted()
if (muted.audio) {
rtcSession.unmute(config)
} else {
rtcSession.mute(config)
} }
} }
export function callMute () {
return callGetRtcSession()?.mute()
}
export function callUnMute () {
return callGetRtcSession()?.unmute()
}
export function callIsMuted () { export function callIsMuted () {
return callGetRtcSession()?.isMuted()?.audio if ($audioTransceiver?.sender?.track) {
return !$audioTransceiver.sender.track.enabled
}
return false
} }
export function callSendDTMF (tone, transport = 'RFC2833') { export function callSendDTMF (tone, transport = 'RFC2833') {
@ -268,31 +307,59 @@ export function callSendDTMF (tone, transport = 'RFC2833') {
} }
} }
/**
* Enables or disables the remote audio depending on the current state.
*/
export function callToggleRemoteAudio () { export function callToggleRemoteAudio () {
if ($remoteMediaStream && $remoteMediaStream.getAudioTracks()[0]) { if ($audioTransceiver?.receiver?.track) {
$remoteMediaStream.getAudioTracks()[0].enabled = !$remoteMediaStream?.getAudioTracks()[0]?.enabled $audioTransceiver.receiver.track.enabled = !$audioTransceiver.receiver.track.enabled
} }
} }
export function callIsRemoteAudioMuted () { export function callMuteRemote () {
return !$remoteMediaStream?.getAudioTracks()[0]?.enabled if ($audioTransceiver?.receiver?.track) {
$audioTransceiver.receiver.track.enabled = false
}
} }
export function callEnd () { export function callUnMuteRemote () {
if ($outgoingRtcSession && !$outgoingRtcSession.isEnded()) { if ($audioTransceiver?.receiver?.track) {
$outgoingRtcSession.terminate(TERMINATION_OPTIONS) $audioTransceiver.receiver.track.enabled = true
$outgoingRtcSession = null
} }
if ($incomingRtcSession && !$incomingRtcSession.isEnded()) { }
$incomingRtcSession.terminate(TERMINATION_OPTIONS)
$incomingRtcSession = null /**
* Checks whether remote audio is muted or not.
* @returns {boolean}
*/
export function callIsRemoteMuted () {
return !$audioTransceiver?.receiver?.track?.enabled
}
/**
* Terminates the call if not ended and cleans up all related resources.
*/
export function callEnd () {
const rtcSession = callGetRtcSession()
if (rtcSession && !rtcSession.isEnded()) {
rtcSession.terminate(TERMINATION_OPTIONS)
} }
try {
if ($localMediaStream) { if ($localMediaStream) {
$localMediaStream.getTracks().forEach(track => track.stop()) $localMediaStream.getTracks().forEach(track => track.stop())
}
} finally {
$localMediaStream = null $localMediaStream = null
} }
try {
if ($remoteMediaStream) { if ($remoteMediaStream) {
$remoteMediaStream.getTracks().forEach(track => track.stop()) $remoteMediaStream.getTracks().forEach(track => track.stop())
}
} finally {
$remoteMediaStream = null $remoteMediaStream = null
} }
$outgoingRtcSession = null
$incomingRtcSession = null
$audioTransceiver = null
$videoTransceiver = null
} }

@ -1,19 +1,16 @@
import {
v4
} from 'uuid'
import _ from 'lodash' import _ from 'lodash'
import { import {
callConfigure, callConfigure,
callEnd,
callEvent, callEvent,
callHasLocalCamera, callHasLocalCamera,
callHasLocalScreen, callHasLocalScreen,
callHasRemoteVideo, callHasRemoteVideo,
callIsMuted, callMute,
callIsRemoteAudioMuted callMuteRemote,
callUnMute,
callUnMuteRemote
} from 'src/api/ngcp-call' } from 'src/api/ngcp-call'
import { errorVisibilityTimeout } from 'src/store/call/common'
export default async ({ Vue, app, store }) => { export default async ({ Vue, app, store }) => {
callConfigure({ callConfigure({
@ -25,19 +22,21 @@ export default async ({ Vue, app, store }) => {
if (reason?.text) { if (reason?.text) {
cause = reason.text cause = reason.text
} }
if (event.originator !== 'local') { store.dispatch('call/end', { cause })
callEnd()
store.commit('call/endCall', cause)
setTimeout(() => {
store.commit('call/inputNumber')
}, errorVisibilityTimeout)
}
} }
callEvent.on('connected', () => { callEvent.on('connected', () => {
store.commit('call/enableCall') store.commit('call/enableCall')
}) })
callEvent.on('disconnected', () => { callEvent.on('disconnected', ({ error, code }) => {
store.commit('call/disableCall') let errorMessage = null
if (error) {
errorMessage = app.i18n.t('WebSocket connection to kamailio lb failed with code {code}', {
code: code
})
}
store.commit('call/disableCall', {
error: errorMessage
})
}) })
callEvent.on('outgoingProgress', (event) => { callEvent.on('outgoingProgress', (event) => {
store.commit('call/startRinging') store.commit('call/startRinging')
@ -51,17 +50,25 @@ export default async ({ Vue, app, store }) => {
number: _.get(session, 'remote_identity.uri.user', 'Unknown') number: _.get(session, 'remote_identity.uri.user', 'Unknown')
}) })
}) })
callEvent.on('localStream', () => { callEvent.on('localStream', (stream) => {
store.commit('call/toggleMicrophone', !callIsMuted()) if (store.state.call.microphoneEnabled) {
callUnMute()
} else {
callMute()
}
store.commit('call/localMediaSuccess', stream.id)
}) })
callEvent.on('remoteStream', () => { callEvent.on('remoteStream', (stream) => {
if (store.state.call.remoteAudioEnabled) {
callUnMuteRemote()
} else {
callMuteRemote()
}
store.commit('call/establishCall', { store.commit('call/establishCall', {
mediaStreamId: v4(), mediaStreamId: stream.id,
isLocalAudioMuted: callIsMuted(),
hasLocalCamera: callHasLocalCamera(), hasLocalCamera: callHasLocalCamera(),
hasLocalScreen: callHasLocalScreen(), hasLocalScreen: callHasLocalScreen(),
hasRemoteVideo: callHasRemoteVideo(), hasRemoteVideo: callHasRemoteVideo()
isRemoteAudioMuted: callIsRemoteAudioMuted()
}) })
}) })
} }

@ -2,7 +2,11 @@
<q-banner <q-banner
:class="bannerClasses" :class="bannerClasses"
inline-actions inline-actions
rounded
v-bind="$attrs"
v-on="$listeners"
> >
<slot />
<template <template
v-if="icon !== null && icon !== undefined" v-if="icon !== null && icon !== undefined"
v-slot:avatar v-slot:avatar
@ -13,7 +17,6 @@
size="24px" size="24px"
/> />
</template> </template>
<slot />
<template <template
v-slot:action v-slot:action
> >
@ -39,7 +42,7 @@ export default {
}, },
computed: { computed: {
bannerClasses () { bannerClasses () {
return ['text-dark', 'bg-' + this.color] return ['text-weight-bold', 'text-dark', 'bg-' + this.color, 'content-start']
} }
} }
} }

@ -1,6 +1,6 @@
<template> <template>
<csc-inline-alert <csc-inline-alert
icon="alert" icon="error"
color="negative" color="negative"
v-bind="$attrs" v-bind="$attrs"
v-on="$listeners" v-on="$listeners"

@ -471,6 +471,7 @@
"We": "We", "We": "We",
"Web Password": "Web Password", "Web Password": "Web Password",
"Web Password confirm": "Web Password confirm", "Web Password confirm": "Web Password confirm",
"WebSocket connection to kamailio lb failed with code {code}": "WebSocket connection to kamailio lb failed with code {code}",
"Wednesday": "Wednesday", "Wednesday": "Wednesday",
"Weekly": "Weekly", "Weekly": "Weekly",
"When I dial {slot} ...": "When I dial {slot} ...", "When I dial {slot} ...": "When I dial {slot} ...",

@ -6,12 +6,19 @@
<div <div
class="col-xs-10 col-sm-8 col-md-4 csc-opt-center" class="col-xs-10 col-sm-8 col-md-4 csc-opt-center"
> >
<csc-inline-alert-alert
v-if="connectionError"
class="q-mb-md"
>
{{ connectionError }}
</csc-inline-alert-alert>
<csc-input <csc-input
id="csc-call-number-input" id="csc-call-number-input"
:label="$t('Enter a number to dial')" :label="$t('Enter a number to dial')"
:value="callNumberInput" :value="callNumberInput"
:readonly="dialpadOpened" :readonly="dialpadOpened"
clearable clearable
:disable="!isCallEnabled"
@keypress.space.prevent @keypress.space.prevent
@keydown.space.prevent @keydown.space.prevent
@keyup.space.prevent @keyup.space.prevent
@ -47,6 +54,7 @@ import {
import CscCallDialpad from 'components/CscCallDialpad' import CscCallDialpad from 'components/CscCallDialpad'
import CscPage from 'components/CscPage' import CscPage from 'components/CscPage'
import CscInput from 'components/form/CscInput' import CscInput from 'components/form/CscInput'
import CscInlineAlertAlert from 'components/CscInlineAlertAlert'
export default { export default {
name: 'CscPageHome', name: 'CscPageHome',
@ -56,6 +64,7 @@ export default {
} }
}, },
components: { components: {
CscInlineAlertAlert,
CscInput, CscInput,
CscPage, CscPage,
CscCallDialpad CscCallDialpad
@ -75,7 +84,8 @@ export default {
'callNumberInput', 'callNumberInput',
'isCallEnabled', 'isCallEnabled',
'callStateTitle', 'callStateTitle',
'callStateSubtitle' 'callStateSubtitle',
'connectionError'
]), ]),
dialpadOpened () { dialpadOpened () {
return this.callState === 'input' && this.isMobile return this.callState === 'input' && this.isMobile

@ -11,8 +11,13 @@ import {
callIsMuted, callIsMuted,
callSendDTMF, callSendDTMF,
callToggleRemoteAudio, callToggleRemoteAudio,
callIsRemoteAudioMuted, callHasLocalScreen, callHasLocalCamera callIsRemoteMuted,
callHasLocalScreen,
callHasLocalCamera
} from 'src/api/ngcp-call' } from 'src/api/ngcp-call'
import { errorVisibilityTimeout } from 'src/store/call/common'
let errorVisibilityTimer = null
export default { export default {
async start (context, localMedia) { async start (context, localMedia) {
@ -37,6 +42,10 @@ export default {
callToggleMicrophone() callToggleMicrophone()
context.commit('toggleMicrophone', !callIsMuted()) context.commit('toggleMicrophone', !callIsMuted())
}, },
toggleRemoteAudio (context) {
callToggleRemoteAudio()
context.commit('toggleRemoteAudio', !callIsRemoteMuted())
},
async toggleCamera (context) { async toggleCamera (context) {
if (!callHasLocalVideo() || callHasLocalScreen()) { if (!callHasLocalVideo() || callHasLocalScreen()) {
await callAddCamera() await callAddCamera()
@ -46,7 +55,6 @@ export default {
await callRemoveVideo() await callRemoveVideo()
context.commit('disableVideo') context.commit('disableVideo')
} }
context.commit('localMediaSuccess', callGetLocalMediaStreamId())
}, },
async toggleScreen (context) { async toggleScreen (context) {
if (!callHasLocalVideo() || callHasLocalCamera()) { if (!callHasLocalVideo() || callHasLocalCamera()) {
@ -57,17 +65,24 @@ export default {
await callRemoveVideo() await callRemoveVideo()
context.commit('disableVideo') context.commit('disableVideo')
} }
context.commit('localMediaSuccess', callGetLocalMediaStreamId())
}, },
end (context) { end (context, options = { cause: null }) {
callEnd() callEnd()
if (!options.cause) {
if (errorVisibilityTimer) {
clearTimeout(errorVisibilityTimer)
}
context.commit('endCall')
context.commit('hangUpCall')
} else if (options.cause && !errorVisibilityTimer) {
context.commit('endCall', options.cause)
errorVisibilityTimer = setTimeout(() => {
context.commit('hangUpCall') context.commit('hangUpCall')
errorVisibilityTimer = null
}, errorVisibilityTimeout)
}
}, },
sendDTMF (context, tone) { sendDTMF (context, tone) {
callSendDTMF(tone) callSendDTMF(tone)
},
toggleRemoteAudio (context) {
callToggleRemoteAudio()
context.commit('toggleRemoteAudio', !callIsRemoteAudioMuted())
} }
} }

@ -134,5 +134,8 @@ export default {
} else { } else {
return '' return ''
} }
},
connectionError (state) {
return state.connectionError
} }
} }

@ -30,18 +30,14 @@ export default {
}, },
establishCall (state, { establishCall (state, {
mediaStreamId, mediaStreamId,
isLocalAudioMuted = false,
hasLocalCamera = false, hasLocalCamera = false,
hasLocalScreen = false, hasLocalScreen = false,
hasRemoteVideo = false, hasRemoteVideo = false
isRemoteAudioMuted = false
}) { }) {
state.remoteMediaStream = mediaStreamId
state.callState = CallState.established state.callState = CallState.established
state.microphoneEnabled = !isLocalAudioMuted state.remoteMediaStream = mediaStreamId
state.cameraEnabled = hasLocalCamera state.cameraEnabled = hasLocalCamera
state.screenEnabled = hasLocalScreen state.screenEnabled = hasLocalScreen
state.remoteAudioEnabled = !isRemoteAudioMuted
state.remoteVideoEnabled = hasRemoteVideo state.remoteVideoEnabled = hasRemoteVideo
}, },
incomingCall (state, options) { incomingCall (state, options) {
@ -52,37 +48,36 @@ export default {
state.endedReason = null state.endedReason = null
}, },
hangUpCall (state) { hangUpCall (state) {
if (state.callState !== CallState.input) {
state.callState = CallState.input state.callState = CallState.input
state.number = '' state.number = ''
state.numberInput = '' state.numberInput = ''
state.endedReason = null state.endedReason = null
}
}, },
endCall (state, reason) { endCall (state, reason) {
if (state.endedReason === null) { if (reason) {
state.callState = CallState.ended state.callState = CallState.ended
state.endedReason = reason state.endedReason = reason
}
state.dialpadOpened = false
state.microphoneEnabled = true
state.cameraEnabled = false
state.screenEnabled = false
state.remoteAudioEnabled = true
state.remoteVideoEnabled = false
state.localMediaStream = null state.localMediaStream = null
state.remoteMediaStream = null state.remoteMediaStream = null
}
},
sendDTMF (state, value) {
state.dtmf = value
}, },
toggleMicrophone (state, enabled) { toggleMicrophone (state, enabled) {
state.microphoneEnabled = enabled state.microphoneEnabled = enabled
}, },
setCamera (state, enabled) {
state.cameraEnabled = enabled
},
enableCamera (state) { enableCamera (state) {
state.cameraEnabled = true state.cameraEnabled = true
}, },
enableScreen (state) { enableScreen (state) {
state.screenEnabled = true state.screenEnabled = true
}, },
setScreen (state, enabled) {
state.screenEnabled = enabled
},
disableVideo (state) { disableVideo (state) {
state.cameraEnabled = false state.cameraEnabled = false
state.screenEnabled = false state.screenEnabled = false
@ -103,8 +98,10 @@ export default {
}, },
enableCall (state) { enableCall (state) {
state.callEnabled = true state.callEnabled = true
state.connectionError = null
}, },
disableCall (state) { disableCall (state, options = { error: null }) {
state.callEnabled = false state.callEnabled = false
state.connectionError = options.error
} }
} }

@ -4,6 +4,7 @@ import {
} from 'src/store/call/common' } from 'src/store/call/common'
export default { export default {
connectionError: null,
callEnabled: false, callEnabled: false,
endedReason: null, endedReason: null,
callState: CallState.input, callState: CallState.input,

Loading…
Cancel
Save