diff --git a/client/src/components/controls.vue b/client/src/components/controls.vue
index 9307a12bc..5ec91a7de 100644
--- a/client/src/components/controls.vue
+++ b/client/src/components/controls.vue
@@ -53,6 +53,24 @@
@click.stop.prevent="toggleMedia"
/>
+
diff --git a/client/src/locale/en-us.ts b/client/src/locale/en-us.ts
index edc7f2bb0..47f3c47ef 100644
--- a/client/src/locale/en-us.ts
+++ b/client/src/locale/en-us.ts
@@ -52,6 +52,9 @@ export const controls = {
unlock: 'Unlock Controls',
has: 'You have control',
hasnot: 'You do not have control',
+ mic_on: 'Enable Microphone',
+ mic_off: 'Disable Microphone',
+ mic_error: 'Microphone Error',
}
export const locks = {
diff --git a/client/src/neko/base.ts b/client/src/neko/base.ts
index 12828595d..8ea87659f 100644
--- a/client/src/neko/base.ts
+++ b/client/src/neko/base.ts
@@ -28,6 +28,9 @@ export abstract class BaseClient extends EventEmitter {
protected _state: RTCIceConnectionState = 'disconnected'
protected _id = ''
protected _candidates: RTCIceCandidate[] = []
+ protected _micStream?: MediaStream
+ protected _micSender?: RTCRtpSender
+ protected _micActive = false
get id() {
return this._id
@@ -128,11 +131,59 @@ export abstract class BaseClient extends EventEmitter {
this._peer = undefined
}
+ this.disableMicrophone()
+
this._state = 'disconnected'
this._displayname = undefined
this._id = ''
}
+ get microphoneActive() {
+ return this._micActive
+ }
+
+ public async enableMicrophone(): Promise {
+ if (!this._peer) {
+ this.emit('warn', 'attempting to enable microphone with no peer connection')
+ return
+ }
+
+ if (this._micActive) {
+ this.emit('debug', 'microphone already active')
+ return
+ }
+
+ try {
+ this._micStream = await navigator.mediaDevices.getUserMedia({ audio: true })
+ const audioTrack = this._micStream.getAudioTracks()[0]
+ this._micSender = this._peer.addTrack(audioTrack, this._micStream)
+ this._micActive = true
+ this.emit('info', `microphone enabled: ${audioTrack.label}`)
+ } catch (err: any) {
+ this.emit('error', err)
+ throw err
+ }
+ }
+
+ public disableMicrophone(): void {
+ if (this._micSender && this._peer) {
+ try {
+ this._peer.removeTrack(this._micSender)
+ } catch (err) {
+ this.emit('warn', 'failed to remove mic track from peer', err)
+ }
+ this._micSender = undefined
+ }
+
+ if (this._micStream) {
+ this._micStream.getTracks().forEach((t) => t.stop())
+ this._micStream = undefined
+ }
+
+ this._micActive = false
+ this.emit('info', 'microphone disabled')
+ }
+
public sendData(event: 'wheel' | 'mousemove', data: { x: number; y: number }): void
public sendData(event: 'mousedown' | 'mouseup' | 'keydown' | 'keyup', data: { key: number }): void
public sendData(event: string, data: any) {
diff --git a/server/internal/webrtc/manager.go b/server/internal/webrtc/manager.go
index 541675f2f..d6b6d9db0 100644
--- a/server/internal/webrtc/manager.go
+++ b/server/internal/webrtc/manager.go
@@ -1,7 +1,9 @@
package webrtc
import (
+ "errors"
"fmt"
+ "io"
"net"
"strings"
"sync"
@@ -459,7 +461,10 @@ func (manager *WebRTCManagerCtx) CreatePeer(session types.Session) (*webrtc.Sess
for {
i, _, err := track.Read(buf)
if err != nil {
- logger.Warn().Err(err).Msg("failed read from remote track")
+ // if the error is not io.EOF, log it. Otherwise, it's a normal closure of the track.
+ if !errors.Is(err, io.EOF) {
+ logger.Warn().Err(err).Msg("failed read from remote track")
+ }
break
}