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
42 changes: 37 additions & 5 deletions webui/src/avdummy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ const DRAW_MAX = 150
let drawLoopIsRunning = false
let drawCount = 0

/* One shared AudioContext: Chrome hard-caps a page at ~6 of them, so a new
* one per listen()/hangup() cycle eventually throws. */
let actx: AudioContext | null = null

function draw() {
if (!ctx)
return
Expand All @@ -21,7 +25,8 @@ function draw() {
ctx.textAlign = "center"
ctx.fillStyle = "gray";
ctx.fillText(State.user.value.name, width / 2, height / 2 + image.height / 2);
ctx.drawImage(image, width / 2 - (image.width / 2), (height / 2 - image.height / 2) - 48)
if (image.complete && image.naturalWidth)
ctx.drawImage(image, width / 2 - (image.width / 2), (height / 2 - image.height / 2) - 48)
}

function drawLoop() {
Expand All @@ -47,17 +52,32 @@ const black = () => {

ctx.fillRect(0, 0, width, height)

image.src = '/avatars/' + api.user_id() + '.png'
image.onload = () => {
const start = () => {
drawCount = 0
//Chrome workaround: needs canvas frame change to start webrtc rtp
drawLoop()
}

image.src = '/avatars/' + api.user_id() + '.png'
image.onload = start
/* without this a missing avatar means the canvas never changes, and on
* Chrome the dummy video track then never starts sending RTP */
image.onerror = () => {
console.warn('avdummy: avatar image could not be loaded')
start()
}

return stream.getVideoTracks()[0]
}

const silence = () => {
const actx = new AudioContext()
if (!actx || actx.state === 'closed')
actx = new AudioContext()

/* created outside a user gesture the context starts suspended */
if (actx.state === 'suspended')
actx.resume().catch((e) => console.log('avdummy: audiocontext resume', e))

const oscillator = actx.createOscillator()
const dst = actx.createMediaStreamDestination()
oscillator.connect(dst)
Expand All @@ -69,6 +89,10 @@ export const Avdummy = {
stream: <null | MediaStream>null,

async init() {
/* reuse while the tracks are still live */
if (this.stream?.getTracks().every((t) => t.readyState === 'live'))
return

this.stream = new MediaStream([black(), silence()])
},

Expand All @@ -90,5 +114,13 @@ export const Avdummy = {

async stopDrawLoop() {
drawCount = DRAW_MAX
}
},

close() {
this.stopDrawLoop()
this.stream?.getTracks().forEach((track) => track.stop())
this.stream = null
actx?.close().catch((e) => console.log('avdummy: audiocontext close', e))
actx = null
},
}
31 changes: 21 additions & 10 deletions webui/src/components/SettingsModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
v-slot="{ active, checked }"
>
<div
@click="on_video_option(option)"
:class="[
active ? 'ring-2 ring-offset-2 ring-indigo-500' : '',
checked
Expand Down Expand Up @@ -185,13 +186,16 @@ import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } fro
import { RadioGroup, RadioGroupLabel, RadioGroupOption } from '@headlessui/vue'
import { Cog6ToothIcon, MicrophoneIcon } from '@heroicons/vue/24/outline'
import { State } from '../ws/state'
import { Webrtc, WebrtcState } from '../webrtc'
import { ref, watch } from 'vue'
import { Webrtc, WebrtcState, has_display_media, pc_screen_request } from '../webrtc'
import { computed, nextTick, ref, watch } from 'vue'
import api from '../api'
import { Error } from '../error'

const open = State.settings_active
const videoOptions = ['Disabled', 'Camera', 'Screen']
/* iOS Safari has no getDisplayMedia at all - do not offer what cannot work */
const videoOptions = computed(() =>
has_display_media() ? ['Disabled', 'Camera', 'Screen'] : ['Disabled', 'Camera']
)
const video_select = Webrtc.video_select
const video_resolution = Webrtc.video_resolution
const audio_input_id = Webrtc.audio_input_id
Expand All @@ -201,13 +205,20 @@ const echo = Webrtc.echo
const video_echo = ref<HTMLVideoElement>()
const video_error = Error.video

watch(video_select, async (newValue, oldValue) => {
const interval = setInterval(async () => {
if (video_echo.value) {
clearInterval(interval)
video_echo.value.srcObject = await Webrtc.change_video()
}
}, 50)
/* Has to run inside the click handler: Safari ties transient user activation
* to the current task, so a getDisplayMedia() started from the watcher below
* (a microtask, previously a 50ms timer) is rejected with InvalidAccessError. */
function on_video_option(option: string) {
if (option === 'Screen' && video_select.value !== 'Screen') pc_screen_request()
}

/* The old setInterval polled for the video ref and never cleared itself when
* the modal closed first, leaking a timer and racing concurrent
* change_video() calls against each other. */
watch(video_select, async () => {
const stream = await Webrtc.change_video()
await nextTick()
if (video_echo.value) video_echo.value.srcObject = stream
})

watch(audio_input_id, async (newValue, oldValue) => {
Expand Down
22 changes: 19 additions & 3 deletions webui/src/webcam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,31 @@ export default {
},

async start() {
if (!navigator.mediaDevices?.getUserMedia) {
console.error('webcam: getUserMedia unavailable (insecure origin?)')
return
}

/* stop the previous stream first: iOS Safari only allows one camera
* stream at a time and silently tears the old one down otherwise */
videoStream?.getTracks().forEach((t) => t.stop())
videoStream = undefined

try {
/* 'ideal' so a stale stored id falls back to the default camera
* instead of rejecting with OverconstrainedError */
constraintsVideo.video.deviceId = this.deviceId.value
? { ideal: this.deviceId.value }
: undefined
videoStream = await navigator.mediaDevices.getUserMedia(constraintsVideo)
} catch (e) {
console.error(`An error occurred: ${e}`)
}
this.deviceId.value = videoStream?.getVideoTracks()[0].getSettings().deviceId
this.deviceId.value = videoStream?.getVideoTracks()[0]?.getSettings().deviceId
if (hvideo && videoStream) {
hvideo.srcObject = videoStream
hvideo.play()
/* play() rejects, it does not throw */
hvideo.play().catch((e) => console.log('webcam: play', e))
}

this.picture.value = undefined
Expand All @@ -61,7 +76,8 @@ export default {
},

stop() {
videoStream?.getVideoTracks()[0].stop()
videoStream?.getTracks().forEach((t) => t.stop())
videoStream = undefined
},

takePicture(canvas: HTMLCanvasElement | null, video: HTMLVideoElement | null) {
Expand Down
Loading
Loading