forked from coracle/flotilla
Add a dialog before joining voice rooms (#109)
After using the voice rooms more since we removed the option for voice-only rooms I think you were right to suggest a dialog box before joining rooms. It felt far to clunky to have to join the voice call any time you just wanted to try to view room members, edit room settings, or just view the recent text chat. This adds a dialog that allows the user to decline to join the call but still access the text part of the room along with associated settings and controls. It also acts as another confirmation step before turning on the user's microphone, and allows them to choose an audio input so they don't have to mess with the (generally terrible) browser controls for doing so. We should probably have controls to change your audio input and output from within the call as well, but I think this is enough for an MVP.  Co-authored-by: mplorentz <mplorentz@noreply.gitea.coracle.social> Reviewed-on: coracle/flotilla#109 Co-authored-by: Matt Lorentz <mplorentz@noreply.coracle.social> Co-committed-by: Matt Lorentz <mplorentz@noreply.coracle.social>
This commit is contained in:
@@ -1,15 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import cx from "classnames"
|
import cx from "classnames"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
import {loadProfile, displayProfileByPubkey} from "@welshman/app"
|
import {loadProfile, displayProfileByPubkey} from "@welshman/app"
|
||||||
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
import SecondaryNavItem from "@lib/components/SecondaryNavItem.svelte"
|
||||||
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
import ProfileCircle from "@app/components/ProfileCircle.svelte"
|
||||||
import RoomImage from "@app/components/RoomImage.svelte"
|
import RoomImage from "@app/components/RoomImage.svelte"
|
||||||
import RoomName from "@app/components/RoomName.svelte"
|
import RoomName from "@app/components/RoomName.svelte"
|
||||||
import {handleJoinError} from "@app/components/VoiceWidget.svelte"
|
|
||||||
import {makeRoomPath} from "@app/util/routes"
|
import {makeRoomPath} from "@app/util/routes"
|
||||||
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||||
|
import {makeRoomId} from "@app/core/state"
|
||||||
import {
|
import {
|
||||||
|
VoiceState,
|
||||||
deriveVoiceParticipants,
|
deriveVoiceParticipants,
|
||||||
joinVoiceRoom,
|
|
||||||
cancelJoinVoiceRoom,
|
cancelJoinVoiceRoom,
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
voiceState,
|
voiceState,
|
||||||
@@ -28,21 +31,24 @@
|
|||||||
|
|
||||||
const participants = deriveVoiceParticipants(url, h)
|
const participants = deriveVoiceParticipants(url, h)
|
||||||
const isActive = $derived(
|
const isActive = $derived(
|
||||||
$voiceState === "connected" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
|
$voiceState === VoiceState.Connected && $currentVoiceRoom?.id === makeRoomId(url, h),
|
||||||
)
|
)
|
||||||
const isJoining = $derived(
|
const isJoining = $derived(
|
||||||
$voiceState === "joining" && $currentVoiceRoom?.url === url && $currentVoiceRoom?.h === h,
|
$voiceState === VoiceState.Joining && $currentVoiceRoom?.id === makeRoomId(url, h),
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleClick = async () => {
|
const handleClick = async (e: MouseEvent) => {
|
||||||
if (isActive) return
|
if (isActive) return
|
||||||
|
|
||||||
if (isJoining) {
|
if (isJoining) {
|
||||||
|
e.preventDefault()
|
||||||
cancelJoinVoiceRoom()
|
cancelJoinVoiceRoom()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await joinVoiceRoom(url, h).catch(handleJoinError)
|
e.preventDefault()
|
||||||
|
await goto(makeRoomPath(url, h), {replaceState})
|
||||||
|
pushModal(VoiceRoomJoinDialog, {url, h})
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
|
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
|
||||||
|
import AltArrowRight from "@assets/icons/alt-arrow-right.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"
|
||||||
|
import {displayRoom} from "@app/core/state"
|
||||||
|
import {joinVoiceRoom} from "@app/voice"
|
||||||
|
import {popModal} from "@app/util/modal"
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
url: string
|
||||||
|
h: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const {url, h}: Props = $props()
|
||||||
|
|
||||||
|
const spaceLabel = $derived(displayRelayUrl(url))
|
||||||
|
|
||||||
|
let audioInputs = $state<MediaDeviceInfo[]>([])
|
||||||
|
let selectedDeviceId = $state("")
|
||||||
|
let startWithoutMic = $state(false)
|
||||||
|
|
||||||
|
const loadDevices = async () => {
|
||||||
|
if (!navigator.mediaDevices?.enumerateDevices) return
|
||||||
|
try {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
audioInputs = devices.filter(d => d.kind === "audioinput")
|
||||||
|
} catch {
|
||||||
|
audioInputs = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
void loadDevices()
|
||||||
|
})
|
||||||
|
|
||||||
|
const goBack = () => history.back()
|
||||||
|
|
||||||
|
const joinVoice = async () => {
|
||||||
|
popModal()
|
||||||
|
await joinVoiceRoom(
|
||||||
|
url,
|
||||||
|
h,
|
||||||
|
startWithoutMic,
|
||||||
|
startWithoutMic ? undefined : selectedDeviceId || undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</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" />
|
||||||
|
<span class="ellipsize min-w-0">{displayRoom(url, h)}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{spaceLabel}</span>
|
||||||
|
</span>
|
||||||
|
</ModalSubtitle>
|
||||||
|
</ModalHeader>
|
||||||
|
<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
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button class="btn btn-link" onclick={goBack}>
|
||||||
|
<Icon icon={AltArrowLeft} />
|
||||||
|
Don't join
|
||||||
|
</Button>
|
||||||
|
<Button class="btn btn-primary" onclick={joinVoice}>
|
||||||
|
Join voice
|
||||||
|
<Icon icon={AltArrowRight} />
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
@@ -1,22 +1,8 @@
|
|||||||
<script module lang="ts">
|
|
||||||
import {AbortError, TimeoutError} from "$lib/util"
|
|
||||||
import {VoiceJoinMembershipError} from "@app/voice"
|
|
||||||
import {pushToast} from "@app/util/toast"
|
|
||||||
|
|
||||||
export function 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 VoiceJoinMembershipError) message = e.message
|
|
||||||
else if (e instanceof TimeoutError)
|
|
||||||
message = "Connection timed out. Please check your network and try again."
|
|
||||||
else if (e instanceof Error && e.message === "No signer available") message = e.message
|
|
||||||
pushToast({theme: "error", message})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import {readable} from "svelte/store"
|
||||||
import {fly} from "svelte/transition"
|
import {fly} from "svelte/transition"
|
||||||
|
import {goto} from "$app/navigation"
|
||||||
|
import {page} from "$app/stores"
|
||||||
import {displayRelayUrl} from "@welshman/util"
|
import {displayRelayUrl} from "@welshman/util"
|
||||||
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
import Microphone from "@assets/icons/microphone.svg?dataurl"
|
||||||
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
|
import MicrophoneOff from "@assets/icons/microphone-off.svg?dataurl"
|
||||||
@@ -25,36 +11,69 @@
|
|||||||
import CloseCircle from "@assets/icons/close-circle.svg?dataurl"
|
import CloseCircle from "@assets/icons/close-circle.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 {displayRoom} from "@app/core/state"
|
import VoiceRoomJoinDialog from "@app/components/VoiceRoomJoinDialog.svelte"
|
||||||
import {
|
import {
|
||||||
|
decodeRelay,
|
||||||
|
deriveRoom,
|
||||||
|
displayRoom,
|
||||||
|
getRoomType,
|
||||||
|
RoomType,
|
||||||
|
type Room,
|
||||||
|
} from "@app/core/state"
|
||||||
|
import {pushModal} from "@app/util/modal"
|
||||||
|
import {makeRoomPath} from "@app/util/routes"
|
||||||
|
import {
|
||||||
|
VoiceState,
|
||||||
currentVoiceSession,
|
currentVoiceSession,
|
||||||
currentVoiceRoom,
|
currentVoiceRoom,
|
||||||
voiceState,
|
voiceState,
|
||||||
leaveVoiceRoom,
|
leaveVoiceRoom,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
rejoinVoiceRoom,
|
|
||||||
cancelJoinVoiceRoom,
|
cancelJoinVoiceRoom,
|
||||||
} from "@app/voice"
|
} from "@app/voice"
|
||||||
|
|
||||||
const roomName = $derived(
|
const {relay, h} = $derived($page.params)
|
||||||
$currentVoiceRoom ? displayRoom($currentVoiceRoom.url, $currentVoiceRoom.h) : "",
|
const url = $derived(relay ? decodeRelay(relay) : undefined)
|
||||||
|
const displayedRoomStore = $derived(
|
||||||
|
url && h && typeof h === "string" ? deriveRoom(url, h) : readable(undefined),
|
||||||
)
|
)
|
||||||
const spaceName = $derived($currentVoiceRoom ? displayRelayUrl($currentVoiceRoom.url) : "")
|
const routeDisplayedRoom = $derived($displayedRoomStore)
|
||||||
|
|
||||||
const handleRejoin = () => {
|
const targetRoom = $derived.by((): Room | undefined => {
|
||||||
void rejoinVoiceRoom().catch(handleJoinError)
|
if ($voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected) {
|
||||||
|
return $currentVoiceRoom
|
||||||
|
}
|
||||||
|
if ($voiceState === VoiceState.Disconnected) {
|
||||||
|
if (routeDisplayedRoom) {
|
||||||
|
if (getRoomType(routeDisplayedRoom) === RoomType.Voice) {
|
||||||
|
return routeDisplayedRoom
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return $currentVoiceRoom
|
||||||
|
}
|
||||||
|
return $currentVoiceRoom
|
||||||
|
})
|
||||||
|
|
||||||
|
const roomName = $derived(targetRoom ? displayRoom(targetRoom.url, targetRoom.h) : "")
|
||||||
|
const spaceName = $derived(targetRoom ? displayRelayUrl(targetRoom.url) : "")
|
||||||
|
|
||||||
|
const openJoinDialog = async () => {
|
||||||
|
if (!targetRoom) return
|
||||||
|
await goto(makeRoomPath(targetRoom.url, targetRoom.h))
|
||||||
|
pushModal(VoiceRoomJoinDialog, {url: targetRoom.url, h: targetRoom.h})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $currentVoiceRoom}
|
{#if targetRoom}
|
||||||
<div
|
<div
|
||||||
in:fly={{y: 60, duration: 350}}
|
in:fly={{y: 60, duration: 350}}
|
||||||
out:fly={{y: 60, duration: 250}}
|
out:fly={{y: 60, duration: 250}}
|
||||||
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
|
class="flex flex-col gap-2 rounded-box bg-base-100 p-3">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
{#if $voiceState === "joining"}
|
{#if $voiceState === VoiceState.Joining}
|
||||||
<span class="text-sm font-semibold text-warning">Joining...</span>
|
<span class="text-sm font-semibold text-warning">Joining...</span>
|
||||||
{:else if $voiceState === "connected"}
|
{:else if $voiceState === VoiceState.Connected}
|
||||||
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
<span class="text-sm font-semibold text-success">Voice Connected</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
|
<span class="text-sm font-semibold text-neutral-content">Disconnected</span>
|
||||||
@@ -64,7 +83,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
{#if $voiceState === "joining"}
|
{#if $voiceState === VoiceState.Joining}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
<Button
|
<Button
|
||||||
data-tip="Cancel"
|
data-tip="Cancel"
|
||||||
@@ -72,7 +91,7 @@
|
|||||||
onclick={cancelJoinVoiceRoom}>
|
onclick={cancelJoinVoiceRoom}>
|
||||||
<Icon icon={CloseCircle} size={4} />
|
<Icon icon={CloseCircle} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
{:else if $voiceState === "connected" && $currentVoiceSession}
|
{:else if $voiceState === VoiceState.Connected && $currentVoiceSession}
|
||||||
<Button
|
<Button
|
||||||
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
data-tip={$currentVoiceSession.muted ? "Unmute" : "Mute"}
|
||||||
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
|
class="center tooltip tooltip-top btn btn-sm btn-square {$currentVoiceSession.muted
|
||||||
@@ -91,7 +110,7 @@
|
|||||||
<Button
|
<Button
|
||||||
data-tip="Join Voice"
|
data-tip="Join Voice"
|
||||||
class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
|
class="center tooltip tooltip-top btn btn-sm btn-square btn-success"
|
||||||
onclick={handleRejoin}>
|
onclick={openJoinDialog}>
|
||||||
<Icon icon={PhoneCallingRounded} size={4} />
|
<Icon icon={PhoneCallingRounded} size={4} />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -669,7 +669,7 @@ export const deriveRoom = call(() => {
|
|||||||
return (url: string, h: string) =>
|
return (url: string, h: string) =>
|
||||||
derived(
|
derived(
|
||||||
_deriveRoom(makeRoomId(url, h)),
|
_deriveRoom(makeRoomId(url, h)),
|
||||||
room => room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})},
|
room => (room || {url, id: makeRoomId(url, h), ...makeRoomMeta({h})}) as Room,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+67
-44
@@ -2,7 +2,14 @@
|
|||||||
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
|
* Voice rooms via LiveKit. Note: Voice does not work on localhost in Firefox
|
||||||
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
|
* (ICE candidate gathering fails). Use Chrome or test from deployed HTTPS.
|
||||||
*/
|
*/
|
||||||
import {DisconnectReason, Room, RoomEvent, Track} from "livekit-client"
|
import {
|
||||||
|
DisconnectReason,
|
||||||
|
Room as LiveKitRoom,
|
||||||
|
RoomEvent,
|
||||||
|
Track,
|
||||||
|
type AudioCaptureOptions,
|
||||||
|
type LocalParticipant,
|
||||||
|
} 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"
|
||||||
import type {TrustedEvent} from "@welshman/util"
|
import type {TrustedEvent} from "@welshman/util"
|
||||||
@@ -10,24 +17,17 @@ import {makeHttpAuth, makeHttpAuthHeader, getTags} from "@welshman/util"
|
|||||||
import {signer} from "@welshman/app"
|
import {signer} from "@welshman/app"
|
||||||
import {getLivekitEndpoint} from "$lib/livekit"
|
import {getLivekitEndpoint} from "$lib/livekit"
|
||||||
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
import {AbortError, whenAborted, whenTimeout} from "$lib/util"
|
||||||
import {deriveLatestEventForUrl} from "@app/core/state"
|
import {deriveLatestEventForUrl, deriveRoom, makeRoomId, type Room} from "@app/core/state"
|
||||||
import {pushToast} from "@app/util/toast"
|
import {pushToast} from "@app/util/toast"
|
||||||
|
|
||||||
export const LIVEKIT_PARTICIPANTS = 39004
|
export const LIVEKIT_PARTICIPANTS = 39004
|
||||||
|
|
||||||
export {checkRelayHasLivekit} from "$lib/livekit"
|
export {checkRelayHasLivekit} from "$lib/livekit"
|
||||||
|
|
||||||
export class VoiceJoinMembershipError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Failed to join voice room: you must be a member.")
|
|
||||||
this.name = "VoiceJoinMembershipError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VoiceSession = {
|
export type VoiceSession = {
|
||||||
url: string
|
url: string
|
||||||
h: string
|
h: string
|
||||||
room: Room
|
room: LiveKitRoom
|
||||||
muted: boolean
|
muted: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,13 +35,17 @@ export type Pubkey = string
|
|||||||
|
|
||||||
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
export type VoiceParticipant = {pubkey?: Pubkey; identity: string}
|
||||||
|
|
||||||
export type VoiceState = "joining" | "connected" | "disconnected"
|
export enum VoiceState {
|
||||||
|
Joining = "joining",
|
||||||
|
Connected = "connected",
|
||||||
|
Disconnected = "disconnected",
|
||||||
|
}
|
||||||
|
|
||||||
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
export const currentVoiceSession = writable<VoiceSession | undefined>(undefined)
|
||||||
|
|
||||||
export const voiceState = writable<VoiceState>("disconnected")
|
export const voiceState = writable<VoiceState>(VoiceState.Disconnected)
|
||||||
|
|
||||||
export const currentVoiceRoom = writable<{url: string; h: string} | undefined>(undefined)
|
export const currentVoiceRoom = writable<Room | undefined>(undefined)
|
||||||
|
|
||||||
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
export const participantPubkeyMap = writable<Map<string, Pubkey>>(new Map())
|
||||||
|
|
||||||
@@ -102,7 +106,6 @@ const fetchLivekitToken = async (
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
if (response.status === 403) throw new VoiceJoinMembershipError()
|
|
||||||
throw new Error(`Token request failed (${response.status}): ${text}`)
|
throw new Error(`Token request failed (${response.status}): ${text}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +121,7 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
|
|||||||
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
deriveLatestEventForUrl(url, [{kinds: [LIVEKIT_PARTICIPANTS], "#d": [h]}]),
|
||||||
],
|
],
|
||||||
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
|
([$participantPubkeyMap, $currentVoiceRoom, $publishedParticipantList]) => {
|
||||||
const inCall =
|
const inCall = $participantPubkeyMap.size > 0 && $currentVoiceRoom?.id === makeRoomId(url, h)
|
||||||
$participantPubkeyMap.size > 0 &&
|
|
||||||
$currentVoiceRoom?.url === url &&
|
|
||||||
$currentVoiceRoom?.h === h
|
|
||||||
|
|
||||||
if (inCall) {
|
if (inCall) {
|
||||||
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity)
|
const participants = [...$participantPubkeyMap.keys()].map(participantFromLiveKitIdentity)
|
||||||
@@ -140,10 +140,33 @@ export const deriveVoiceParticipants = (url: string, h: string) =>
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const setUpMicrophone = async (
|
||||||
|
startMuted: boolean,
|
||||||
|
preferredMicId: string | undefined,
|
||||||
|
participant: LocalParticipant,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (startMuted) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let muted = true
|
||||||
|
let capture: AudioCaptureOptions | undefined = undefined
|
||||||
|
if (preferredMicId) {
|
||||||
|
capture = {deviceId: preferredMicId}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await participant.setMicrophoneEnabled(true, capture)
|
||||||
|
muted = false
|
||||||
|
} catch (e) {
|
||||||
|
pushToast({theme: "error", message: "Could not access microphone"})
|
||||||
|
}
|
||||||
|
return muted
|
||||||
|
}
|
||||||
|
|
||||||
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
const onRoomDisconnected = (reason?: DisconnectReason) => {
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
|
||||||
voiceState.set("disconnected")
|
voiceState.set(VoiceState.Disconnected)
|
||||||
const message =
|
const message =
|
||||||
reason === DisconnectReason.JOIN_FAILURE
|
reason === DisconnectReason.JOIN_FAILURE
|
||||||
? "Could not connect to voice room. Please try again."
|
? "Could not connect to voice room. Please try again."
|
||||||
@@ -191,14 +214,19 @@ export const cancelJoinVoiceRoom = () => {
|
|||||||
joinAbortController?.abort()
|
joinAbortController?.abort()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
|
export const joinVoiceRoom = async (
|
||||||
|
url: string,
|
||||||
|
h: string,
|
||||||
|
startMuted = true,
|
||||||
|
preferredMicId?: string,
|
||||||
|
): Promise<void> => {
|
||||||
cancelJoinVoiceRoom()
|
cancelJoinVoiceRoom()
|
||||||
|
|
||||||
const session = get(currentVoiceSession)
|
const session = get(currentVoiceSession)
|
||||||
if (session) await leaveVoiceRoom()
|
if (session) await leaveVoiceRoom()
|
||||||
|
|
||||||
currentVoiceRoom.set({url, h})
|
currentVoiceRoom.set(get(deriveRoom(url, h)))
|
||||||
voiceState.set("joining")
|
voiceState.set(VoiceState.Joining)
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
joinAbortController = controller
|
joinAbortController = controller
|
||||||
@@ -210,47 +238,42 @@ export const joinVoiceRoom = async (url: string, h: string): Promise<void> => {
|
|||||||
|
|
||||||
if (signal.aborted) throw new AbortError()
|
if (signal.aborted) throw new AbortError()
|
||||||
|
|
||||||
const room = new Room({adaptiveStream: true, dynacast: true})
|
const liveKitRoom = new LiveKitRoom({adaptiveStream: true, dynacast: true})
|
||||||
|
|
||||||
room.on(RoomEvent.Disconnected, onRoomDisconnected)
|
liveKitRoom.on(RoomEvent.Disconnected, onRoomDisconnected)
|
||||||
room.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
liveKitRoom.on(RoomEvent.ParticipantConnected, onParticipantConnected)
|
||||||
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
liveKitRoom.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
|
||||||
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
liveKitRoom.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||||
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
liveKitRoom.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||||
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
liveKitRoom.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
room.connect(server_url, participant_token, {maxRetries: 0}),
|
liveKitRoom.connect(server_url, participant_token, {maxRetries: 0}),
|
||||||
whenTimeout(5_000, {
|
whenTimeout(5_000, {
|
||||||
message: "Connection timed out. Please check your network and try again.",
|
message: "Connection timed out. Please check your network and try again.",
|
||||||
}),
|
}),
|
||||||
whenAborted(signal),
|
whenAborted(signal),
|
||||||
])
|
])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
room.disconnect()
|
liveKitRoom.disconnect()
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
|
|
||||||
participantPubkeyMap.set(new Map())
|
participantPubkeyMap.set(new Map())
|
||||||
addParticipant(room.localParticipant.identity)
|
addParticipant(liveKitRoom.localParticipant.identity)
|
||||||
for (const p of room.remoteParticipants.values()) {
|
for (const p of liveKitRoom.remoteParticipants.values()) {
|
||||||
addParticipant(p.identity)
|
addParticipant(p.identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
let muted = false
|
const muted = await setUpMicrophone(startMuted, preferredMicId, liveKitRoom.localParticipant)
|
||||||
try {
|
|
||||||
await room.localParticipant.setMicrophoneEnabled(true)
|
|
||||||
} catch (e) {
|
|
||||||
muted = true
|
|
||||||
pushToast({theme: "error", message: "Could not access microphone"})
|
|
||||||
}
|
|
||||||
|
|
||||||
currentVoiceSession.set({url, h, room, muted})
|
currentVoiceSession.set({url, h, room: liveKitRoom, muted})
|
||||||
voiceState.set("connected")
|
voiceState.set(VoiceState.Connected)
|
||||||
playJoinSound()
|
playJoinSound()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isActive()) voiceState.set("disconnected")
|
if (isActive()) voiceState.set(VoiceState.Disconnected)
|
||||||
|
if (e instanceof AbortError) return
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
} finally {
|
||||||
if (isActive()) joinAbortController = undefined
|
if (isActive()) joinAbortController = undefined
|
||||||
@@ -264,7 +287,7 @@ export const leaveVoiceRoom = async () => {
|
|||||||
const audio = new Audio("/leave-voice-room.mp3")
|
const audio = new Audio("/leave-voice-room.mp3")
|
||||||
audio.play().catch(() => {})
|
audio.play().catch(() => {})
|
||||||
|
|
||||||
voiceState.set("disconnected")
|
voiceState.set(VoiceState.Disconnected)
|
||||||
currentVoiceSession.set(undefined)
|
currentVoiceSession.set(undefined)
|
||||||
session.room.disconnect()
|
session.room.disconnect()
|
||||||
speakingParticipants.set([])
|
speakingParticipants.set([])
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
userSettingsValues,
|
userSettingsValues,
|
||||||
} from "@app/core/state"
|
} from "@app/core/state"
|
||||||
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
import VoiceWidget from "@app/components/VoiceWidget.svelte"
|
||||||
import {voiceState} from "@app/voice"
|
import {VoiceState, voiceState} from "@app/voice"
|
||||||
import {makeFeed} from "@app/core/requests"
|
import {makeFeed} from "@app/core/requests"
|
||||||
import {popKey} from "@lib/implicit"
|
import {popKey} from "@lib/implicit"
|
||||||
import {checked} from "@app/util/notifications"
|
import {checked} from "@app/util/notifications"
|
||||||
@@ -494,7 +494,7 @@
|
|||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if isVoiceRoom || $voiceState === "joining" || $voiceState === "connected"}
|
{#if isVoiceRoom || $voiceState === VoiceState.Joining || $voiceState === VoiceState.Connected}
|
||||||
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
|
<div class="hide-on-keyboard flex-shrink-0 p-2 md:hidden">
|
||||||
<VoiceWidget />
|
<VoiceWidget />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user