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
8 changes: 3 additions & 5 deletions app/components/ScreenshareButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ interface ScreenshareButtonProps {}

export const ScreenshareButton: FC<ScreenshareButtonProps> = () => {
const {
userMedia: { screenShareVideoTrack, startScreenShare, endScreenShare },
userMedia: { startScreenShare, endScreenShare, screenShareEnabled },
} = useRoomContext()

const sharing = screenShareVideoTrack !== undefined

const [canShareScreen, setCanShareScreen] = useState(true)

// setting this in a useEffect because we need to do this feature
Expand All @@ -31,8 +29,8 @@ export const ScreenshareButton: FC<ScreenshareButtonProps> = () => {

return (
<Button
displayType={sharing ? 'danger' : 'secondary'}
onClick={sharing ? endScreenShare : startScreenShare}
displayType={screenShareEnabled ? 'danger' : 'secondary'}
onClick={screenShareEnabled ? endScreenShare : startScreenShare}
>
<VisuallyHidden>Share screen</VisuallyHidden>
<Icon type="screenshare" />
Expand Down
26 changes: 3 additions & 23 deletions app/components/VideoInputSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
import { useObservableAsValue } from 'partytracks/react'
import { useMemo, type FC } from 'react'
import { type FC } from 'react'
import useMediaDevices from '~/hooks/useMediaDevices'
import { useRoomContext } from '~/hooks/useRoomContext'
import { errorMessageMap } from '~/hooks/useUserMedia'
import { getSortedDeviceListObservable } from '~/utils/rxjs/getDeviceListObservable'
import { Option, Select } from './Select'

export const VideoInputSelector: FC<{ id?: string }> = ({ id }) => {
const videoInputDevices = useMediaDevices((d) => d.kind === 'videoinput')
const sortedDeviceListObservable$ = useMemo(
() => getSortedDeviceListObservable(),
[]
)
const sortedDeviceList = useObservableAsValue(sortedDeviceListObservable$, [])

const {
userMedia: {
videoUnavailableReason,
videoDeviceId,
setVideoDeviceId,
videoEnabled,
},
userMedia: { videoUnavailableReason, videoDeviceId, setVideoDeviceId },
} = useRoomContext()

if (videoUnavailableReason) {
Expand All @@ -37,17 +25,9 @@ export const VideoInputSelector: FC<{ id?: string }> = ({ id }) => {
)
}

// we can only rely on videoDeviceId when the webcam is enabled because
// when it's not, the device id is being pulled from our black canvas track
// so we will instead fall back to show the user's preferred webcam that
// we would _try_ to acquire the next time they enable their webcam.
const shownDeviceId = videoEnabled
? videoDeviceId
: sortedDeviceList.find((d) => d.kind === 'videoinput')?.deviceId

return (
<div className="max-w-[40ch]">
<Select value={shownDeviceId} onValueChange={setVideoDeviceId} id={id}>
<Select value={videoDeviceId} onValueChange={setVideoDeviceId} id={id}>
{videoInputDevices.map((d) => (
<Option key={d.deviceId} value={d.deviceId}>
{d.label}
Expand Down
1 change: 0 additions & 1 deletion app/hooks/useMediaDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export default function useMediaDevices(
let mounted = true
const requestDevices = () => {
navigator.mediaDevices.enumerateDevices().then((d) => {
console.log(`enumerateDevices with filter fn: ${filterSource} `, d)
if (mounted) setDevices(d)
})
}
Expand Down
2 changes: 1 addition & 1 deletion app/hooks/useStageManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default function useStageManager(
const usersAndScreenshares = useMemo(
() =>
users.concat(self ? [self] : []).flatMap((u) =>
u.tracks.screenshare
u.tracks.screenShareEnabled
? [
u,
{
Expand Down
275 changes: 113 additions & 162 deletions app/hooks/useUserMedia.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { useObservableAsValue, useValueAsObservable } from 'partytracks/react'
import { useCallback, useMemo, useState } from 'react'
import { getCamera, getMic, getScreenshare } from 'partytracks/client'
import { useObservable, useObservableAsValue } from 'partytracks/react'
import { useCallback, useEffect, useState } from 'react'
import { useLocalStorage } from 'react-use'
import { combineLatest, map, of, shareReplay, switchMap, tap } from 'rxjs'
import invariant from 'tiny-invariant'
import { blackCanvasStreamTrack } from '~/utils/blackCanvasStreamTrack'
import blurVideoTrack from '~/utils/blurVideoTrack'
import { mode } from '~/utils/mode'
import noiseSuppression from '~/utils/noiseSuppression'
import { prependDeviceToPrioritizeList } from '~/utils/rxjs/devicePrioritization'
import { getScreenshare$ } from '~/utils/rxjs/getScreenshare$'
import { getUserMediaTrack$ } from '~/utils/rxjs/getUserMediaTrack$'
import { inaudibleAudioTrack$ } from '~/utils/rxjs/inaudibleAudioTrack$'

export const errorMessageMap = {
NotAllowedError:
Expand All @@ -23,178 +18,134 @@ export const errorMessageMap = {

type UserMediaError = keyof typeof errorMessageMap

export default function useUserMedia() {
const [blurVideo, setBlurVideo] = useLocalStorage('blur-video', false)
const broadcastByDefault = mode === 'production'
export const mic = getMic({ broadcasting: broadcastByDefault })
export const camera = getCamera({
broadcasting: true,
constraints: { width: { ideal: 1280 }, height: { ideal: 720 } },
})
export const screenshare = getScreenshare({ audio: false })

function useNoiseSuppression() {
const [suppressNoise, setSuppressNoise] = useLocalStorage(
'suppress-noise',
false
)
// NOTE: in the past we've set this to be false by default in dev
// but as long as we're using the web audio API to generate an inaudible
// audio track we shouldn't do this because the audio track will not have
// any packets flowing out over the peer connection if the audio context
// fails to initialize due to no prior user interaction. If we need
// to require user interaction, we might as well have that interaction
// be for the user to manually mute themselves and not have to work around
// this. Once Calls is handling audio tracks with no data flowing better
// we should be able to go back to muting by default in dev.
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
const [screenShareEnabled, setScreenShareEnabled] = useState(false)
useEffect(() => {
if (suppressNoise) mic.addTransform(noiseSuppression)
return () => {
mic.removeTransform(noiseSuppression)
}
}, [suppressNoise])

return [suppressNoise, setSuppressNoise] as const
}

function useBlurVideo() {
const [blurVideo, setBlurVideo] = useLocalStorage('blur-video', false)
useEffect(() => {
if (blurVideo) camera.addTransform(blurVideoTrack)
return () => {
camera.removeTransform(blurVideoTrack)
}
}, [blurVideo])

return [blurVideo, setBlurVideo] as const
}

function useScreenshare() {
const screenShareIsBroadcasting = useObservableAsValue(
screenshare.video.isBroadcasting$,
false
)
const startScreenShare = useCallback(() => {
screenshare.startBroadcasting()
}, [])
const endScreenShare = useCallback(() => {
screenshare.stopBroadcasting()
}, [])

return {
screenShareEnabled: screenShareIsBroadcasting,
startScreenShare,
endScreenShare,
screenShareVideoTrack$: screenshare.video.broadcastTrack$,
screenShareVideoTrack: useObservableAsValue(
screenshare.video.broadcastTrack$
),
}
}

export default function useUserMedia() {
const [suppressNoise, setSuppressNoise] = useNoiseSuppression()
const [blurVideo, setBlurVideo] = useBlurVideo()

const [videoUnavailableReason, setVideoUnavailableReason] =
useState<UserMediaError>()
const [audioUnavailableReason, setAudioUnavailableReason] =
useState<UserMediaError>()

const turnMicOff = useCallback(() => setAudioEnabled(false), [])
const turnMicOn = useCallback(() => setAudioEnabled(true), [])
const turnCameraOn = useCallback(() => setVideoEnabled(true), [])
const turnCameraOff = useCallback(() => setVideoEnabled(false), [])
const startScreenShare = useCallback(() => setScreenShareEnabled(true), [])
const endScreenShare = useCallback(() => setScreenShareEnabled(false), [])

const blurVideo$ = useValueAsObservable(blurVideo)
const videoEnabled$ = useValueAsObservable(videoEnabled)
const videoTrack$ = useMemo(() => {
const track$ = videoEnabled$.pipe(
switchMap((enabled) =>
enabled
? getUserMediaTrack$('videoinput').pipe(
tap({
error: (e) => {
invariant(e instanceof Error)
const reason =
e.name in errorMessageMap
? (e.name as UserMediaError)
: 'UnknownError'
if (reason === 'UnknownError') {
console.error('Unknown error getting video track: ', e)
}
setVideoUnavailableReason(reason)
setVideoEnabled(false)
},
})
)
: of(undefined)
)
)
return combineLatest([blurVideo$, track$]).pipe(
switchMap(([blur, track]) =>
blur && track
? blurVideoTrack(track)
: of(track ?? blackCanvasStreamTrack())
),
shareReplay({
refCount: true,
bufferSize: 1,
})
)
}, [videoEnabled$, blurVideo$])
const videoTrack = useObservableAsValue(videoTrack$)
const videoDeviceId = videoTrack?.getSettings().deviceId

const suppressNoiseEnabled$ = useValueAsObservable(suppressNoise)
const audioTrack$ = useMemo(() => {
return combineLatest([
getUserMediaTrack$('audioinput').pipe(
tap({
error: (e) => {
invariant(e instanceof Error)
const reason =
e.name in errorMessageMap
? (e.name as UserMediaError)
: 'UnknownError'
if (reason === 'UnknownError') {
console.error('Unknown error getting audio track: ', e)
}
setAudioUnavailableReason(reason)
setAudioEnabled(false)
},
})
),
suppressNoiseEnabled$,
]).pipe(
switchMap(([track, suppressNoise]) =>
of(suppressNoise && track ? noiseSuppression(track) : track)
),
shareReplay({
refCount: true,
bufferSize: 1,
})
)
}, [suppressNoiseEnabled$])

const alwaysOnAudioStreamTrack = useObservableAsValue(audioTrack$)
const audioDeviceId = alwaysOnAudioStreamTrack?.getSettings().deviceId
const audioEnabled$ = useValueAsObservable(audioEnabled)
const publicAudioTrack$ = useMemo(
() =>
audioEnabled$.pipe(
switchMap((enabled) => (enabled ? audioTrack$ : inaudibleAudioTrack$)),
shareReplay({
refCount: true,
bufferSize: 1,
})
),
[audioEnabled$, audioTrack$]
)
const audioStreamTrack = useObservableAsValue(publicAudioTrack$)

const screenShareVideoTrack$ = useMemo(
() =>
screenShareEnabled
? getScreenshare$({ contentHint: 'text' }).pipe(
tap({
next: (ms) => {
if (ms === undefined) {
setScreenShareEnabled(false)
}
},
finalize: () => setScreenShareEnabled(false),
}),
map((ms) => ms?.getVideoTracks()[0])
)
: of(undefined),
[screenShareEnabled]
)
const screenShareVideoTrack = useObservableAsValue(screenShareVideoTrack$)
const {
endScreenShare,
startScreenShare,
screenShareEnabled,
screenShareVideoTrack,
screenShareVideoTrack$,
} = useScreenshare()

const micDevices = useObservableAsValue(mic.devices$, [])
const cameraDevices = useObservableAsValue(camera.devices$, [])

const setVideoDeviceId = (deviceId: string) =>
navigator.mediaDevices.enumerateDevices().then((devices) => {
const device = devices.find((d) => d.deviceId === deviceId)
if (device) prependDeviceToPrioritizeList(device)
})
useObservable(mic.error$, (e) => {
const reason =
e.name in errorMessageMap ? (e.name as UserMediaError) : 'UnknownError'
if (reason === 'UnknownError') {
console.error('Unknown error getting audio track: ', e)
}
setAudioUnavailableReason(reason)
mic.stopBroadcasting()
})

const setAudioDeviceId = (deviceId: string) =>
navigator.mediaDevices.enumerateDevices().then((devices) => {
const device = devices.find((d) => d.deviceId === deviceId)
if (device) prependDeviceToPrioritizeList(device)
})
useObservable(camera.error$, (e) => {
const reason =
e.name in errorMessageMap ? (e.name as UserMediaError) : 'UnknownError'
if (reason === 'UnknownError') {
console.error('Unknown error getting video track: ', e)
}
setVideoUnavailableReason(reason)
camera.stopBroadcasting()
})

return {
turnMicOn,
turnMicOff,
audioStreamTrack,
audioMonitorStreamTrack: alwaysOnAudioStreamTrack,
audioEnabled,
turnMicOn: mic.startBroadcasting,
turnMicOff: mic.stopBroadcasting,
audioStreamTrack: useObservableAsValue(mic.broadcastTrack$),
audioMonitorStreamTrack: useObservableAsValue(mic.localMonitorTrack$),
audioEnabled: useObservableAsValue(mic.isBroadcasting$, broadcastByDefault),
audioUnavailableReason,
publicAudioTrack$,
privateAudioTrack$: audioTrack$,
audioDeviceId,
setAudioDeviceId,

setVideoDeviceId,
videoDeviceId,
turnCameraOn,
turnCameraOff,
videoEnabled,
publicAudioTrack$: mic.broadcastTrack$,
privateAudioTrack$: mic.localMonitorTrack$,
audioDeviceId: useObservableAsValue(mic.activeDevice$)?.deviceId,
setAudioDeviceId: (deviceId: string) => {
const found = micDevices.find((d) => d.deviceId === deviceId)
if (found) mic.setPreferredDevice(found)
},

setVideoDeviceId: (deviceId: string) => {
const found = cameraDevices.find((d) => d.deviceId === deviceId)
if (found) camera.setPreferredDevice(found)
},
videoDeviceId: useObservableAsValue(camera.activeDevice$)?.deviceId,
turnCameraOn: camera.startBroadcasting,
turnCameraOff: camera.stopBroadcasting,
videoEnabled: useObservableAsValue(camera.isBroadcasting$, true),
videoUnavailableReason,
blurVideo,
setBlurVideo,
suppressNoise,
setSuppressNoise,
videoTrack$,
videoStreamTrack: videoTrack,
videoTrack$: camera.broadcastTrack$,
videoStreamTrack: useObservableAsValue(camera.broadcastTrack$),

startScreenShare,
endScreenShare,
Expand Down
Loading
Loading