2026-03-27 19:02:56 +00:00
|
|
|
<script lang="ts">
|
2026-07-28 16:16:26 +00:00
|
|
|
import {spec} from "@welshman/lib"
|
2026-03-27 19:02:56 +00:00
|
|
|
import {displayRelayUrl} from "@welshman/util"
|
|
|
|
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
|
|
|
|
import Volume from "@assets/icons/volume.svg?dataurl"
|
|
|
|
|
import Button from "@lib/components/Button.svelte"
|
|
|
|
|
import FieldInline from "@lib/components/FieldInline.svelte"
|
|
|
|
|
import Icon from "@lib/components/Icon.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-04-02 19:35:46 +00:00
|
|
|
import {AbortError, TimeoutError} from "$lib/util"
|
2026-08-10 18:14:46 +00:00
|
|
|
import MicLevelMeter from "@app/components/MicLevelMeter.svelte"
|
2026-06-09 00:04:54 +00:00
|
|
|
import ProfileCircles from "@app/components/ProfileCircles.svelte"
|
2026-07-28 16:16:26 +00:00
|
|
|
import {displayRoom} from "@app/rooms"
|
2026-09-04 15:36:19 +00:00
|
|
|
import {deriveCallParticipants, loadCallParticipants} from "@app/call"
|
|
|
|
|
import {joinVoiceRoom} from "@app/callEngine"
|
2026-06-08 21:14:40 +00:00
|
|
|
import {popModal} from "@app/modal"
|
|
|
|
|
import {pushToast} from "@app/toast"
|
2026-03-27 19:02:56 +00:00
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
url: string
|
|
|
|
|
h: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const {url, h}: Props = $props()
|
|
|
|
|
|
|
|
|
|
const spaceLabel = $derived(displayRelayUrl(url))
|
2026-06-26 22:56:49 +00:00
|
|
|
const participants = deriveCallParticipants(url, h)
|
2026-06-09 00:04:54 +00:00
|
|
|
const participantPubkeys = $derived($participants.flatMap(p => (p.pubkey ? [p.pubkey] : [])))
|
2026-03-27 19:02:56 +00:00
|
|
|
|
|
|
|
|
let audioInputs = $state<MediaDeviceInfo[]>([])
|
|
|
|
|
let selectedDeviceId = $state("")
|
|
|
|
|
let startWithoutMic = $state(false)
|
2026-08-10 18:14:46 +00:00
|
|
|
let micError = $state(false)
|
2026-03-27 19:02:56 +00:00
|
|
|
|
|
|
|
|
const loadDevices = async () => {
|
|
|
|
|
if (!navigator.mediaDevices?.enumerateDevices) return
|
|
|
|
|
try {
|
|
|
|
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
2026-07-28 16:16:26 +00:00
|
|
|
audioInputs = devices.filter(spec({kind: "audioinput"}))
|
2026-03-27 19:02:56 +00:00
|
|
|
} catch {
|
|
|
|
|
audioInputs = []
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
2026-06-26 22:56:49 +00:00
|
|
|
void loadCallParticipants(url, h)
|
2026-03-27 19:02:56 +00:00
|
|
|
void loadDevices()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-10 18:14:46 +00:00
|
|
|
// Live mic level meter so the user can confirm their selected microphone is
|
|
|
|
|
// actually picking up sound before joining, rather than finding out mid-call.
|
|
|
|
|
// The stream handle stays a plain local while only the track is reactive: the
|
|
|
|
|
// effect below both reads and writes it, and reading reactive state an effect
|
|
|
|
|
// also writes makes that effect depend on its own output, so it would re-run
|
|
|
|
|
// forever and cancel the in-flight getUserMedia every time.
|
|
|
|
|
let previewStream: MediaStream | undefined
|
|
|
|
|
let previewTrack = $state<MediaStreamTrack | undefined>(undefined)
|
|
|
|
|
|
|
|
|
|
const stopMicPreview = () => {
|
|
|
|
|
previewStream?.getTracks().forEach(t => t.stop())
|
|
|
|
|
previewStream = undefined
|
|
|
|
|
previewTrack = undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
// Re-runs when selectedDeviceId or startWithoutMic changes.
|
|
|
|
|
void selectedDeviceId
|
|
|
|
|
const withoutMic = startWithoutMic
|
|
|
|
|
|
|
|
|
|
stopMicPreview()
|
|
|
|
|
micError = false
|
|
|
|
|
|
|
|
|
|
if (withoutMic || !navigator.mediaDevices?.getUserMedia) return
|
|
|
|
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
const deviceId = selectedDeviceId
|
|
|
|
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
try {
|
|
|
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
|
|
|
audio: deviceId ? {deviceId: {exact: deviceId}} : true,
|
|
|
|
|
})
|
|
|
|
|
if (cancelled) {
|
|
|
|
|
stream.getTracks().forEach(t => t.stop())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
previewStream = stream
|
|
|
|
|
previewTrack = stream.getAudioTracks()[0]
|
|
|
|
|
} catch {
|
|
|
|
|
if (!cancelled) micError = true
|
|
|
|
|
}
|
|
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true
|
|
|
|
|
stopMicPreview()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const goBack = () => {
|
|
|
|
|
stopMicPreview()
|
|
|
|
|
history.back()
|
|
|
|
|
}
|
2026-03-27 19:02:56 +00:00
|
|
|
|
2026-04-02 19:35:46 +00:00
|
|
|
const handleJoinError = (e: unknown) => {
|
|
|
|
|
if (e instanceof AbortError) return
|
|
|
|
|
console.error("Failed to join voice room", e)
|
|
|
|
|
let message = "Failed to join voice room"
|
|
|
|
|
if (e instanceof TimeoutError)
|
|
|
|
|
message = "Connection timed out. Please check your network and try again."
|
|
|
|
|
else if (e instanceof Error) message = e.message
|
|
|
|
|
pushToast({theme: "error", message})
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 19:02:56 +00:00
|
|
|
const joinVoice = async () => {
|
2026-08-10 18:14:46 +00:00
|
|
|
stopMicPreview()
|
2026-03-27 19:02:56 +00:00
|
|
|
popModal()
|
|
|
|
|
await joinVoiceRoom(
|
|
|
|
|
url,
|
|
|
|
|
h,
|
|
|
|
|
startWithoutMic,
|
|
|
|
|
startWithoutMic ? undefined : selectedDeviceId || undefined,
|
2026-04-02 19:35:46 +00:00
|
|
|
).catch(handleJoinError)
|
2026-03-27 19:02:56 +00:00
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<Modal>
|
|
|
|
|
<ModalBody>
|
|
|
|
|
<ModalHeader>
|
|
|
|
|
<ModalTitle>Join voice room?</ModalTitle>
|
|
|
|
|
<ModalSubtitle>
|
|
|
|
|
<span class="inline-flex flex-wrap items-center justify-center gap-x-1.5 gap-y-1">
|
|
|
|
|
<Icon icon={Volume} size={4} class="shrink-0" />
|
2026-07-02 21:22:55 +00:00
|
|
|
<span class="truncate min-w-0">{displayRoom(url, h)}</span>
|
2026-03-27 19:02:56 +00:00
|
|
|
<span>·</span>
|
|
|
|
|
<span>{spaceLabel}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</ModalSubtitle>
|
|
|
|
|
</ModalHeader>
|
2026-06-09 00:04:54 +00:00
|
|
|
{#if participantPubkeys.length > 0}
|
|
|
|
|
<div class="flex justify-center py-2">
|
|
|
|
|
<ProfileCircles pubkeys={participantPubkeys} size={5} limit={3} />
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
2026-03-27 19:02:56 +00:00
|
|
|
<p class="text-sm opacity-80">Select a microphone to join the call:</p>
|
|
|
|
|
<div class="flex flex-col gap-4 pt-2">
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<input
|
|
|
|
|
id="voice-start-without-mic"
|
|
|
|
|
type="checkbox"
|
|
|
|
|
class="checkbox"
|
|
|
|
|
bind:checked={startWithoutMic} />
|
|
|
|
|
<label for="voice-start-without-mic" class="text-sm cursor-pointer">
|
|
|
|
|
Join without microphone (you can unmute later)
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<FieldInline>
|
|
|
|
|
{#snippet label()}
|
|
|
|
|
<p>Microphone</p>
|
|
|
|
|
{/snippet}
|
|
|
|
|
{#snippet input()}
|
|
|
|
|
<select
|
2026-06-24 20:56:53 +00:00
|
|
|
class="select input w-full"
|
2026-03-27 19:02:56 +00:00
|
|
|
bind:value={selectedDeviceId}
|
|
|
|
|
disabled={startWithoutMic}
|
|
|
|
|
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
|
|
|
{#if !startWithoutMic}
|
|
|
|
|
<MicLevelMeter
|
|
|
|
|
track={previewTrack}
|
|
|
|
|
error={micError
|
|
|
|
|
? "Could not access this microphone. Check your browser permissions, or join without one."
|
|
|
|
|
: undefined} />
|
|
|
|
|
{/if}
|
2026-03-27 19:02:56 +00:00
|
|
|
</div>
|
|
|
|
|
</ModalBody>
|
|
|
|
|
<ModalFooter>
|
2026-06-24 20:56:53 +00:00
|
|
|
<Button class="button button-link" onclick={goBack}>
|
2026-03-27 19:02:56 +00:00
|
|
|
<Icon icon={AltArrowLeft} />
|
|
|
|
|
Don't join
|
|
|
|
|
</Button>
|
2026-07-01 23:25:29 +00:00
|
|
|
<Button class="button button-primary" onclick={joinVoice}>Join voice</Button>
|
2026-03-27 19:02:56 +00:00
|
|
|
</ModalFooter>
|
|
|
|
|
</Modal>
|