2026-04-02 19:33:48 +00:00
|
|
|
<script lang="ts">
|
2026-07-28 16:16:26 +00:00
|
|
|
import {spec} from "@welshman/lib"
|
2026-09-04 15:36:19 +00:00
|
|
|
import {RoomEvent, Track, supportsAudioOutputSelection} from "livekit-client"
|
2026-04-02 19:33:48 +00:00
|
|
|
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 ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
|
|
|
|
|
import ModalTitle from "@lib/components/ModalTitle.svelte"
|
2026-08-10 18:14:46 +00:00
|
|
|
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
2026-09-04 15:36:19 +00:00
|
|
|
import {currentCallSession, DeviceKind, type CallSession} from "@app/call"
|
|
|
|
|
import {switchCallActiveDevice} from "@app/callEngine"
|
2026-06-08 21:14:40 +00:00
|
|
|
import {popModal} from "@app/modal"
|
2026-04-02 19:33:48 +00:00
|
|
|
|
2026-06-26 22:56:49 +00:00
|
|
|
const selectValueForActiveDevice = (session: CallSession, kind: DeviceKind): string => {
|
|
|
|
|
const livekitDeviceId = session.livekit.getActiveDevice(kind)
|
2026-04-02 19:33:48 +00:00
|
|
|
if (livekitDeviceId === undefined || livekitDeviceId === "" || livekitDeviceId === "default") {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return livekitDeviceId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let audioInputs = $state<MediaDeviceInfo[]>([])
|
|
|
|
|
let audioOutputs = $state<MediaDeviceInfo[]>([])
|
2026-04-08 17:10:20 +00:00
|
|
|
let videoInputs = $state<MediaDeviceInfo[]>([])
|
2026-04-02 19:33:48 +00:00
|
|
|
let selectedInput = $state("")
|
|
|
|
|
let selectedOutput = $state("")
|
2026-04-08 17:10:20 +00:00
|
|
|
let selectedVideo = $state("")
|
2026-08-10 18:14:46 +00:00
|
|
|
let micTrack = $state<MediaStreamTrack | undefined>(undefined)
|
|
|
|
|
|
|
|
|
|
const readMicTrack = (session: CallSession): MediaStreamTrack | undefined =>
|
|
|
|
|
session.livekit.localParticipant.getTrackPublication(Track.Source.Microphone)?.track
|
|
|
|
|
?.mediaStreamTrack
|
2026-04-02 19:33:48 +00:00
|
|
|
|
|
|
|
|
const loadDevices = async () => {
|
2026-09-21 16:48:35 +00:00
|
|
|
if (!navigator.mediaDevices?.enumerateDevices) {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-04-02 19:33:48 +00:00
|
|
|
try {
|
|
|
|
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
2026-07-28 16:16:26 +00:00
|
|
|
audioInputs = devices.filter(spec({kind: "audioinput"}))
|
|
|
|
|
audioOutputs = devices.filter(spec({kind: "audiooutput"}))
|
|
|
|
|
videoInputs = devices.filter(spec({kind: "videoinput"}))
|
2026-04-02 19:33:48 +00:00
|
|
|
} catch {
|
|
|
|
|
audioInputs = []
|
|
|
|
|
audioOutputs = []
|
2026-04-08 17:10:20 +00:00
|
|
|
videoInputs = []
|
2026-04-02 19:33:48 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
loadDevices()
|
|
|
|
|
navigator.mediaDevices?.addEventListener?.("devicechange", loadDevices)
|
|
|
|
|
return () => navigator.mediaDevices?.removeEventListener?.("devicechange", loadDevices)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
2026-06-26 22:56:49 +00:00
|
|
|
const session = $currentCallSession
|
2026-04-02 19:33:48 +00:00
|
|
|
if (!session) {
|
|
|
|
|
popModal()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
selectedInput = selectValueForActiveDevice(session, DeviceKind.AudioInput)
|
|
|
|
|
selectedOutput = selectValueForActiveDevice(session, DeviceKind.AudioOutput)
|
2026-04-08 17:10:20 +00:00
|
|
|
selectedVideo = selectValueForActiveDevice(session, DeviceKind.VideoInput)
|
2026-08-10 18:14:46 +00:00
|
|
|
|
|
|
|
|
// Mic mute/unmute publishes or unpublishes the microphone track; keep the
|
|
|
|
|
// level meter in sync with whichever track is actually live right now.
|
|
|
|
|
const {livekit} = session
|
|
|
|
|
const refreshMicTrack = () => {
|
|
|
|
|
micTrack = readMicTrack(session)
|
|
|
|
|
}
|
|
|
|
|
refreshMicTrack()
|
|
|
|
|
livekit.on(RoomEvent.LocalTrackPublished, refreshMicTrack)
|
|
|
|
|
livekit.on(RoomEvent.LocalTrackUnpublished, refreshMicTrack)
|
|
|
|
|
return () => {
|
|
|
|
|
livekit.off(RoomEvent.LocalTrackPublished, refreshMicTrack)
|
|
|
|
|
livekit.off(RoomEvent.LocalTrackUnpublished, refreshMicTrack)
|
|
|
|
|
}
|
2026-04-02 19:33:48 +00:00
|
|
|
})
|
|
|
|
|
|
2026-08-10 18:14:46 +00:00
|
|
|
const onInputChange = async () => {
|
|
|
|
|
await switchCallActiveDevice(DeviceKind.AudioInput, selectedInput)
|
|
|
|
|
// Switching the active device restarts the existing LocalAudioTrack in
|
|
|
|
|
// place with a new MediaStreamTrack rather than emitting a publish event,
|
|
|
|
|
// so the meter needs an explicit re-read here.
|
|
|
|
|
const session = $currentCallSession
|
2026-09-21 16:48:35 +00:00
|
|
|
if (session) {
|
|
|
|
|
micTrack = readMicTrack(session)
|
|
|
|
|
}
|
2026-04-02 19:33:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const onOutputChange = () => {
|
2026-06-26 22:56:49 +00:00
|
|
|
void switchCallActiveDevice(DeviceKind.AudioOutput, selectedOutput)
|
2026-04-02 19:33:48 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-08 17:10:20 +00:00
|
|
|
const onVideoChange = () => {
|
2026-06-26 22:56:49 +00:00
|
|
|
void switchCallActiveDevice(DeviceKind.VideoInput, selectedVideo)
|
2026-04-08 17:10:20 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-02 19:33:48 +00:00
|
|
|
const onDone = () => {
|
|
|
|
|
popModal()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Output not support in Safari
|
|
|
|
|
const canPickOutput = supportsAudioOutputSelection()
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<Modal>
|
|
|
|
|
<ModalBody>
|
|
|
|
|
<ModalHeader>
|
2026-04-08 17:10:20 +00:00
|
|
|
<ModalTitle>Call settings</ModalTitle>
|
|
|
|
|
<ModalSubtitle>Microphone, speaker, and camera for this call.</ModalSubtitle>
|
2026-04-02 19:33:48 +00:00
|
|
|
</ModalHeader>
|
|
|
|
|
<div class="flex flex-col gap-4 pt-2">
|
|
|
|
|
<FieldInline>
|
|
|
|
|
{#snippet label()}
|
|
|
|
|
<p>Microphone</p>
|
|
|
|
|
{/snippet}
|
|
|
|
|
{#snippet input()}
|
|
|
|
|
<select
|
2026-06-24 20:56:53 +00:00
|
|
|
class="select input w-full"
|
2026-04-02 19:33:48 +00:00
|
|
|
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>
|
2026-08-10 18:14:46 +00:00
|
|
|
<MicLevelMeter track={micTrack} offMessage="Microphone is off — unmute to test it" />
|
2026-04-02 19:33:48 +00:00
|
|
|
{#if canPickOutput}
|
|
|
|
|
<FieldInline>
|
|
|
|
|
{#snippet label()}
|
|
|
|
|
<p>Speaker</p>
|
|
|
|
|
{/snippet}
|
|
|
|
|
{#snippet input()}
|
|
|
|
|
<select
|
2026-06-24 20:56:53 +00:00
|
|
|
class="select input w-full"
|
2026-04-02 19:33:48 +00:00
|
|
|
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}
|
2026-04-08 17:10:20 +00:00
|
|
|
<FieldInline>
|
|
|
|
|
{#snippet label()}
|
|
|
|
|
<p>Camera</p>
|
|
|
|
|
{/snippet}
|
|
|
|
|
{#snippet input()}
|
|
|
|
|
<select
|
2026-06-24 20:56:53 +00:00
|
|
|
class="select input w-full"
|
2026-04-08 17:10:20 +00:00
|
|
|
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>
|
2026-04-02 19:33:48 +00:00
|
|
|
</div>
|
|
|
|
|
</ModalBody>
|
|
|
|
|
<ModalFooter>
|
2026-06-24 20:56:53 +00:00
|
|
|
<Button class="button button-primary button-block" onclick={onDone}>Done</Button>
|
2026-04-02 19:33:48 +00:00
|
|
|
</ModalFooter>
|
|
|
|
|
</Modal>
|