Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion client/src/components/controls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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>
3 changes: 3 additions & 0 deletions client/src/locale/en-us.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
51 changes: 51 additions & 0 deletions client/src/neko/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion server/internal/webrtc/manager.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package webrtc

import (
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
Expand Down Expand Up @@ -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
}

Expand Down