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:
mplorentz
2026-04-02 11:21:29 -04:00
4 changed files with 265 additions and 3 deletions
@@ -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>
+37 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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)