Merge feature/98-audio-settings-in-call into video-demo
Resolve VoiceWidget conflict: keep camera, screen share, and call settings. Extend join flow with optional camera on join and device picker. Add camera to in-call settings; rename UI to Call settings. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
||||||
|
import Modal from "@lib/components/Modal.svelte"
|
||||||
|
import ModalBody from "@lib/components/ModalBody.svelte"
|
||||||
|
import ModalFooter from "@lib/components/ModalFooter.svelte"
|
||||||
|
import ModalHeader from "@lib/components/ModalHeader.svelte"
|
||||||
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
||||||
|
import {
|
||||||
|
currentVoiceSession,
|
||||||
|
DeviceKind,
|
||||||
|
supportsAudioOutputSelection,
|
||||||
|
switchVoiceActiveDevice,
|
||||||
|
type VoiceSession,
|
||||||
|
} from "@app/voice"
|
||||||
|
import {popModal} from "@app/util/modal"
|
||||||
|
|
||||||
|
const selectValueForActiveDevice = (session: VoiceSession, kind: DeviceKind): string => {
|
||||||
|
const livekitDeviceId = session.room.getActiveDevice(kind)
|
||||||
|
if (livekitDeviceId === undefined || livekitDeviceId === "" || livekitDeviceId === "default") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return livekitDeviceId
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let audioOutputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let videoInputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let selectedInput = $state("")
|
||||||
|
let selectedOutput = $state("")
|
||||||
|
let selectedVideo = $state("")
|
||||||
|
|
||||||
|
const loadDevices = async () => {
|
||||||
|
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||||
|
try {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
audioInputs = devices.filter(d => d.kind === "audioinput")
|
||||||
|
audioOutputs = devices.filter(d => d.kind === "audiooutput")
|
||||||
|
videoInputs = devices.filter(d => d.kind === "videoinput")
|
||||||
|
} catch {
|
||||||
|
audioInputs = []
|
||||||
|
audioOutputs = []
|
||||||
|
videoInputs = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void loadDevices()
|
||||||
|
const md = navigator.mediaDevices
|
||||||
|
if (!md?.addEventListener) return
|
||||||
|
const onDeviceChange = () => {
|
||||||
|
void loadDevices()
|
||||||
|
}
|
||||||
|
md.addEventListener("devicechange", onDeviceChange)
|
||||||
|
return () => {
|
||||||
|
md.removeEventListener("devicechange", onDeviceChange)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const session = $currentVoiceSession
|
||||||
|
if (!session) {
|
||||||
|
popModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
||||||
|
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
||||||
|
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onInputChange = () => {
|
||||||
|
void switchVoiceActiveDevice(DeviceKind.AudioInput, selectedInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOutputChange = () => {
|
||||||
|
void switchVoiceActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVideoChange = () => {
|
||||||
|
void switchVoiceActiveDevice(DeviceKind.VideoInput, selectedVideo)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDone = () => {
|
||||||
|
popModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
const canPickOutput = supportsAudioOutputSelection()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal>
|
||||||
|
<ModalBody>
|
||||||
|
<ModalHeader>
|
||||||
|
<ModalTitle>Call settings</ModalTitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<p class="text-sm opacity-80">Microphone, speaker, and camera for this call.</p>
|
||||||
|
<div class="flex flex-col gap-4 pt-2">
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Microphone</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={selectedInput}
|
||||||
|
onchange={onInputChange}
|
||||||
|
aria-label="Microphone">
|
||||||
|
<option value="">Default microphone</option>
|
||||||
|
{#each audioInputs as d (d.deviceId)}
|
||||||
|
<option value={d.deviceId}>
|
||||||
|
{d.label || `Microphone ${d.deviceId.slice(0, 8)}…`}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{#if canPickOutput}
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Speaker</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={selectedOutput}
|
||||||
|
onchange={onOutputChange}
|
||||||
|
aria-label="Speaker">
|
||||||
|
<option value="">Default speaker</option>
|
||||||
|
{#each audioOutputs as d (d.deviceId)}
|
||||||
|
<option value={d.deviceId}>
|
||||||
|
{d.label || `Speaker ${d.deviceId.slice(0, 8)}…`}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
{/if}
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Camera</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={selectedVideo}
|
||||||
|
onchange={onVideoChange}
|
||||||
|
aria-label="Camera">
|
||||||
|
<option value="">Default camera</option>
|
||||||
|
{#each videoInputs as d (d.deviceId)}
|
||||||
|
<option value={d.deviceId}>
|
||||||
|
{d.label || `Camera ${d.deviceId.slice(0, 8)}…`}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-primary" onclick={onDone}>Done</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@@ -26,16 +26,21 @@
|
|||||||
const spaceLabel = $derived(displayRelayUrl(url))
|
const spaceLabel = $derived(displayRelayUrl(url))
|
||||||
|
|
||||||
let audioInputs = $state<MediaDeviceInfo[]>([])
|
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let videoInputs = $state<MediaDeviceInfo[]>([])
|
||||||
let selectedDeviceId = $state("")
|
let selectedDeviceId = $state("")
|
||||||
|
let selectedVideoDeviceId = $state("")
|
||||||
let startWithoutMic = $state(false)
|
let startWithoutMic = $state(false)
|
||||||
|
let joinWithCamera = $state(false)
|
||||||
|
|
||||||
const loadDevices = async () => {
|
const loadDevices = async () => {
|
||||||
if (!navigator.mediaDevices?.enumerateDevices) return
|
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||||
try {
|
try {
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
audioInputs = devices.filter(d => d.kind === "audioinput")
|
audioInputs = devices.filter(d => d.kind === "audioinput")
|
||||||
|
videoInputs = devices.filter(d => d.kind === "videoinput")
|
||||||
} catch {
|
} catch {
|
||||||
audioInputs = []
|
audioInputs = []
|
||||||
|
videoInputs = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +57,8 @@
|
|||||||
h,
|
h,
|
||||||
startWithoutMic,
|
startWithoutMic,
|
||||||
startWithoutMic ? undefined : selectedDeviceId || undefined,
|
startWithoutMic ? undefined : selectedDeviceId || undefined,
|
||||||
|
joinWithCamera,
|
||||||
|
joinWithCamera ? selectedVideoDeviceId || undefined : undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -69,7 +76,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</ModalSubtitle>
|
</ModalSubtitle>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<p class="text-sm opacity-80">Select a microphone to join the call:</p>
|
<p class="text-sm opacity-80">Choose devices for the call:</p>
|
||||||
<div class="flex flex-col gap-4 pt-2">
|
<div class="flex flex-col gap-4 pt-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -77,7 +84,7 @@
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox"
|
class="checkbox"
|
||||||
bind:checked={startWithoutMic} />
|
bind:checked={startWithoutMic} />
|
||||||
<label for="voice-start-without-mic" class="text-sm cursor-pointer">
|
<label for="voice-start-without-mic" class="cursor-pointer text-sm">
|
||||||
Join without microphone (you can unmute later)
|
Join without microphone (you can unmute later)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,6 +107,34 @@
|
|||||||
</select>
|
</select>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</FieldInline>
|
</FieldInline>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
id="voice-join-with-camera"
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox"
|
||||||
|
bind:checked={joinWithCamera} />
|
||||||
|
<label for="voice-join-with-camera" class="cursor-pointer text-sm"
|
||||||
|
>Turn camera on when joining</label>
|
||||||
|
</div>
|
||||||
|
<FieldInline>
|
||||||
|
{#snippet label()}
|
||||||
|
<p>Camera</p>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet input()}
|
||||||
|
<select
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
bind:value={selectedVideoDeviceId}
|
||||||
|
disabled={!joinWithCamera}
|
||||||
|
aria-label="Camera">
|
||||||
|
<option value="">Default camera</option>
|
||||||
|
{#each videoInputs as d (d.deviceId)}
|
||||||
|
<option value={d.deviceId}>
|
||||||
|
{d.label || `Camera ${d.deviceId.slice(0, 8)}…`}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/snippet}
|
||||||
|
</FieldInline>
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
import PhoneRounded from "@assets/icons/phone-rounded.svg?dataurl"
|
||||||
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
|
import PhoneCallingRounded from "@assets/icons/phone-calling-rounded.svg?dataurl"
|
||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
||||||
|
import Settings from "@assets/icons/settings.svg?dataurl"
|
||||||
import Icon from "@lib/components/Icon.svelte"
|
import Icon from "@lib/components/Icon.svelte"
|
||||||
import Button from "@lib/components/Button.svelte"
|
import Button from "@lib/components/Button.svelte"
|
||||||
|
import VoiceCallAudioSettingsDialog from "@app/components/VoiceCallAudioSettingsDialog.svelte"
|
||||||
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||||
import {
|
import {
|
||||||
decodeRelay,
|
decodeRelay,
|
||||||
@@ -68,6 +70,10 @@
|
|||||||
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
|
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
|
||||||
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openAudioSettings = () => {
|
||||||
|
pushModal(VoiceCallAudioSettingsDialog)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if targetRoom}
|
{#if targetRoom}
|
||||||
@@ -121,6 +127,12 @@
|
|||||||
onclick={toggleScreenShare}>
|
onclick={toggleScreenShare}>
|
||||||
<Icon icon={Monitor} size={4} />
|
<Icon icon={Monitor} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
data-tip="Call settings"
|
||||||
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-ghost"
|
||||||
|
onclick={openAudioSettings}>
|
||||||
|
<Icon icon={Settings} size={4} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-tip="Leave room"
|
data-tip="Leave room"
|
||||||
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-error"
|
||||||
|
|||||||
+55
-1
@@ -9,7 +9,9 @@ import {
|
|||||||
Room as LiveKitRoom,
|
Room as LiveKitRoom,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
Track,
|
Track,
|
||||||
|
supportsAudioOutputSelection,
|
||||||
type AudioCaptureOptions,
|
type AudioCaptureOptions,
|
||||||
|
type VideoCaptureOptions,
|
||||||
} from "livekit-client"
|
} from "livekit-client"
|
||||||
import {derived, get, writable} from "svelte/store"
|
import {derived, get, writable} from "svelte/store"
|
||||||
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
import {map, removeUndefined, uniqBy} from "@welshman/lib"
|
||||||
@@ -25,6 +27,8 @@ export const LIVEKIT_PARTICIPANTS = 39004
|
|||||||
|
|
||||||
export {checkRelayHasLivekit} from "$lib/livekit"
|
export {checkRelayHasLivekit} from "$lib/livekit"
|
||||||
|
|
||||||
|
export {supportsAudioOutputSelection}
|
||||||
|
|
||||||
export type VoiceSession = {
|
export type VoiceSession = {
|
||||||
url: string
|
url: string
|
||||||
h: string
|
h: string
|
||||||
@@ -46,6 +50,40 @@ export enum VoiceState {
|
|||||||
|
|
||||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||||
|
|
||||||
|
const LIVEKIT_DEFAULT_DEVICE_ID = "default"
|
||||||
|
|
||||||
|
export enum DeviceKind {
|
||||||
|
AudioInput = "audioinput",
|
||||||
|
AudioOutput = "audiooutput",
|
||||||
|
VideoInput = "videoinput",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const switchVoiceActiveDevice = async (
|
||||||
|
kind: DeviceKind,
|
||||||
|
targetDeviceId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const session = get(currentVoiceSession)
|
||||||
|
if (!session) return
|
||||||
|
const id = targetDeviceId === "" ? LIVEKIT_DEFAULT_DEVICE_ID : targetDeviceId
|
||||||
|
try {
|
||||||
|
await session.room.switchActiveDevice(kind, id)
|
||||||
|
} catch {
|
||||||
|
let label: string
|
||||||
|
switch (kind) {
|
||||||
|
case DeviceKind.AudioInput:
|
||||||
|
label = "microphone"
|
||||||
|
break
|
||||||
|
case DeviceKind.AudioOutput:
|
||||||
|
label = "speaker"
|
||||||
|
break
|
||||||
|
case DeviceKind.VideoInput:
|
||||||
|
label = "camera"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
pushToast({theme: "error", message: `Error changing ${label}`})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||||
|
|
||||||
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||||
@@ -253,6 +291,8 @@ export const joinVoiceRoom = async (
|
|||||||
h: string,
|
h: string,
|
||||||
startMuted = true,
|
startMuted = true,
|
||||||
preferredMicId?: string,
|
preferredMicId?: string,
|
||||||
|
joinWithCamera = false,
|
||||||
|
preferredCameraId?: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
cancelJoinVoiceRoom()
|
cancelJoinVoiceRoom()
|
||||||
|
|
||||||
@@ -303,12 +343,26 @@ export const joinVoiceRoom = async (
|
|||||||
|
|
||||||
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||||
|
|
||||||
|
let cameraOn = false
|
||||||
|
if (joinWithCamera) {
|
||||||
|
const videoCapture: VideoCaptureOptions | undefined = preferredCameraId
|
||||||
|
? {deviceId: preferredCameraId}
|
||||||
|
: undefined
|
||||||
|
try {
|
||||||
|
await liveKitRoom.localParticipant.setCameraEnabled(true, videoCapture)
|
||||||
|
cameraOn = true
|
||||||
|
bumpVideoCallLayoutRevision()
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({theme: "error", message: "Could not access camera"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
currentVoiceSession.set({
|
currentVoiceSession.set({
|
||||||
url,
|
url,
|
||||||
h,
|
h,
|
||||||
room: liveKitRoom,
|
room: liveKitRoom,
|
||||||
muted,
|
muted,
|
||||||
cameraOn: false,
|
cameraOn,
|
||||||
screenShareOn: false,
|
screenShareOn: false,
|
||||||
})
|
})
|
||||||
voiceState.set(VoiceState.Connected)
|
voiceState.set(VoiceState.Connected)
|
||||||
|
|||||||
Reference in New Issue
Block a user