From bc19ab866f675be199391dab22e6f33d2810649f Mon Sep 17 00:00:00 2001 From: Hani <78293294+h1n054ur@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:02:51 +0500 Subject: [PATCH] feat(client): add microphone passthrough button to controls toolbar (#620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(client): add microphone passthrough button to controls toolbar Add mic toggle button to the bottom controls bar that enables users to share their local microphone with the remote neko session via WebRTC. The server already supports microphone capture (capture.microphone.enabled) but the legacy client had no UI to trigger getUserMedia and send an audio track to the peer connection. Changes: - base.ts: Add enableMicrophone/disableMicrophone methods that call getUserMedia and addTrack/removeTrack on the RTCPeerConnection. Mic is cleaned up automatically on disconnect. - controls.vue: Add mic button (fa-microphone/fa-microphone-slash) between play/pause and volume controls with tooltip and error handling. - en-us.ts: Add i18n strings for mic tooltips and error dialog. * if the error is not io.EOF, log it. Otherwise, it's a normal closure of the track. * tie microphone to active host and auto-disable on control loss --------- Co-authored-by: h1n054ur Co-authored-by: Miroslav Šedivý --- client/src/components/controls.vue | 68 +++++++++++++++++++++++++++++- client/src/locale/en-us.ts | 3 ++ client/src/neko/base.ts | 51 ++++++++++++++++++++++ server/internal/webrtc/manager.go | 7 ++- 4 files changed, 127 insertions(+), 2 deletions(-) diff --git a/client/src/components/controls.vue b/client/src/components/controls.vue index 9307a12b..5ec91a7d 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 edc7f2bb..47f3c47e 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 12828595..8ea87659 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 541675f2..d6b6d9db 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 }