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>
This commit is contained in:
parent
902c849f22
commit
bc19ab866f
4 changed files with 127 additions and 2 deletions
|
|
@ -53,6 +53,24 @@
|
|||
@click.stop.prevent="toggleMedia"
|
||||
/>
|
||||
</li>
|
||||
<li v-if="micAllowed">
|
||||
<i
|
||||
:class="[
|
||||
{ disabled: !playable },
|
||||
microphoneActive ? 'fa-microphone' : 'fa-microphone-slash',
|
||||
microphoneActive ? '' : 'faded',
|
||||
'fas',
|
||||
]"
|
||||
v-tooltip="{
|
||||
content: microphoneActive ? $t('controls.mic_off') : $t('controls.mic_on'),
|
||||
placement: 'top',
|
||||
offset: 5,
|
||||
boundariesElement: 'body',
|
||||
delay: { show: 300, hide: 100 },
|
||||
}"
|
||||
@click.stop.prevent="toggleMicrophone"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<div class="volume">
|
||||
<i
|
||||
|
|
@ -252,7 +270,7 @@
|
|||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component, Prop } from 'vue-property-decorator'
|
||||
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
|
||||
|
||||
@Component({ name: 'neko-controls' })
|
||||
export default class extends Vue {
|
||||
|
|
@ -270,10 +288,23 @@
|
|||
return this.$accessor.remote.hosting
|
||||
}
|
||||
|
||||
get controlling() {
|
||||
return this.$accessor.remote.controlling
|
||||
}
|
||||
|
||||
get implicitHosting() {
|
||||
return this.$accessor.remote.implicitHosting
|
||||
}
|
||||
|
||||
// Microphone is allowed when the user is actively controlling (has host).
|
||||
// With implicit hosting, the controlling getter is true only when the user
|
||||
// has actually been assigned as host (clicked inside the video), not for
|
||||
// everyone by default. This prevents multiple users from sharing their
|
||||
// microphone simultaneously — only the person in control can.
|
||||
get micAllowed() {
|
||||
return this.controlling
|
||||
}
|
||||
|
||||
get volume() {
|
||||
return this.$accessor.video.volume
|
||||
}
|
||||
|
|
@ -319,5 +350,40 @@
|
|||
toggleMute() {
|
||||
this.$accessor.video.toggleMute()
|
||||
}
|
||||
|
||||
microphoneActive = false
|
||||
|
||||
// Auto-disable microphone when the user loses control (e.g. another user
|
||||
// takes host, or admin releases control). This ensures the mic track is
|
||||
// cleaned up and the server-side audio input is freed for the new host.
|
||||
@Watch('controlling')
|
||||
onControllingChanged(isControlling: boolean) {
|
||||
if (!isControlling && this.microphoneActive) {
|
||||
this.$client.disableMicrophone()
|
||||
this.microphoneActive = false
|
||||
}
|
||||
}
|
||||
|
||||
async toggleMicrophone() {
|
||||
if (!this.playable || !this.micAllowed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.microphoneActive) {
|
||||
this.$client.disableMicrophone()
|
||||
this.microphoneActive = false
|
||||
} else {
|
||||
try {
|
||||
await this.$client.enableMicrophone()
|
||||
this.microphoneActive = true
|
||||
} catch (err: any) {
|
||||
this.$swal({
|
||||
title: this.$t('controls.mic_error') as string,
|
||||
text: err.message,
|
||||
icon: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ export abstract class BaseClient extends EventEmitter<BaseEvents> {
|
|||
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<BaseEvents> {
|
|||
this._peer = undefined
|
||||
}
|
||||
|
||||
this.disableMicrophone()
|
||||
|
||||
this._state = 'disconnected'
|
||||
this._displayname = undefined
|
||||
this._id = ''
|
||||
}
|
||||
|
||||
get microphoneActive() {
|
||||
return this._micActive
|
||||
}
|
||||
|
||||
public async enableMicrophone(): Promise<void> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue