Skip to content

Commit bc19ab8

Browse files
h1n054urh1n054urm1k1o
authored
feat(client): add microphone passthrough button to controls toolbar (#620)
* 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 <admin@haniumer.com> Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
1 parent 902c849 commit bc19ab8

File tree

4 files changed

+127
-2
lines changed

4 files changed

+127
-2
lines changed

client/src/components/controls.vue

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,24 @@
5353
@click.stop.prevent="toggleMedia"
5454
/>
5555
</li>
56+
<li v-if="micAllowed">
57+
<i
58+
:class="[
59+
{ disabled: !playable },
60+
microphoneActive ? 'fa-microphone' : 'fa-microphone-slash',
61+
microphoneActive ? '' : 'faded',
62+
'fas',
63+
]"
64+
v-tooltip="{
65+
content: microphoneActive ? $t('controls.mic_off') : $t('controls.mic_on'),
66+
placement: 'top',
67+
offset: 5,
68+
boundariesElement: 'body',
69+
delay: { show: 300, hide: 100 },
70+
}"
71+
@click.stop.prevent="toggleMicrophone"
72+
/>
73+
</li>
5674
<li>
5775
<div class="volume">
5876
<i
@@ -252,7 +270,7 @@
252270
</style>
253271

254272
<script lang="ts">
255-
import { Vue, Component, Prop } from 'vue-property-decorator'
273+
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
256274
257275
@Component({ name: 'neko-controls' })
258276
export default class extends Vue {
@@ -270,10 +288,23 @@
270288
return this.$accessor.remote.hosting
271289
}
272290
291+
get controlling() {
292+
return this.$accessor.remote.controlling
293+
}
294+
273295
get implicitHosting() {
274296
return this.$accessor.remote.implicitHosting
275297
}
276298
299+
// Microphone is allowed when the user is actively controlling (has host).
300+
// With implicit hosting, the controlling getter is true only when the user
301+
// has actually been assigned as host (clicked inside the video), not for
302+
// everyone by default. This prevents multiple users from sharing their
303+
// microphone simultaneously — only the person in control can.
304+
get micAllowed() {
305+
return this.controlling
306+
}
307+
277308
get volume() {
278309
return this.$accessor.video.volume
279310
}
@@ -319,5 +350,40 @@
319350
toggleMute() {
320351
this.$accessor.video.toggleMute()
321352
}
353+
354+
microphoneActive = false
355+
356+
// Auto-disable microphone when the user loses control (e.g. another user
357+
// takes host, or admin releases control). This ensures the mic track is
358+
// cleaned up and the server-side audio input is freed for the new host.
359+
@Watch('controlling')
360+
onControllingChanged(isControlling: boolean) {
361+
if (!isControlling && this.microphoneActive) {
362+
this.$client.disableMicrophone()
363+
this.microphoneActive = false
364+
}
365+
}
366+
367+
async toggleMicrophone() {
368+
if (!this.playable || !this.micAllowed) {
369+
return
370+
}
371+
372+
if (this.microphoneActive) {
373+
this.$client.disableMicrophone()
374+
this.microphoneActive = false
375+
} else {
376+
try {
377+
await this.$client.enableMicrophone()
378+
this.microphoneActive = true
379+
} catch (err: any) {
380+
this.$swal({
381+
title: this.$t('controls.mic_error') as string,
382+
text: err.message,
383+
icon: 'error',
384+
})
385+
}
386+
}
387+
}
322388
}
323389
</script>

client/src/locale/en-us.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ export const controls = {
5252
unlock: 'Unlock Controls',
5353
has: 'You have control',
5454
hasnot: 'You do not have control',
55+
mic_on: 'Enable Microphone',
56+
mic_off: 'Disable Microphone',
57+
mic_error: 'Microphone Error',
5558
}
5659

5760
export const locks = {

client/src/neko/base.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
2828
protected _state: RTCIceConnectionState = 'disconnected'
2929
protected _id = ''
3030
protected _candidates: RTCIceCandidate[] = []
31+
protected _micStream?: MediaStream
32+
protected _micSender?: RTCRtpSender
33+
protected _micActive = false
3134

3235
get id() {
3336
return this._id
@@ -128,11 +131,59 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
128131
this._peer = undefined
129132
}
130133

134+
this.disableMicrophone()
135+
131136
this._state = 'disconnected'
132137
this._displayname = undefined
133138
this._id = ''
134139
}
135140

141+
get microphoneActive() {
142+
return this._micActive
143+
}
144+
145+
public async enableMicrophone(): Promise<void> {
146+
if (!this._peer) {
147+
this.emit('warn', 'attempting to enable microphone with no peer connection')
148+
return
149+
}
150+
151+
if (this._micActive) {
152+
this.emit('debug', 'microphone already active')
153+
return
154+
}
155+
156+
try {
157+
this._micStream = await navigator.mediaDevices.getUserMedia({ audio: true })
158+
const audioTrack = this._micStream.getAudioTracks()[0]
159+
this._micSender = this._peer.addTrack(audioTrack, this._micStream)
160+
this._micActive = true
161+
this.emit('info', `microphone enabled: ${audioTrack.label}`)
162+
} catch (err: any) {
163+
this.emit('error', err)
164+
throw err
165+
}
166+
}
167+
168+
public disableMicrophone(): void {
169+
if (this._micSender && this._peer) {
170+
try {
171+
this._peer.removeTrack(this._micSender)
172+
} catch (err) {
173+
this.emit('warn', 'failed to remove mic track from peer', err)
174+
}
175+
this._micSender = undefined
176+
}
177+
178+
if (this._micStream) {
179+
this._micStream.getTracks().forEach((t) => t.stop())
180+
this._micStream = undefined
181+
}
182+
183+
this._micActive = false
184+
this.emit('info', 'microphone disabled')
185+
}
186+
136187
public sendData(event: 'wheel' | 'mousemove', data: { x: number; y: number }): void
137188
public sendData(event: 'mousedown' | 'mouseup' | 'keydown' | 'keyup', data: { key: number }): void
138189
public sendData(event: string, data: any) {

server/internal/webrtc/manager.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package webrtc
22

33
import (
4+
"errors"
45
"fmt"
6+
"io"
57
"net"
68
"strings"
79
"sync"
@@ -459,7 +461,10 @@ func (manager *WebRTCManagerCtx) CreatePeer(session types.Session) (*webrtc.Sess
459461
for {
460462
i, _, err := track.Read(buf)
461463
if err != nil {
462-
logger.Warn().Err(err).Msg("failed read from remote track")
464+
// if the error is not io.EOF, log it. Otherwise, it's a normal closure of the track.
465+
if !errors.Is(err, io.EOF) {
466+
logger.Warn().Err(err).Msg("failed read from remote track")
467+
}
463468
break
464469
}
465470

0 commit comments

Comments
 (0)